Redesign the content library (#677)

This commit is contained in:
Coracle-Bot 2026-09-30 17:21:48 +00:00 committed by hodlbod
parent 7234e4a375
commit 71cd7fbe55
31 changed files with 1637 additions and 428 deletions

View file

@ -885,12 +885,15 @@ resources members have collected.
Acceptance:
- Typing a term filters the shelf list to shelves whose title, description, or
topics match.
- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message
instead.
- Alice is offered "Create Shelf" and "Add a link", and "Add link" on someone
else's shelf, but not that shelf's "Edit shelf" or "Delete shelf".
- A shelf card carries its title, its description, how many links are on it and
who has contributed.
- Typing a term filters the shelves to those whose title, description or topics
match, and lists the links that match from any shelf.
- Opening a shelf shows its links; one that already holds a link still offers
"Add a link".
- Alice is offered "Create Shelf" and "Add a link" on someone else's shelf, and
not its "Edit shelf". Its "Delete shelf" opens a dialog saying only the author
or an admin can delete a shelf.
### US-054 — Curate the library
@ -899,13 +902,56 @@ good material first.
Acceptance:
- Creating a shelf with a title and description adds it to the list and
navigates into it; editing and deleting it from its menu update the list.
- Adding a pin with an external url shows it as a content card, and adding one
with a nostr note link renders that note as a rich embed.
- Creating a shelf with a title, description and topics opens it; editing it
from its menu updates its page, and deleting it returns to the library.
- Several links pasted at once each become a link of its own and are offered no
title or description between them: an external url renders as the link, a
nostr note link as the note it points at.
- "Add to Library" on a post (for example a poll) with a shelf selected adds it
as a pin visible in that shelf; with no shelves yet, the dialog prompts to
create one.
as a link on that shelf; with no shelves yet, the dialog prompts to create
one.
- Renaming a link from its menu republishes it under the new title.
- bob signs through a browser extension, so everything he publishes here is
signed by something outside the page.
### US-137 — Discuss a shelf and a link on it
As alice, I want to comment on a shelf and on one link on it, so that a question
about what was collected sits with what it is about.
Acceptance:
- Opening a link raises its own discussion, and a comment posted there appears
under it.
- A link's card counts the comments on it.
- The shelf has a discussion of its own, for anything about the collection
rather than about one link.
### US-138 — Manage a shelf as a list
As bob, I want to work through a shelf as a list, so that tidying up many links
costs one action rather than one each.
Acceptance:
- A shelf renders as cards or as a list, and the list gives every link a
checkbox with a select-all above them.
- Moving the selected links to another shelf files them there and takes them off
this one.
- Deleting the selected links for everyone asks for confirmation first.
### US-139 — See who built a shelf and where its links point
As alice, I want to see who contributed to a shelf and what each link points at,
so that a collection reads as somebody's work rather than a list of identifiers.
Acceptance:
- A shelf header counts its contributors, opens the roster of them, and each name
in it opens that person's profile.
- A link to a post reads as a short entity label in the list view, the same way
rendered content names one.
- Following that label lands on the post.
### US-055 — Create community content from a room

View file

@ -17,6 +17,8 @@ export type BootOptions = {
indexers?: string[]
spaces?: string[]
user?: TestUser
// Whether this user signs through the window.nostr injectNip07 installed, rather than with a key.
nip07?: boolean
// What this user's client already has in local storage, e.g. their room list.
events?: TrustedEvent[]
// Which paths this user has already read, keyed the way src/app/notifications.ts keys them.
@ -33,6 +35,7 @@ export const boot = async (
indexers = relays,
spaces = [],
user,
nip07 = false,
events = [],
checked,
path = "/",
@ -73,7 +76,7 @@ export const boot = async (
)
if (user) {
await injectSession(context, user)
await injectSession(context, user, nip07)
await injectEvents(context, events)
}

View file

@ -29,7 +29,8 @@ export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
})
await context.addInitScript(key => {
const call = (request: Nip07Call) => Reflect.get(window, key)(request)
// An extension answers over postMessage, which structured-clones every argument it is given.
const call = (request: Nip07Call) => Reflect.get(window, key)(structuredClone(request))
Object.assign(window, {
nostr: {

View file

@ -8,12 +8,16 @@ const TEST_SESSION_KEY = "__TEST_SESSION__"
const TEST_EVENTS_KEY = "__TEST_EVENTS__"
// The {method, data} shape @welshman/app's session handlers deserialize, installed before navigating.
export const injectSession = (context: BrowserContext, user: TestUser) =>
// A nip07 session carries no key and signs through whatever injectNip07 put on window.
export const injectSession = (context: BrowserContext, user: TestUser, nip07 = false) =>
context.addInitScript(
([key, session]) => {
Object.assign(window, {[key]: session})
},
[TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const,
[
TEST_SESSION_KEY,
nip07 ? {method: "nip07", data: {}} : {method: "nip01", data: {secret: user.secret}},
] as const,
)
// The local cache a returning user would boot with, loaded once the injected session is restored.

View file

@ -129,7 +129,7 @@ export type PageOptions = {
context?: BrowserContextOptions
// VITE_ values applied over the ones derived from the scenario's relays. See BootOptions in app/boot.ts.
env?: Record<string, string>
// A NIP-07 provider signing as this user, for a login that goes through an extension.
// A NIP-07 provider signing as this user, so that `as` signs the way an extension does.
nip07?: TestUser
// A WebLN provider on window, for a wallet that gets connected through an extension.
webln?: WebLnInfo
@ -276,6 +276,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
return boot(context, {
user,
nip07: Boolean(options.nip07),
path,
env: options.env,
checked: options.checked,

View file

@ -46,9 +46,9 @@ import type {TestUser} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent}
// A shelf is a card and its menu button side by side, so the menu is reached through their wrapper.
// A shelf card carries its cover, its counts and its menu, so the menu is reached through it.
const shelfCard = (page: Page, title: string) =>
page.getByRole("button", {name: new RegExp(title)}).locator("xpath=..")
page.locator('[data-component="PinboardItem"]').filter({hasText: title})
// Button swallows both the default and the propagation of a click, and the middle of a card is one.
const openCard = (card: Locator, title: string) => card.getByText(title).click()
@ -672,35 +672,58 @@ test("US-053 browse and search the library", async ({seed, as}) => {
const alice = await as(users.alice, libraryPath)
const term = alice.getByPlaceholder("Search library...")
await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible()
await expect(alice.getByRole("button", {name: /Recipes/})).toBeVisible()
await expect(shelfCard(alice, "Getting Started")).toBeVisible()
await expect(shelfCard(alice, "Recipes")).toBeVisible()
// A card says how much is on a shelf before anyone opens it.
await expect(shelfCard(alice, "Getting Started")).toContainText("1 link")
await expect(shelfCard(alice, "Recipes")).toContainText("0 links")
// A term matches a shelf on its description as well as its title.
await term.fill("new members")
await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible()
await expect(alice.getByRole("button", {name: /Recipes/})).toHaveCount(0)
await expect(shelfCard(alice, "Getting Started")).toBeVisible()
await expect(shelfCard(alice, "Recipes")).toHaveCount(0)
// A term also reaches past the shelves into the links on them.
await term.fill("handbook")
await expect(alice.getByRole("heading", {name: "1 link matches that"})).toBeVisible()
await term.fill("")
await alice.getByRole("button", {name: /Getting Started/}).click()
await alice.getByRole("link", {name: "Getting Started", exact: true}).click()
await expect(alice.getByRole("heading", {name: "Getting Started"})).toBeVisible()
await expect(alice.getByText("The Handbook")).toBeVisible()
await alice.getByRole("button", {name: /Recipes/}).click()
// A shelf that already holds a link still offers a way to add another.
await expect(alice.getByRole("button", {name: "Add a link"})).toBeVisible()
await alice.goto(libraryPath)
// An empty shelf says so rather than drawing a blank page.
await alice.getByRole("link", {name: "Recipes", exact: true}).click()
await expect(alice.getByText("This shelf doesn't have any links yet.")).toBeVisible()
// The library is the whole space's, so an ordinary member is offered the same controls.
await alice.goto(libraryPath)
await expect(alice.getByRole("button", {name: "Create Shelf"})).toBeVisible()
await expect(alice.getByRole("button", {name: "Add a link"})).toBeVisible()
// Someone else's shelf is hers to add to and not to rewrite.
await shelfCard(alice, "Recipes").getByRole("button", {name: "More options"}).click()
// A shelf is edited from its own page, and someone else's is hers to add to and not to rewrite.
await alice.getByRole("link", {name: "Recipes", exact: true}).click()
await alice.getByRole("button", {name: "Shelf options"}).click()
await expect(alice.getByRole("button", {name: "Add link", exact: true})).toBeVisible()
await expect(alice.getByRole("button", {name: "Edit shelf"})).toHaveCount(0)
await expect(alice.getByRole("button", {name: "Delete shelf"})).toHaveCount(0)
// Deleting is offered and says why it is refused, rather than going missing.
await alice.getByRole("button", {name: "Delete shelf"}).click()
await expect(dialog(alice, "You can't delete this shelf")).toContainText(
"or an admin of this space, can delete it",
)
})
test("US-054 curate the library", async ({seed, as}) => {
@ -738,8 +761,8 @@ test("US-054 curate the library", async ({seed, as}) => {
const libraryPath = `${spacePath(url)}/library`
const pollPath = `${spacePath(url)}/polls/${poll.id}`
// Curating is nobody's privilege here, so this is an ordinary member doing all of it.
const page = await as(users.bob, libraryPath)
// An ordinary member curates, signing through an extension, which clones what it is handed.
const page = await as(users.bob, libraryPath, {nip07: users.bob})
const pollCard = page
.locator(".card.z-feature")
@ -751,41 +774,50 @@ test("US-054 curate the library", async ({seed, as}) => {
await shelfForm.getByPlaceholder("Shelf title").fill("Reading List")
await shelfForm.getByPlaceholder("What's this shelf about?").fill("Things worth reading")
await shelfForm.getByPlaceholder("Add a topic...").fill("reading")
await shelfForm.getByPlaceholder("Add a topic...").press("Enter")
await shelfForm.getByRole("button", {name: "Save changes"}).click()
// Creating a shelf drops its author straight into it.
await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute(
"aria-pressed",
"true",
)
// Creating a shelf drops its author straight onto it.
await expect(page.getByRole("heading", {name: "Reading List"})).toBeVisible()
await expect(page.getByText("#reading")).toBeVisible()
// An external url becomes a card of its own...
await page.getByRole("button", {name: "Add a link"}).click()
const externalLink = dialog(page, "Add Link")
await externalLink.getByPlaceholder("URL or nevent...").fill("https://handbook.test/style-guide")
await externalLink.getByPlaceholder("Optional title").fill("The Style Guide")
await externalLink.getByRole("button", {name: "Add link"}).click()
await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible()
// ...and a nostr link becomes the note it points at.
// Several links go in at once, each as a card of its own.
const nevent = nip19.neventEncode({id: message.id, kind: MESSAGE, relays: [url]})
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
await page.getByRole("button", {name: "Add link", exact: true}).click()
await page.getByRole("button", {name: "Add a link"}).click()
const nostrLink = dialog(page, "Add Link")
const links = dialog(page, "Add a Link")
await nostrLink.getByPlaceholder("URL or nevent...").fill(`nostr:${nevent}`)
await nostrLink.getByPlaceholder("Optional title").fill("Yesterday's incident")
await nostrLink.getByRole("button", {name: "Add link"}).click()
await links
.getByPlaceholder("URL or nevent...")
.fill(`https://handbook.test/style-guide\nnostr:${nevent}`)
// A title and a description are about one link, so a batch is offered neither.
await expect(links.getByPlaceholder("Optional title")).toHaveCount(0)
await links.getByRole("button", {name: "Add 2 links"}).click()
await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible()
await expect(page.getByText("the deploy broke again")).toBeVisible()
// Editing the shelf from its menu updates the list.
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
// Renaming a link republishes the pin the menu was opened from.
const styleGuide = page
.locator('[data-component="PinItem"]')
.filter({hasText: "handbook.test/style-guide"})
await menuButton(styleGuide).click()
await page.getByRole("button", {name: "Edit Link"}).click()
const linkEdit = dialog(page, "Edit Link")
await linkEdit.getByPlaceholder("Optional title").fill("House style guide")
await linkEdit.getByRole("button", {name: "Save changes"}).click()
await expect(page.getByRole("alert")).toContainText("Link updated!")
await expect(page.getByText("House style guide")).toBeVisible()
// Editing the shelf from its menu updates the page it was edited from.
await page.getByRole("button", {name: "Shelf options"}).click()
await page.getByRole("button", {name: "Edit shelf"}).click()
const shelfEdit = dialog(page, "Edit Shelf")
@ -793,9 +825,7 @@ test("US-054 curate the library", async ({seed, as}) => {
await shelfEdit.getByPlaceholder("What's this shelf about?").fill("Things worth reading twice")
await shelfEdit.getByRole("button", {name: "Save changes"}).click()
await expect(page.getByRole("button", {name: /Reading List/})).toContainText(
"Things worth reading twice",
)
await expect(page.getByText("Things worth reading twice")).toBeVisible()
// A post filed from its own menu lands on the shelf that was picked for it.
await page.goto(pollPath)
@ -803,7 +833,7 @@ test("US-054 curate the library", async ({seed, as}) => {
await page.getByRole("button", {name: "Add to Library"}).click()
await page.getByRole("button", {name: /Reading List/}).click()
const fromPoll = dialog(page, "Add Link")
const fromPoll = dialog(page, "Add a Link")
await fromPoll.getByPlaceholder("Optional title").fill("Standup poll")
await fromPoll.getByRole("button", {name: "Add link"}).click()
@ -811,17 +841,297 @@ test("US-054 curate the library", async ({seed, as}) => {
await expect(page.getByRole("alert")).toContainText("Link added!")
await page.goto(libraryPath)
await page.getByRole("button", {name: /Reading List/}).click()
await expect(shelfCard(page, "Reading List")).toContainText("3 links")
await page.getByRole("link", {name: "Reading List", exact: true}).click()
await expect(page.getByText("Standup poll")).toBeVisible()
await expect(page.getByText("Should we move standup to 10?")).toBeVisible()
// Deleting the shelf from its menu takes it off the list.
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
// Deleting a shelf from its own page takes it off the library and puts its curator back there.
await page.getByRole("button", {name: "Shelf options"}).click()
await page.getByRole("button", {name: "Delete shelf"}).click()
await page.getByRole("button", {name: "Confirm"}).click()
await expect(page.getByRole("button", {name: /Reading List/})).toHaveCount(0)
await expect(page).toHaveURL(libraryPath)
await expect(shelfCard(page, "Reading List")).toHaveCount(0)
})
test("US-137 discuss a shelf and a link on it", async ({seed, as}) => {
let shelf!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.alice, "general")
space.profile(user.admin, {name: "Ada Admin"})
space.profile(user.alice, {name: "Alice Anderson"})
shelf = space.event(
user.admin,
() =>
space
.kind(Pinboard)
.writer()
.setIdentifier()
.setTitle("Getting Started")
.setDescription("Reading for new members")
.setCollaborative(true)
.renderTemplate(),
at(2, HOUR),
)
space.event(
user.admin,
() =>
space
.kind(Pin)
.writer()
.setIdentifier()
.addBoard(getAddress(shelf.event))
.setTitle("The Handbook")
.setExternal("https://handbook.test/start-here")
.renderTemplate(),
at(1, HOUR),
)
})
const {url} = scenario.space("space")
const alice = await as(users.alice, `${spacePath(url)}/library`)
await alice.getByRole("link", {name: "Getting Started", exact: true}).click()
// A link carries a discussion of its own, so asking about one happens where it sits.
await alice.getByRole("button", {name: "Discuss"}).click()
const detail = dialog(alice, "Link")
// The link's own title stands where the heading was, so nothing says "Link" twice.
await expect(detail.getByText("The Handbook")).toBeVisible()
await detail.getByRole("button", {name: "Comment"}).click()
const linkComment = detail.locator("form")
await noteEditor(linkComment).pressSequentially("Is this still the current version?")
await linkComment.getByRole("button", {name: "Comment"}).click()
await expect(
detail
.locator('[data-component="Comment"]')
.filter({hasText: "Is this still the current version?"}),
).toBeVisible()
await detail.getByRole("button", {name: "Close dialog"}).click()
// The card counts it, so the next person can see there is something to read.
await expect(alice.getByRole("button", {name: "1 comment"})).toBeVisible()
// The shelf has one too, for anything about the collection rather than about one link.
await alice.getByRole("button", {name: "Add a comment"}).click()
const shelfComment = alice.locator("form").filter({has: alice.locator(".note-editor")})
await noteEditor(shelfComment).pressSequentially("Should we split this in two?")
await shelfComment.getByRole("button", {name: "Comment"}).click()
await expect(
alice.locator('[data-component="Comment"]').filter({hasText: "Should we split this in two?"}),
).toBeVisible()
})
test("US-138 manage a shelf as a list", async ({seed, as}) => {
let readingList!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barker"})
readingList = space.event(
user.bob,
() =>
space
.kind(Pinboard)
.writer()
.setIdentifier()
.setTitle("Reading List")
.setCollaborative(true)
.renderTemplate(),
at(3, HOUR),
)
space.event(
user.bob,
() =>
space
.kind(Pinboard)
.writer()
.setIdentifier()
.setTitle("Archive")
.setCollaborative(true)
.renderTemplate(),
at(3, HOUR),
)
for (const [title, href] of [
["The Handbook", "https://handbook.test/start-here"],
["The Style Guide", "https://handbook.test/style-guide"],
]) {
space.event(
user.bob,
() =>
space
.kind(Pin)
.writer()
.setIdentifier()
.addBoard(getAddress(readingList.event))
.setTitle(title)
.setExternal(href)
.renderTemplate(),
at(1, HOUR),
)
}
})
const {url} = scenario.space("space")
const libraryPath = `${spacePath(url)}/library`
const page = await as(users.bob, libraryPath)
await page.getByRole("link", {name: "Reading List", exact: true}).click()
await page.getByRole("button", {name: "List"}).click()
await page.getByRole("checkbox", {name: "Select The Handbook"}).check()
await page.getByRole("checkbox", {name: "Select The Style Guide"}).check()
await expect(page.getByText("2 selected")).toBeVisible()
// Moving files them on the destination and takes them off here, in one action.
await page.getByRole("button", {name: "Bulk actions"}).click()
await page.getByRole("button", {name: "Move to Archive"}).click()
await expect(page.getByRole("alert")).toContainText("2 moved.")
await expect(page.getByRole("button", {name: "The Handbook"})).toHaveCount(0)
await page.goto(libraryPath)
await expect(shelfCard(page, "Reading List")).toContainText("0 links")
await expect(shelfCard(page, "Archive")).toContainText("2 links")
// Select all reaches everything on the shelf, and deleting for everyone asks first.
await page.getByRole("link", {name: "Archive", exact: true}).click()
await page.getByRole("button", {name: "List"}).click()
await page.getByRole("checkbox", {name: "Select all"}).check()
await expect(page.getByText("2 selected")).toBeVisible()
await page.getByRole("button", {name: "Bulk actions"}).click()
await page.getByRole("button", {name: "Delete links"}).click()
await page.getByRole("button", {name: "Confirm"}).click()
await expect(page.getByRole("alert")).toContainText("2 deleted.")
})
test("US-139 see who built a shelf and where its links point", async ({seed, as}) => {
let shelf!: Seeded
let message!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.alice, "general")
space.profile(user.admin, {name: "Ada Admin"})
space.profile(user.alice, {name: "Alice Anderson"})
message = space.message(user.alice, "general", "the anchor came loose again", at(30, MINUTE))
shelf = space.event(
user.admin,
() =>
space
.kind(Pinboard)
.writer()
.setIdentifier()
.setTitle("Field Notes")
.setCollaborative(true)
.renderTemplate(),
at(3, HOUR),
)
space.event(
user.admin,
() =>
space
.kind(Pin)
.writer()
.setIdentifier()
.addBoard(getAddress(shelf.event))
.setTitle("The Handbook")
.setExternal("https://handbook.test/start-here")
.renderTemplate(),
at(2, HOUR),
)
// A link to a post carries no words of its own, so the post is all the row has to show.
space.event(
user.alice,
() =>
space
.kind(Pin)
.writer()
.setIdentifier()
.addBoard(getAddress(shelf.event))
.setEvent(message.id, space.url)
.renderTemplate(),
at(1, HOUR),
)
})
const {url} = scenario.space("space")
const libraryPath = `${spacePath(url)}/library`
const alice = await as(users.alice, libraryPath)
await alice.getByRole("link", {name: "Field Notes", exact: true}).click()
// A card embeds the post it points at, which is also proof the post has loaded.
await expect(alice.getByText("the anchor came loose again")).toBeVisible()
await alice.getByRole("button", {name: "List"}).click()
const untitled = alice
.locator('[data-component="PinList"] > div')
.filter({hasText: "Untitled link"})
const pointer = untitled.getByRole("button", {name: /^nevent1/})
// A row names a post the way the rest of the app does, rather than printing its bech32.
await expect(pointer).toHaveText(/^nevent1[a-z0-9]{9}…$/)
await pointer.click()
await expect(alice).toHaveURL(new RegExp(`${roomPath(url, "general")}\\?`))
await expect(alice.getByText("the anchor came loose again")).toBeVisible()
await alice.goBack()
// Whoever made the shelf or added to it is a contributor, and the header opens the roster.
await alice.getByRole("button", {name: /2 contributors/}).click()
const contributors = dialog(alice, "Contributors")
await expect(contributors.getByText("Ada Admin")).toBeVisible()
await expect(contributors.getByText("Alice Anderson")).toBeVisible()
// A name in the roster is a way through to that person.
await contributors.getByRole("button", {name: "Ada Admin", exact: true}).click()
await expect(dialog(alice, "Profile details")).toBeVisible()
})
test("US-055 create community content from a room", async ({seed, as}) => {

View file

@ -1,6 +1,6 @@
<script lang="ts">
import {page} from "$app/state"
import type {PinboardReader} from "@welshman/domain"
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 ShareCircle from "@assets/icons/share-circle.svg?dataurl"
@ -9,12 +9,15 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Notice from "@lib/components/Notice.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import PinSave from "@app/components/PinSave.svelte"
import PinboardEdit from "@app/components/PinboardEdit.svelte"
import {deletes, relays, user} from "@app/core"
import {user} from "@app/core"
import {deriveSpacePermissions} from "@app/management"
import {deleteLibraryEvent} from "@app/pinboards"
import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal"
import {navigate, pushModal} from "@app/modal"
import {makeLibraryPath} from "@app/routes"
import {pushToast} from "@app/toast"
type Props = {
@ -25,26 +28,28 @@
const {url, board, onClick}: Props = $props()
const permissions = deriveSpacePermissions(url)
const isAuthor = $derived(board.event.pubkey === $user.pubkey)
const canDelete = $derived(isAuthor || $permissions.deleteContent)
const showInfo = () => pushModal(EventInfo, {url, event: board.event})
const share = () => shareEvent(url, "Shelf", board.event)
const addLink = () => pushModal(PinSave, {url, address: board.address()})
const edit = () => pushModal(PinboardEdit, {url, board})
const deleteBoard = async () => {
try {
const protect = await $relays.hasNip(url, 70)
const command = await $deletes.deleteEvent(board.event, writer =>
writer.setProtected(protect),
)
const error = (await command.publishToRelays([url]).settled()).getError()
const error = await deleteLibraryEvent(url, board.event)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Shelf deleted!"})
if (page.url.pathname === makeLibraryPath(url, board.address())) {
navigate(makeLibraryPath(url))
}
}
} catch (e) {
console.error(e)
@ -58,6 +63,12 @@
message: `Delete "${board.title() || "this shelf"}"?`,
confirm: deleteBoard,
})
const explainDelete = () =>
pushModal(Notice, {
title: "You can't delete this shelf",
message: "Only the person who made a shelf, or an admin of this space, can delete it.",
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
@ -73,24 +84,18 @@
Share to chat
</Button>
</li>
<li>
<Button onclick={addLink}>
<Icon icon={AddCircle} />
Add link
</Button>
</li>
{#if board.event.pubkey === $user.pubkey}
{#if isAuthor}
<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>
{/if}
<li>
<Button class="text-error" onclick={canDelete ? confirmDelete : explainDelete}>
<Icon icon={TrashBin} />
Delete shelf
</Button>
</li>
</ul>

View file

@ -4,6 +4,7 @@
import {Note} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@ -129,7 +130,10 @@
<div class="flex justify-end gap-2">
<Button class="button button-link" onclick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" class="button button-primary button-sm" disabled={$uploading || loading}>
<Spinner {loading} reserveSpace>
<Spinner size="sm" {loading}>
{#if !loading}
<Icon icon={Plane} size={4} />
{/if}
{#if parent}
Reply
{:else}

View file

@ -30,8 +30,8 @@
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
import {
getUrlContentType,
isImageUrl,
AUDIO_CONTENT_TYPES,
IMAGE_CONTENT_TYPES,
VIDEO_CONTENT_TYPES,
} from "@app/content"
import {isRoomId} from "@app/rooms"
@ -62,8 +62,7 @@
const isVideo = Boolean(url.match(/\.(mov|webm|mp4)$/)) || VIDEO_CONTENT_TYPES.includes(fileType)
const isImage =
Boolean(url.match(/\.(jpe?g|png|gif|webp)$/)) || IMAGE_CONTENT_TYPES.includes(fileType)
const isImage = isImageUrl(url, event)
const getVideoPoster = (videoUrl: string): string | undefined => {
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {

View file

@ -18,9 +18,10 @@
url?: string
context: FeedContext
columnClass?: string
noun?: string
}
const {event, url, context, columnClass}: Props = $props()
const {event, url, context, columnClass, noun = "post"}: Props = $props()
const relays = removeUndefined([url])
const filters = getCommentFiltersForRoot([event])
@ -54,7 +55,7 @@
<div class={cx("flex w-full flex-wrap items-center justify-between gap-2 py-3", columnClass)}>
<h2 class="text-lg font-bold">
{nodes.length === 0
? "Discussion about this post"
? `Discussion about this ${noun}`
: `${$comments.length} ${$comments.length === 1 ? "comment" : "comments"}`}
</h2>
{#if url && replyTo?.id !== event.id}

View file

@ -38,7 +38,9 @@
let element: HTMLElement
type WrapperProps = ComponentProps<typeof Dialog>
const instances: Record<string, {instance: ReturnType<typeof mount>; props: WrapperProps}> = {}
type WrapperState = {active: boolean; restoreFocus: boolean}
const instances: Record<string, {instance: ReturnType<typeof mount>; state: WrapperState}> = {}
$effect(() => {
const stack = getModalStack()
@ -47,11 +49,11 @@
const ids = stack.map(({id}) => id)
const activeId = ids.at(-1)
for (const [id, {instance, props}] of Object.entries(instances)) {
props.active = id === activeId
for (const [id, {instance, state}] of Object.entries(instances)) {
state.active = id === activeId
if (!ids.includes(id)) {
props.restoreFocus = !activeId || Boolean(instances[activeId])
state.restoreFocus = !activeId || Boolean(instances[activeId])
unmount(instance, {outro: true})
delete instances[id]
}
@ -64,23 +66,29 @@
const {options, component, props} = item
const wrapper = options.drawer ? Drawer : Dialog
const wrapperProps = $state({
active: item.id === activeId,
// Only the two props this container changes are reactive, so nothing proxies a modal's own.
const state = $state({active: item.id === activeId, restoreFocus: true})
const wrapperProps: WrapperProps = {
get active() {
return state.active
},
get restoreFocus() {
return state.restoreFocus
},
onClose: closeModal,
label: options.label,
restoreFocus: true,
size: options.size,
noEscape: options.noEscape,
fullscreen: options.fullscreen,
children: {component, props},
})
}
instances[item.id] = {
instance: mount(wrapper as Component, {
target: element,
props: wrapperProps,
}),
props: wrapperProps,
state,
}
}
})

View file

@ -0,0 +1,31 @@
<script lang="ts">
import {removeUndefined} from "@welshman/lib"
import type {PinReader} from "@welshman/domain"
import Content from "@app/components/Content.svelte"
import PinContentEvent from "@app/components/PinContentEvent.svelte"
import {pinToReference} from "@app/pinboards"
type Props = {
url: string
pin: PinReader
class?: string
}
const {url, pin, ...props}: Props = $props()
const reference = $derived(pin.reference())
</script>
<div class="{props.class} flex flex-col gap-2">
{#if pin.content()}
<Content event={{content: pin.content(), tags: []}} {url} />
{/if}
{#if reference?.type === "event" || reference?.type === "address"}
<PinContentEvent
{url}
value={reference.type === "event" ? reference.id : reference.address}
relays={removeUndefined([reference.relay, url])} />
{:else}
<Content event={{content: pinToReference(pin), tags: []}} {url} />
{/if}
</div>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import {onDestroy} from "svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
import {events} from "@app/core"
import {makeFeedContext} from "@app/feeds"
@ -22,7 +22,8 @@
{#if $event}
<NoteItem {url} {context} event={$event} />
{:else}
<p class="flex justify-center py-8">
<Spinner loading>Loading event...</Spinner>
</p>
<LoadingFallback
class="py-8"
loadingMessage="Loading post..."
failureMessage="Failed to load this post." />
{/if}

View file

@ -0,0 +1,99 @@
<script lang="ts">
import {onMount} from "svelte"
import {uniq} from "@welshman/lib"
import {getCommentFiltersForRoot} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import type {PinReader} from "@welshman/domain"
import {normalizeTopic} from "@lib/util"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import CommentCompose from "@app/components/CommentCompose.svelte"
import CommentTree from "@app/components/CommentTree.svelte"
import ContentActions from "@app/components/ContentActions.svelte"
import PinContent from "@app/components/PinContent.svelte"
import PinSave from "@app/components/PinSave.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {events, network} from "@app/core"
import type {FeedContext} from "@app/feeds"
import {buildCommentTree} from "@app/social"
type Props = {
url: string
pin: PinReader
context: FeedContext
}
const {url, pin, context}: Props = $props()
const filters = getCommentFiltersForRoot([pin.event])
const comments = $events.asc(filters).$
const topics = $derived(uniq(pin.topics().map(normalizeTopic)))
const nodes = $derived(buildCommentTree(pin.event, $comments))
const setReplyTo = (comment?: TrustedEvent) => {
replyTo = comment
}
const clearReplyTo = () => setReplyTo(undefined)
const replyToPin = () => setReplyTo(pin.event)
let replyTo: TrustedEvent | undefined = $state()
onMount(() => {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
return () => controller.abort()
})
</script>
{#snippet topicLabels()}
{#each topics as topic (topic)}
<span class="badge badge-neutral badge-sm font-normal">#{topic}</span>
{/each}
{/snippet}
<Modal label="Link">
<ModalBody>
<div class="border-line flex flex-col gap-3 border-b pb-4">
{#if pin.title()}
<strong class="text-2xl">{pin.title()}</strong>
{/if}
<PinContent {url} {pin} />
<p class="text-sm opacity-75">
Added by <ProfileLink pubkey={pin.event.pubkey} {url} />
</p>
<div class="flex flex-wrap items-center justify-end gap-2">
{#if replyTo?.id !== pin.event.id}
<Button class="button button-neutral button-xs" onclick={replyToPin}>
<Icon size={4} icon={Reply} />
Comment
</Button>
{/if}
<ContentActions
noun="Link"
editForm={PinSave}
labels={topics.length > 0 ? topicLabels : undefined}
{url}
event={pin.event}
{context} />
</div>
{#if replyTo?.id === pin.event.id}
<CommentCompose {url} event={pin.event} onCancel={clearReplyTo} onSubmit={clearReplyTo} />
{/if}
</div>
<div class="flex flex-col">
{#each nodes as node (node.comment.id)}
<CommentTree {node} root={pin.event} {replyTo} {setReplyTo} {url} {context} />
{/each}
{#if nodes.length === 0}
<p class="py-2 text-sm opacity-75">No comments yet.</p>
{/if}
</div>
</ModalBody>
</Modal>

View file

@ -1,71 +1,84 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {removeUndefined} from "@welshman/lib"
import {Address} from "@welshman/util"
import {derived} from "svelte/store"
import {filter, formatTimestampRelative, spec, uniq} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import type {PinReader} from "@welshman/domain"
import MenuButton from "@lib/components/MenuButton.svelte"
import Content from "@app/components/Content.svelte"
import PinContentEvent from "@app/components/PinContentEvent.svelte"
import PinMenu from "@app/components/PinMenu.svelte"
import {pinToReference} from "@app/pinboards"
import {normalizeTopic} from "@lib/util"
import ChatRoundDots from "@assets/icons/chat-round-dots.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Cv from "@lib/components/Cv.svelte"
import Icon from "@lib/components/Icon.svelte"
import ContentActions from "@app/components/ContentActions.svelte"
import PinContent from "@app/components/PinContent.svelte"
import PinDetail from "@app/components/PinDetail.svelte"
import PinSave from "@app/components/PinSave.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import type {FeedContext} from "@app/feeds"
import {pushModal} from "@app/modal"
type Props = {
url: string
pin: PinReader
minimal?: boolean
class?: string
context: FeedContext
card?: boolean
}
const {url, pin, minimal, ...props}: Props = $props()
const {url, pin, context, card = true}: Props = $props()
// Encoded as nostr: entities (i tags are already urls) so Content parses them into embeds.
const reference = $derived(pin.reference())
const topics = $derived(uniq(pin.topics().map(normalizeTopic)))
const related = context.related(pin.event)
const comments = derived(related, $related => filter(spec({kind: COMMENT}), $related))
const content = $derived.by(() => {
if (reference?.type === "event") {
return "nostr:" + nip19.neventEncode({id: reference.id, relays: [url]})
}
if (reference?.type === "address") {
const {kind, pubkey, identifier} = Address.from(reference.address)
return "nostr:" + nip19.naddrEncode({kind, pubkey, identifier, relays: [url]})
}
return pinToReference(pin)
})
const discuss = () => pushModal(PinDetail, {url, pin, context}, {size: "large"})
</script>
<div class="{props.class} flex h-full flex-col gap-2 {minimal ? '' : 'card relative'}">
{#if !minimal}
<div class="absolute right-2 top-2 z-feature">
<MenuButton
class="button button-neutral button-sm button-square"
aria-label="More options"
iconSize={4}
component={PinMenu}
componentProps={{url, pin}} />
</div>
{/if}
{#snippet topicLabels()}
{#each topics as topic (topic)}
<span class="badge badge-neutral badge-sm font-normal">#{topic}</span>
{/each}
{/snippet}
{#snippet body()}
<div class="flex min-w-0 items-center gap-2 text-xs text-content-muted">
<ProfileCircle pubkey={pin.event.pubkey} {url} size={4} />
<span class="min-w-0 truncate">
<ProfileLink unstyled pubkey={pin.event.pubkey} {url} />
</span>
<span class="whitespace-nowrap">{formatTimestampRelative(pin.event.created_at)}</span>
</div>
{#if pin.title()}
<strong class="truncate min-w-0 pr-8">{pin.title()}</strong>
<strong class="min-w-0">{pin.title()}</strong>
{/if}
{#if pin.content()}
<Content event={{content: pin.content(), tags: []}} {url} />
{/if}
{#if reference?.type === "event" || reference?.type === "address"}
<PinContentEvent
<PinContent {url} {pin} />
<div class="mt-auto flex flex-wrap items-center justify-end gap-2 pt-1">
<Button class="button button-neutral button-xs" onclick={discuss}>
<Icon size={4} icon={ChatRoundDots} />
{#if $comments.length > 0}
{$comments.length}
{$comments.length === 1 ? "comment" : "comments"}
{:else}
Discuss
{/if}
</Button>
<ContentActions
noun="Link"
editForm={PinSave}
labels={topics.length > 0 ? topicLabels : undefined}
{url}
value={reference.type === "event" ? reference.id : reference.address}
relays={removeUndefined([reference.relay, url])} />
{:else}
<Content event={{content, tags: []}} {url} />
{/if}
{#if !minimal && pin.topics().length > 0}
<div class="mt-auto flex flex-wrap gap-1">
{#each pin.topics() as topic (topic)}
<span class="badge badge-neutral">#{topic}</span>
{/each}
</div>
{/if}
</div>
event={pin.event}
{context} />
</div>
{/snippet}
{#if card}
<Cv data-component="PinItem" class="card flex h-full flex-col gap-2">
{@render body()}
</Cv>
{:else}
<Cv
data-component="PinItem"
class="flex flex-col gap-2 border-b border-solid border-line px-4 py-4">
{@render body()}
</Cv>
{/if}

View file

@ -0,0 +1,184 @@
<script lang="ts">
import {formatTimestampRelative, uniq} from "@welshman/lib"
import type {PinReader} from "@welshman/domain"
import {normalizeTopic} from "@lib/util"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Icon from "@lib/components/Icon.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Notice from "@lib/components/Notice.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PinDetail from "@app/components/PinDetail.svelte"
import PinReference from "@app/components/PinReference.svelte"
import PinSelectionMenu from "@app/components/PinSelectionMenu.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {user} from "@app/core"
import type {FeedContext} from "@app/feeds"
import {deriveSpacePermissions} from "@app/management"
import {
deletePins,
deriveShelves,
displayPin,
movePinsToShelf,
removePinsFromShelf,
} from "@app/pinboards"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
address: string
pins: PinReader[]
context: FeedContext
}
const {url, address, pins, context}: Props = $props()
const shelves = deriveShelves(url)
const permissions = deriveSpacePermissions(url)
const selected = $derived(pins.filter(pin => selection.has(pin.event.id)))
const allSelected = $derived(pins.length > 0 && selected.length === pins.length)
const destinations = $derived($shelves.filter(shelf => shelf.address() !== address))
// A pin is addressable, so shelving it is a rewrite only its author can publish.
const canShelve = $derived(selected.every(pin => pin.event.pubkey === $user.pubkey))
const canDelete = $derived(canShelve || $permissions.deleteContent)
const toggle = (pin: PinReader) => () => {
const next = new Set(selection)
if (next.has(pin.event.id)) {
next.delete(pin.event.id)
} else {
next.add(pin.event.id)
}
selection = next
}
const toggleAll = () => {
selection = allSelected ? new Set() : new Set(pins.map(pin => pin.event.id))
}
const open = (pin: PinReader) => () => pushModal(PinDetail, {url, pin, context}, {size: "large"})
const run = async (action: () => Promise<(string | undefined)[]>, message: string) => {
loading = true
try {
const error = (await action()).find(Boolean)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message})
selection = new Set()
}
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Some of those links could not be changed."})
} finally {
loading = false
}
}
const remove = () =>
run(
() => removePinsFromShelf(url, selected, address),
`${selected.length} taken off the shelf.`,
)
const confirmDelete = () =>
pushModal(Confirm, {
title: "Delete Links",
message: `Delete ${selected.length} ${selected.length === 1 ? "link" : "links"} for everyone?`,
confirm: () => run(() => deletePins(url, selected), `${selected.length} deleted.`),
})
const move = (destination: string) => {
const moving = selected
run(() => movePinsToShelf(url, moving, address, destination), `${moving.length} moved.`)
}
const explainShelve = () =>
pushModal(Notice, {
title: "You can't move those links",
message: "Only the person who added a link can move it or take it off a shelf.",
})
const explainDelete = () =>
pushModal(Notice, {
title: "You can't delete those links",
message: "Only the person who added a link, or an admin of this space, can delete it.",
})
let selection = $state(new Set<string>())
let loading = $state(false)
</script>
<div data-component="PinList" class="flex flex-col bg-surface">
<div
class="flex h-14 items-center gap-3 border-b border-solid border-line bg-surface-less px-4 text-sm">
<label class="flex items-center gap-2">
<input type="checkbox" class="checkbox" checked={allSelected} onchange={toggleAll} />
Select all
</label>
{#if selected.length > 0}
<strong class="ml-auto">{selected.length} selected</strong>
<MenuButton
class="button button-neutral button-sm button-square"
aria-label="Bulk actions"
disabled={loading}
component={PinSelectionMenu}
componentProps={{
shelves: destinations,
onMove: canShelve ? move : explainShelve,
onRemove: canShelve ? remove : explainShelve,
onDelete: canDelete ? confirmDelete : explainDelete,
}}>
{#if loading}
<Spinner size="sm" />
{:else}
<Icon icon={MenuDots} size={4} />
{/if}
</MenuButton>
{/if}
</div>
{#each pins as pin (pin.event.id)}
<div class="flex items-start gap-3 border-b border-solid border-line px-4 py-3">
<input
type="checkbox"
class="checkbox mt-1"
aria-label="Select {displayPin(pin)}"
checked={selection.has(pin.event.id)}
onchange={toggle(pin)} />
<div class="flex min-w-0 grow flex-col gap-1">
<Button class="min-w-0 wrap-break-word text-left" onclick={open(pin)}>
{displayPin(pin)}
</Button>
{#if pin.title() && pin.content()}
<p class="line-clamp-2 wrap-break-word text-sm text-content-muted">{pin.content()}</p>
{/if}
<div class="flex min-w-0 text-sm">
<PinReference {url} {pin} />
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-content-muted">
<span class="flex min-w-0 items-center gap-1.5">
<ProfileCircle pubkey={pin.event.pubkey} {url} size={4} />
<span class="truncate">
<ProfileLink unstyled pubkey={pin.event.pubkey} {url} />
</span>
</span>
<span>{formatTimestampRelative(pin.event.created_at)}</span>
{#each uniq(pin.topics().map(normalizeTopic)) as topic (topic)}
<span class="badge badge-sm rounded-full font-normal">#{topic}</span>
{/each}
</div>
</div>
</div>
{/each}
</div>

View file

@ -1,84 +0,0 @@
<script lang="ts">
import type {PinReader} from "@welshman/domain"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import {errorMessage} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import PinSave from "@app/components/PinSave.svelte"
import {deletes, relays, user} from "@app/core"
import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
pin: PinReader
onClick: () => void
}
const {url, pin, onClick}: Props = $props()
const showInfo = () => pushModal(EventInfo, {url, event: pin.event})
const share = () => shareEvent(url, "Link", pin.event)
const edit = () => pushModal(PinSave, {url, pin})
const deletePin = async () => {
try {
const protect = await $relays.hasNip(url, 70)
const command = await $deletes.deleteEvent(pin.event, writer => writer.setProtected(protect))
const error = (await command.publishToRelays([url]).settled()).getError()
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Link deleted!"})
}
} catch (e) {
console.error(e)
pushToast({theme: "error", message: errorMessage(e)})
}
}
const confirmDelete = () =>
pushModal(Confirm, {
title: "Delete Link",
message: "Delete this link?",
confirm: deletePin,
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
<li>
<Button onclick={showInfo}>
<Icon icon={Code2} />
Event details
</Button>
</li>
<li>
<Button onclick={share}>
<Icon icon={ShareCircle} />
Share to chat
</Button>
</li>
{#if pin.event.pubkey === $user.pubkey}
<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>
{/if}
</ul>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import type {PinReader} from "@welshman/domain"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentQuote from "@app/components/ContentQuote.svelte"
import {pinToReference} from "@app/pinboards"
type Props = {
url: string
pin: PinReader
}
const {url, pin}: Props = $props()
const reference = $derived(pin.reference())
const quote = $derived.by(() => {
if (reference?.type === "event" || reference?.type === "address") {
const raw = pinToReference(pin)
return {raw, value: nip19.decode(raw).data}
}
return undefined
})
</script>
{#if reference?.type === "external"}
<ContentLinkInline url={reference.id} event={pin.event} />
{:else if quote}
<ContentQuote inline {url} value={quote.value} raw={quote.raw} event={pin.event} />
{/if}

View file

@ -1,10 +1,13 @@
<script lang="ts">
import {writable} from "svelte/store"
import {randomId} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {Pin} from "@welshman/domain"
import {publish} from "@welshman/app"
import type {PinReader} from "@welshman/domain"
import {preventDefault, stopPropagation} from "@lib/html"
import {slide} from "@lib/transition"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import CloudUpload from "@assets/icons/cloud-upload.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@ -20,8 +23,8 @@
import RelayName from "@app/components/RelayName.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {command, writer} from "@app/core"
import {pinToReference, setPinReference} from "@app/pinboards"
import {command, reader, writer} from "@app/core"
import {pinToReference, setPinReference, uploadFilesAsReferences} from "@app/pinboards"
import {addressWriterToSpace} from "@app/rooms"
import {pushToast} from "@app/toast"
import {clearModals} from "@app/modal"
@ -30,53 +33,109 @@
url: string
address?: string
reference?: string
pin?: PinReader
event?: TrustedEvent
}
const {url, address, reference = "", pin}: Props = $props()
type Fields = {
title: string
content: string
topics: string[]
}
let title = $state(pin?.title() ?? "")
let topics = $state<string[]>(pin?.topics() ?? [])
let value = $state(pin ? pinToReference(pin) : reference)
let content = $state<string | object>(pin?.content() ?? "")
let loading = $state(false)
const {url, address, reference = "", event}: Props = $props()
const fileInputId = randomId()
const pin = event ? reader(Pin)(event) : undefined
const uploading = writable(false)
const back = () => history.back()
const submit = async () => {
const upload = async (files: File[]) => {
$uploading = true
try {
const {references: uploaded, errors} = await uploadFilesAsReferences(url, files)
value = [...references, ...uploaded].join("\n")
if (errors.length > 0) {
pushToast({theme: "error", message: errors.join(" ")})
}
} finally {
$uploading = false
}
}
const onFileChange = (e: Event) => {
if (e.target instanceof HTMLInputElement && e.target.files?.length) {
upload(Array.from(e.target.files))
e.target.value = ""
}
}
const onDrop = (e: Event) => {
dropActive = false
if (e instanceof DragEvent && e.dataTransfer?.files.length) {
upload(Array.from(e.dataTransfer.files))
}
}
const onDragOver = () => {
dropActive = true
}
const onDragLeave = () => {
dropActive = false
}
const savePin = async (target: string, fields: Fields) => {
const eventWriter = await addressWriterToSpace(
address ? writer(Pin).setIdentifier().addBoard(address) : writer(Pin, pin),
pin ? writer(Pin, pin) : writer(Pin).setIdentifier(),
url,
)
if (!setPinReference(eventWriter, value)) {
return "Please enter a valid URL or nostr link."
if (address && !pin) {
eventWriter.addBoard(address)
}
const ed = await editor
if (!setPinReference(eventWriter, target)) {
return `${target} is not a valid URL or nostr link.`
}
eventWriter
.setTitle(title)
.setTopics(topics)
.setContent(ed.getText({blockSeparator: "\n"}).trim())
eventWriter.setTitle(fields.title).setTopics(fields.topics).setContent(fields.content)
const publication = await command(eventWriter).then(publish)
return (await publication.settled()).getError()
}
const onSubmit = async () => {
const submit = async () => {
loading = true
try {
const error = await submit()
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
// A title and a description are about one link, so a batch is filed with neither.
const fields =
references.length === 1
? {title, content, topics}
: {title: "", content: "", topics: [] as string[]}
const errors = await Promise.all(references.map(target => savePin(target, fields)))
const error = errors.find(Boolean)
if (error) {
pushToast({theme: "error", message: error})
} else if (pin) {
pushToast({message: "Link updated!"})
clearModals()
} else if (references.length > 1) {
pushToast({message: `${references.length} links added!`})
clearModals()
} else {
pushToast({message: pin ? "Link updated!" : "Link added!"})
pushToast({message: "Link added!"})
clearModals()
}
} catch (e) {
@ -87,13 +146,17 @@
}
}
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit: onSubmit, uploading, onChange, content})
const editor = makeEditor({url, submit, uploading, content: pin?.content() ?? ""})
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
let title = $state(pin?.title() ?? "")
let topics = $state<string[]>(pin?.topics() ?? [])
let value = $state(pin ? pinToReference(pin) : reference)
let loading = $state(false)
let dropActive = $state(false)
const references = $derived(value.split(/\s+/).filter(Boolean))
</script>
<Modal>
@ -103,7 +166,7 @@
{#if pin}
Edit Link
{:else}
Add Link
Add a Link
{/if}
</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
@ -113,13 +176,46 @@
Link
{/snippet}
{#snippet info()}
<p>A URL or nostr link (note, nevent, naddr, npub, or nprofile).</p>
<p>
A URL or a nostr link (note, nevent, naddr, npub, or nprofile). Drop files here to upload
them. Paste several links, one per line, to file them all at once.
</p>
{/snippet}
{#snippet input()}
<input bind:value class="input w-full" placeholder="URL or nevent..." />
<div
class="flex flex-col gap-2"
role="presentation"
ondragover={stopPropagation(preventDefault(onDragOver))}
ondragleave={stopPropagation(preventDefault(onDragLeave))}
ondrop={stopPropagation(preventDefault(onDrop))}>
<textarea
bind:value
rows={pin ? 2 : 3}
class="textarea input w-full"
style:border-color={dropActive ? "var(--primary)" : undefined}
placeholder="URL or nevent..."></textarea>
{#if !pin}
<label
for={fileInputId}
class="button button-neutral button-sm cursor-pointer self-start">
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon size={4} icon={CloudUpload} />
{/if}
Upload files
</label>
<input id={fileInputId} type="file" multiple class="hidden" onchange={onFileChange} />
{/if}
</div>
{/snippet}
</Field>
{#if value.trim()}
{#if references.length > 1}
<p transition:slide class="card card-sm text-sm opacity-75">
{references.length} links, each filed on its own. A title and a description describe one link,
so add them from its menu once it is on the shelf.
</p>
{:else if references.length === 1}
<div transition:slide class="card card-sm flex flex-col gap-4 opacity-75">
<Field>
{#snippet label()}
@ -170,11 +266,13 @@
</Button>
<Button
class="button button-primary"
onclick={onSubmit}
disabled={loading || $uploading || !value.trim()}>
onclick={submit}
disabled={loading || $uploading || references.length === 0}>
<Spinner {loading}>
{#if pin}
Save changes
{:else if references.length > 1}
Add {references.length} links
{:else}
Add link
{/if}

View file

@ -0,0 +1,43 @@
<script lang="ts">
import type {PinboardReader} from "@welshman/domain"
import FolderMove from "@assets/icons/folder-move.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
type Props = {
shelves: PinboardReader[]
onMove: (address: string) => void
onRemove: () => void
onDelete: () => void
onClick: () => void
}
const {shelves, onMove, onRemove, onDelete, onClick}: Props = $props()
</script>
<ul
class="menu max-h-96 overflow-y-auto whitespace-nowrap rounded-2xl bg-surface p-2"
onclickcapture={onClick}>
{#each shelves as shelf (shelf.address())}
<li>
<Button onclick={() => onMove(shelf.address())}>
<Icon icon={FolderMove} />
Move to {shelf.title() || "Untitled shelf"}
</Button>
</li>
{/each}
<li>
<Button onclick={onRemove}>
<Icon icon={MinusCircle} />
Take off this shelf
</Button>
</li>
<li>
<Button class="text-error" onclick={onDelete}>
<Icon icon={TrashBin} />
Delete links
</Button>
</li>
</ul>

View file

@ -1,10 +1,10 @@
<script lang="ts">
import {navigate} from "@app/modal"
import {getAddress} from "@welshman/util"
import {publish} from "@welshman/app"
import {Pinboard} from "@welshman/domain"
import type {PinboardReader} from "@welshman/domain"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
@ -15,11 +15,15 @@
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 ImageInputButton from "@app/components/ImageInputButton.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {command, writer} from "@app/core"
import {navigate} from "@app/modal"
import {addressWriterToSpace} from "@app/rooms"
import {makeLibraryPath} from "@app/routes"
import {pushToast} from "@app/toast"
import {resolveImageInput} from "@app/uploads"
type Props = {
url: string
@ -30,12 +34,31 @@
const back = () => history.back()
const onImageSelect = (selected: File | string) => {
if (typeof selected === "string") {
imageFile = undefined
image = selected
} else {
imageFile = selected
image = URL.createObjectURL(selected)
}
}
const clearImage = () => {
imageFile = undefined
image = ""
}
const submit = async () => {
loading = true
try {
const eventWriter = await addressWriterToSpace(
writer(Pinboard, board).setTitle(title).setDescription(description),
writer(Pinboard, board)
.setTitle(title)
.setDescription(description)
.setImage((await resolveImageInput(imageFile, image)) ?? "")
.setTopics(topics),
url,
)
@ -65,6 +88,9 @@
let title = $state(board?.title() ?? "")
let description = $state(board?.description() ?? "")
let topics = $state<string[]>(board?.topics() ?? [])
let image = $state(board?.image() ?? "")
let imageFile = $state<File>()
let loading = $state(false)
</script>
@ -93,6 +119,37 @@
placeholder="What's this shelf about?" />
{/snippet}
</Field>
<Field>
{#snippet label()}
Cover image
{/snippet}
{#snippet input()}
<div class="flex items-center gap-3">
{#if image}
<img src={image} alt="" class="h-16 w-24 rounded-2xl object-cover" />
{/if}
<ImageInputButton
onSelect={onImageSelect}
initialValue={imageFile ? undefined : image}
aria-label="Add a cover image"
class="button button-neutral button-sm">
<Icon icon={UploadMinimalistic} size={4} />
{image ? "Replace" : "Add an image"}
</ImageInputButton>
{#if image}
<Button class="button button-link button-sm" onclick={clearImage}>Remove</Button>
{/if}
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
Topics
{/snippet}
{#snippet input()}
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
{/snippet}
</Field>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>

View file

@ -1,43 +1,103 @@
<script lang="ts">
import cx from "classnames"
import type {PinboardReader} from "@welshman/domain"
import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import BoardMenu from "@app/components/BoardMenu.svelte"
import {derived} from "svelte/store"
import {filter, formatTimestampRelative, max, spec, uniq} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import type {PinReader, PinboardReader} from "@welshman/domain"
import {normalizeTopic} from "@lib/util"
import ChatRoundDots from "@assets/icons/chat-round-dots.svg?dataurl"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import Cv from "@lib/components/Cv.svelte"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import type {FeedContext} from "@app/feeds"
import {shelfCoverImages} from "@app/pinboards"
import {makeLibraryPath} from "@app/routes"
type Props = {
url: string
board: PinboardReader
selected: boolean
onclick: () => void
pins: PinReader[]
context: FeedContext
}
const {url, board, selected, onclick}: Props = $props()
const {url, board, pins, context}: Props = $props()
const path = $derived(makeLibraryPath(url, board.address()))
const path = makeLibraryPath(url, board.address())
const related = context.related(board.event)
const comments = derived(related, $related => filter(spec({kind: COMMENT}), $related))
const title = $derived(board.title() || "Untitled shelf")
const topics = $derived(uniq(board.topics().map(normalizeTopic)))
const contributors = $derived(uniq([board.event.pubkey, ...pins.map(pin => pin.event.pubkey)]))
const cover = $derived.by(() => {
const image = board.image()
return image ? [image] : shelfCoverImages(pins)
})
const lastActive = $derived(
max([
board.event.created_at,
...pins.map(pin => pin.event.created_at),
...$comments.map(comment => comment.created_at),
]),
)
</script>
<div class="relative w-56 shrink-0">
<Button
{onclick}
aria-pressed={selected}
class={cx("card card-sm card-interactive flex h-full w-full flex-col items-start gap-1 pr-10", {
"card-primary": selected,
})}>
<strong class="truncate w-full text-left"
><UnreadDot {path} class="mr-1" />{board.title() || "Untitled shelf"}</strong>
{#if board.description()}
<span class="line-clamp-2 text-left text-sm opacity-70">{board.description()}</span>
<Cv
tag={Link}
data-component="PinboardItem"
href={path}
aria-label={title}
class="group card card-interactive card-media flex h-full w-full flex-col">
<div class="relative h-32 w-full shrink-0 overflow-hidden bg-surface-more">
{#if cover.length > 0}
<div
class="grid h-full auto-cols-fr grid-flow-col gap-px transition-transform duration-300 group-hover:scale-105">
{#each cover as image (image)}
<img src={image} alt="" class="h-full w-full object-cover" />
{/each}
</div>
{:else}
<div class="flex h-full w-full items-center justify-center">
<Icon icon={GalleryWide} size={10} class="opacity-20" />
</div>
{/if}
</Button>
<div class="absolute right-2 top-2 z-feature">
<MenuButton
class="button button-neutral button-sm button-square"
aria-label="More options"
iconSize={4}
component={BoardMenu}
componentProps={{url, board}} />
<UnreadDot {path} class="absolute right-3 top-3 z-feature" />
</div>
</div>
<div class="flex min-w-0 grow flex-col gap-2 p-4">
<p class="wrap-break-word text-xl leading-snug">{title}</p>
{#if board.description()}
<p class="line-clamp-2 wrap-break-word text-sm text-content-muted">{board.description()}</p>
{/if}
{#if topics.length > 0}
<div class="flex min-w-0 flex-wrap gap-1">
{#each topics.slice(0, 2) as topic (topic)}
<span class="badge badge-sm rounded-full font-normal">#{topic}</span>
{/each}
{#if topics.length > 2}
<span class="badge badge-sm rounded-full font-normal">+{topics.length - 2}</span>
{/if}
</div>
{/if}
</div>
<div
class="flex min-w-0 items-center justify-between gap-2 border-t border-solid border-line px-4 py-3 text-xs text-content-muted">
<span class="flex items-center gap-3">
<span>{pins.length} {pins.length === 1 ? "link" : "links"}</span>
{#if $comments.length > 0}
<span class="flex items-center gap-1">
<Icon size={3} icon={ChatRoundDots} />
{$comments.length}
</span>
{/if}
</span>
<span class="flex items-center gap-2">
<ProfileCircles pubkeys={contributors} size={5} limit={4} />
<span class="whitespace-nowrap">{formatTimestampRelative(lastActive)}</span>
</span>
</div>
</Cv>

View file

@ -46,7 +46,7 @@
back to the login screen.
</p>
<Button class="button button-primary" onclick={logout} disabled={loading}>
<Spinner {loading} reserveSpace>Log out</Spinner>
<Spinner {loading}>Log out</Spinner>
</Button>
</div>
{/if}

View file

@ -63,6 +63,10 @@ export const getUrlContentType = (url: string, event: TrustedEvent) => {
return tagValue(tagSpec("m"), tags) || tagValue(tagSpec("file-type"), tags) || ""
}
export const isImageUrl = (url: string, event: TrustedEvent) =>
Boolean(url.match(/\.(jpe?g|png|gif|webp)$/)) ||
IMAGE_CONTENT_TYPES.includes(getUrlContentType(url, event))
export const decryptUrl = async (url: string, event: TrustedEvent, signal?: AbortSignal) => {
const tags = getUrlTags(url, event)
const algorithm = tagValue(tagSpec("encryption-algorithm"), tags)

View file

@ -1,8 +1,28 @@
import * as nip19 from "nostr-tools/nip19"
import {Address, fromNostrURI, getAddress, isReplaceable} from "@welshman/util"
import {derived} from "svelte/store"
import type {Readable} from "svelte/store"
import {removeUndefined, sortBy} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {Address, PIN, PINBOARD, fromNostrURI, getAddress, isReplaceable} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import type {PinReader, PinReference, PinWriter} from "@welshman/domain"
import {Pin, Pinboard} from "@welshman/domain"
import type {PinReader, PinReference, PinWriter, PinboardReader} from "@welshman/domain"
import {isLink, parse} from "@welshman/content"
import {publish} from "@welshman/app"
import {
app,
command,
deletes,
events,
reader,
relayManagement,
relays,
user,
writer,
} from "@app/core"
import {addressWriterToSpace} from "@app/rooms"
import {isImageUrl} from "@app/content"
import {compressFileForUpload, uploadFile} from "@app/uploads"
const encodeReference = (reference: PinReference) => {
if (reference.type === "event") {
@ -80,3 +100,98 @@ export const setPinReference = (writer: PinWriter, reference: string) => {
return false
}
export const deriveShelves = (url: string): Readable<PinboardReader[]> =>
derived(events.get().forUrl(url, [{kinds: [PINBOARD]}]).$, $events =>
$events.map(reader(Pinboard)),
)
export const derivePins = (url: string): Readable<PinReader[]> =>
derived(events.get().forUrl(url, [{kinds: [PIN]}]).$, $events => $events.map(reader(Pin)))
export const deriveShelf = (url: string, address: string): Readable<Maybe<PinboardReader>> =>
derived(events.get().one(address, [url]).$, $event =>
$event ? reader(Pinboard)($event) : undefined,
)
const matchesTerm = (term: string, fields: Maybe<string>[]) =>
fields.some(field => field?.toLowerCase().includes(term))
export const shelfMatchesTerm = (shelf: PinboardReader, term: string) =>
matchesTerm(term, [shelf.title(), shelf.description(), ...shelf.topics()])
export const pinMatchesTerm = (pin: PinReader, term: string) =>
matchesTerm(term, [pin.title(), pin.content(), pinToReference(pin), ...pin.topics()])
// A pin's own words. Its reference is rendered as a link rather than named here.
export const displayPin = (pin: PinReader) => pin.title() || pin.content() || "Untitled link"
const pinImage = (pin: PinReader) => {
const reference = pin.reference()
if (reference?.type === "external" && isImageUrl(reference.id, pin.event)) {
return reference.id
}
return undefined
}
// A shelf with no cover of its own is illustrated by the pictures on it.
export const shelfCoverImages = (pins: PinReader[]) =>
removeUndefined(sortBy(pin => -pin.event.created_at, pins).map(pinImage)).slice(0, 3)
// A library pin is published to the space rather than to an author outbox, so every write goes there.
const publishToSpace = async (url: string, eventWriter: PinWriter) => {
await addressWriterToSpace(eventWriter, url)
const publication = await command(eventWriter).then(publish)
return (await publication.settled()).getError()
}
// One write per pin: a second update built from the same reader would undo the first.
export const movePinsToShelf = (url: string, pins: PinReader[], from: string, to: string) =>
Promise.all(pins.map(pin => publishToSpace(url, writer(Pin, pin).addBoard(to).removeBoard(from))))
export const removePinsFromShelf = (url: string, pins: PinReader[], address: string) =>
Promise.all(pins.map(pin => publishToSpace(url, writer(Pin, pin).removeBoard(address))))
// Your own goes as a kind 5; anyone else's is a NIP-86 ban, which the repository never hears about.
export const deleteLibraryEvent = async (url: string, event: TrustedEvent) => {
if (event.pubkey === user.get().pubkey) {
const protect = await relays.get().hasNip(url, 70)
const request = await deletes
.get()
.deleteEvent(event, deleteWriter => deleteWriter.setProtected(protect))
return (await request.publishToRelays([url]).settled()).getError()
}
const {error} = await relayManagement.get().forUrl(url).banEvent(event.id)
if (!error) {
app.get().repository.removeEvent(event.id)
}
return error
}
export const deletePins = (url: string, pins: PinReader[]) =>
Promise.all(pins.map(pin => deleteLibraryEvent(url, pin.event)))
export const uploadFilesAsReferences = async (url: string, files: File[]) => {
const references: string[] = []
const errors: string[] = []
for (const file of files) {
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
if (result) {
references.push(result.url)
} else {
errors.push(error ?? `${file.name} could not be uploaded.`)
}
}
return {references, errors}
}

View file

@ -172,16 +172,8 @@ export const makeCalendarPath = (url: string, address?: string) =>
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
// Shelves are selected in place on the library page, so the address goes in a query param.
export const makeLibraryPath = (url: string, address?: string) => {
const path = makeSpacePath(url, "library")
if (address) {
return path + "?" + new URLSearchParams({board: address}).toString()
}
return path
}
export const makeLibraryPath = (url: string, address?: string) =>
makeSpacePath(url, "library", address)
export const makeContentPath = (url: string, kind: number, idOrAddress?: string) => {
switch (kind) {

View file

@ -1,25 +0,0 @@
<script lang="ts" generics="T">
import type {Snippet} from "svelte"
type Props = {
items: T[]
child: Snippet<[T]>
getKey: (item: T) => PropertyKey
// Minimum column width, in tailwind spacing units (1 = 0.25rem).
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>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
type Props = {
title: string
message: string
}
const {title, message}: Props = $props()
const back = () => history.back()
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>{title}</ModalTitle>
</ModalHeader>
<p class="text-center">{message}</p>
</ModalBody>
<ModalFooter>
<Button class="button button-primary" onclick={back}>Got it</Button>
</ModalFooter>
</Modal>

View file

@ -6,18 +6,10 @@
children?: Snippet
size?: "md" | "sm" | "xs"
loading?: boolean
// Reserves the spinner's slot while idle, so toggling `loading` doesn't reflow the button.
reserveSpace?: boolean
class?: string
}
const {
children,
size = "md",
loading = true,
reserveSpace = false,
...restProps
}: Props = $props()
const {children, size = "md", loading = true, ...restProps}: Props = $props()
const spinnerClass = $derived(
cx("spinner", {
@ -30,8 +22,6 @@
<div class={cx("flex items-center gap-2", restProps.class)}>
{#if loading}
<span class={spinnerClass}></span>
{:else if reserveSpace}
<span class={cx(spinnerClass, "invisible")}></span>
{/if}
{@render children?.()}
</div>

View file

@ -1,70 +1,71 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {derived} from "svelte/store"
import {onDestroy, onMount} from "svelte"
import {max, pushToMapKey, sortBy} from "@welshman/lib"
import {PIN, PINBOARD} from "@welshman/util"
import {Pins} from "@welshman/app"
import {Pinboard} from "@welshman/domain"
import type {PinboardReader} from "@welshman/domain"
import type {PinReader} from "@welshman/domain"
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 AddCircle from "@assets/icons/add-circle.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 PinItem from "@app/components/PinItem.svelte"
import PinSave from "@app/components/PinSave.svelte"
import PinboardItem from "@app/components/PinboardItem.svelte"
import PinboardEdit from "@app/components/PinboardEdit.svelte"
import {app, events, network, reader} from "@app/core"
import {network} from "@app/core"
import {makeFeedContext} from "@app/feeds"
import {derivePins, deriveShelves, pinMatchesTerm, shelfMatchesTerm} from "@app/pinboards"
import {decodeRelay} from "@app/relays"
import {makeLibraryPath} from "@app/routes"
import {pushModal} from "@app/modal"
import type {PageProps} from "./$types"
const {params}: PageProps = $props()
const url = decodeRelay(params.relay)
const context = makeFeedContext({relays: [url]})
const shelves = deriveShelves(url)
const pins = derivePins(url)
let term = $state("")
let loading = $state(true)
const pinsByShelf = $derived.by(() => {
const result = new Map<string, PinReader[]>()
// A shelf belongs to the space it was published to rather than to the pubkey that signed it.
const boards = derived($events.forUrl(url, [{kinds: [PINBOARD]}]).$, $pinboards =>
$pinboards.map(reader(Pinboard)),
)
const address = $derived($page.url.searchParams.get("board") ?? "")
const selected = $derived($boards.find(board => board.address() === address))
const pins = $derived($app.use(Pins).forBoard(address).$)
const filtered = $derived.by(() => {
const value = term.trim().toLowerCase()
if (value) {
return $boards.filter(board =>
[board.title(), board.description(), ...board.topics()].some(field =>
field?.toLowerCase().includes(value),
),
)
for (const pin of $pins) {
for (const address of pin.boards()) {
pushToMapKey(result, address, pin)
}
}
return $boards
return result
})
const createBoard = () => pushModal(PinboardEdit, {url})
// A shelf is as recent as the newest thing on it, so curating one brings it back to the front.
const sorted = $derived(
sortBy(
shelf =>
-max([
shelf.event.created_at,
...(pinsByShelf.get(shelf.address()) ?? []).map(pin => pin.event.created_at),
]),
$shelves,
),
)
const addLink = () => pushModal(PinSave, {url, address})
let search = $state("")
let loading = $state(true)
const selectBoard = (board: PinboardReader) => () =>
goto(makeLibraryPath(url, board.address()), {replaceState: true, noScroll: true})
const term = $derived(search.trim().toLowerCase())
const matchingShelves = $derived(
term ? sorted.filter(shelf => shelfMatchesTerm(shelf, term)) : sorted,
)
const matchingPins = $derived(
term ? $pins.filter(pin => pin.boards().length > 0 && pinMatchesTerm(pin, term)) : [],
)
const createShelf = () => pushModal(PinboardEdit, {url})
onMount(() => {
const controller = new AbortController()
@ -77,6 +78,8 @@
return () => controller.abort()
})
onDestroy(context.cleanup)
</script>
<SpaceBar>
@ -87,53 +90,54 @@
<strong>Library</strong>
{/snippet}
{#snippet action()}
<Button class="button button-primary button-sm" onclick={createBoard}>
<Button class="button button-primary button-sm" onclick={createShelf}>
<Icon icon={Add} />
Create Shelf
</Button>
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-4 p-4">
<PageContent class="@container flex flex-col gap-4 p-4">
<label class="card input 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..." />
<input bind:value={search} class="min-w-0 grow" type="text" placeholder="Search library..." />
</label>
{#if loading}
<p class="flex items-center justify-center py-20">
<Spinner loading>Loading library...</Spinner>
</p>
{:else if $shelves.length === 0}
<div class="card flex flex-col items-center gap-4 py-16 text-center">
<p class="text-xl">This space has no shelves yet.</p>
<p class="opacity-75">
A shelf collects links, notes and files worth keeping where everyone can find them.
</p>
<Button class="button button-primary" onclick={createShelf}>
<Icon icon={Add} />
Create the first shelf
</Button>
</div>
{:else}
{#if filtered.length > 0}
<div class="scroll-container flex shrink-0 gap-3 overflow-x-auto pb-2">
{#each filtered as board (board.address())}
<PinboardItem
{url}
{board}
selected={board.address() === address}
onclick={selectBoard(board)} />
{#if matchingShelves.length > 0}
<div class="grid gap-4 @2xl:grid-cols-2 @4xl:grid-cols-3">
{#each matchingShelves as board (board.address())}
<PinboardItem {url} {board} {context} pins={pinsByShelf.get(board.address()) ?? []} />
{/each}
</div>
{:else}
<p class="py-8 text-center opacity-70">No shelves found.</p>
<p class="py-8 text-center opacity-70">No shelves match that.</p>
{/if}
<div class="divider shrink-0"></div>
{#if selected && $pins.length > 0}
<Masonry items={$pins} getKey={pin => pin.id()} columnWidth={80} gap={3}>
{#snippet child(pin)}
<PinItem {url} {pin} />
{/snippet}
</Masonry>
{:else if selected}
<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>
<Button class="button button-primary" onclick={addLink}>
<Icon icon={AddCircle} />
Add a link
</Button>
{#if term}
<h2 class="text-lg">
{matchingPins.length === 1
? "1 link matches that"
: `${matchingPins.length} links match that`}
</h2>
<div class="grid gap-4 @2xl:grid-cols-2 @4xl:grid-cols-3">
{#each matchingPins as pin (pin.event.id)}
<PinItem {url} {pin} {context} />
{/each}
</div>
{:else}
<p class="py-20 text-center opacity-70">Select a shelf to see what's on it.</p>
{/if}
{/if}
</PageContent>

View file

@ -0,0 +1,184 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte"
import {sortBy, uniq} from "@welshman/lib"
import {PIN, PINBOARD} from "@welshman/util"
import {normalizeTopic} from "@lib/util"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import List from "@assets/icons/list.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import BoardMenu from "@app/components/BoardMenu.svelte"
import EventComments from "@app/components/EventComments.svelte"
import PinItem from "@app/components/PinItem.svelte"
import PinList from "@app/components/PinList.svelte"
import PinSave from "@app/components/PinSave.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileList from "@app/components/ProfileList.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import {network} from "@app/core"
import {makeFeedContext} from "@app/feeds"
import {derivePins, deriveShelf, pinMatchesTerm} from "@app/pinboards"
import {decodeRelay} from "@app/relays"
import {pushModal} from "@app/modal"
import type {PageProps} from "./$types"
const {params}: PageProps = $props()
const {address} = params
const url = decodeRelay(params.relay)
const context = makeFeedContext({relays: [url]})
const board = deriveShelf(url, address)
const pins = derivePins(url)
const shelved = $derived(
sortBy(
pin => -pin.event.created_at,
$pins.filter(pin => pin.boards().includes(address)),
),
)
let search = $state("")
let layout = $state<"cards" | "list">("cards")
const term = $derived(search.trim().toLowerCase())
const visible = $derived(term ? shelved.filter(pin => pinMatchesTerm(pin, term)) : shelved)
const topics = $derived(uniq(($board?.topics() ?? []).map(normalizeTopic)))
const contributors = $derived(
uniq([...($board ? [$board.event.pubkey] : []), ...shelved.map(pin => pin.event.pubkey)]),
)
const back = () => history.back()
const addLinks = () => pushModal(PinSave, {url, address})
const showContributors = () =>
pushModal(ProfileList, {
title: "Contributors",
subtitle: "Made this shelf or added a link to it",
pubkeys: contributors,
url,
})
const toggleList = () => {
layout = layout === "list" ? "cards" : "list"
}
onMount(() => {
const controller = new AbortController()
$network.loadLenient({
relays: [url],
filters: [{kinds: [PINBOARD, PIN]}],
signal: controller.signal,
})
return () => controller.abort()
})
onDestroy(context.cleanup)
</script>
<SpaceBar {back}>
{#snippet leading()}
<Icon icon={GalleryWide} />
{/snippet}
{#snippet title()}
<h1 class="text-xl">{$board?.title() || "Shelf"}</h1>
{/snippet}
{#snippet action()}
<Button class="button button-primary button-sm" onclick={addLinks}>
<Icon icon={AddCircle} />
Add a link
</Button>
{#if $board}
<MenuButton
class="button button-neutral button-sm button-square"
aria-label="Shelf options"
iconSize={4}
component={BoardMenu}
componentProps={{url, board: $board}} />
{/if}
{/snippet}
</SpaceBar>
<PageContent noPad class="flex flex-col gap-0">
{#if $board}
<header class="flex flex-col border-b border-line bg-surface">
{#if $board.image()}
<img src={$board.image()} alt="" class="h-40 w-full object-cover" />
{/if}
<div class="flex flex-col gap-3 p-4">
{#if $board.description()}
<p class="wrap-break-word">{$board.description()}</p>
{/if}
{#if topics.length > 0}
<div class="flex flex-wrap gap-1">
{#each topics as topic (topic)}
<span class="badge badge-sm rounded-full font-normal">#{topic}</span>
{/each}
</div>
{/if}
<div class="flex flex-wrap items-center gap-3 text-sm text-content-muted">
<Button class="flex items-center gap-3" onclick={showContributors}>
<ProfileCircles pubkeys={contributors} size={5} />
<span class="underline underline-offset-2">
{contributors.length}
{contributors.length === 1 ? "contributor" : "contributors"}
</span>
</Button>
<span>{shelved.length} {shelved.length === 1 ? "link" : "links"}</span>
</div>
</div>
</header>
<div class="flex items-center gap-2 border-b border-line bg-surface px-4 py-3">
<label class="input input-group flex w-auto min-w-0 grow items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:value={search}
class="min-w-0 grow"
type="text"
placeholder="Search this shelf..." />
</label>
<Button
class="button button-input shrink-0 {layout === 'list'
? 'button-primary'
: 'button-neutral'}"
aria-pressed={layout === "list"}
onclick={toggleList}>
<Icon size={4} icon={List} />
List
</Button>
</div>
{#if visible.length === 0}
<div class="flex flex-col items-center gap-4 py-16 text-center">
{#if term}
<p class="opacity-70">Nothing on this shelf matches that.</p>
{:else}
<p class="opacity-70">This shelf doesn't have any links yet.</p>
<Button class="button button-primary" onclick={addLinks}>
<Icon icon={AddCircle} />
Add the first link
</Button>
{/if}
</div>
{:else if layout === "cards"}
<div class="bg-surface">
{#each visible as pin (pin.event.id)}
<PinItem {url} {pin} {context} card={false} />
{/each}
</div>
{:else}
<PinList {url} {address} pins={visible} {context} />
{/if}
<div class="border-t border-line bg-surface pt-1">
<EventComments noun="shelf" event={$board.event} {url} {context} />
</div>
{:else}
<LoadingFallback loadingMessage="Loading shelf..." failureMessage="Failed to load shelf." />
{/if}
</PageContent>