Redesign the content library (#677)
This commit is contained in:
parent
7234e4a375
commit
71cd7fbe55
31 changed files with 1637 additions and 428 deletions
|
|
@ -885,12 +885,15 @@ resources members have collected.
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- Typing a term filters the shelf list to shelves whose title, description, or
|
- A shelf card carries its title, its description, how many links are on it and
|
||||||
topics match.
|
who has contributed.
|
||||||
- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message
|
- Typing a term filters the shelves to those whose title, description or topics
|
||||||
instead.
|
match, and lists the links that match from any shelf.
|
||||||
- Alice is offered "Create Shelf" and "Add a link", and "Add link" on someone
|
- Opening a shelf shows its links; one that already holds a link still offers
|
||||||
else's shelf, but not that shelf's "Edit shelf" or "Delete shelf".
|
"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
|
### US-054 — Curate the library
|
||||||
|
|
||||||
|
|
@ -899,13 +902,56 @@ good material first.
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- Creating a shelf with a title and description adds it to the list and
|
- Creating a shelf with a title, description and topics opens it; editing it
|
||||||
navigates into it; editing and deleting it from its menu update the list.
|
from its menu updates its page, and deleting it returns to the library.
|
||||||
- Adding a pin with an external url shows it as a content card, and adding one
|
- Several links pasted at once each become a link of its own and are offered no
|
||||||
with a nostr note link renders that note as a rich embed.
|
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
|
- "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
|
as a link on that shelf; with no shelves yet, the dialog prompts to create
|
||||||
create one.
|
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
|
### US-055 — Create community content from a room
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ export type BootOptions = {
|
||||||
indexers?: string[]
|
indexers?: string[]
|
||||||
spaces?: string[]
|
spaces?: string[]
|
||||||
user?: TestUser
|
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.
|
// What this user's client already has in local storage, e.g. their room list.
|
||||||
events?: TrustedEvent[]
|
events?: TrustedEvent[]
|
||||||
// Which paths this user has already read, keyed the way src/app/notifications.ts keys them.
|
// 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,
|
indexers = relays,
|
||||||
spaces = [],
|
spaces = [],
|
||||||
user,
|
user,
|
||||||
|
nip07 = false,
|
||||||
events = [],
|
events = [],
|
||||||
checked,
|
checked,
|
||||||
path = "/",
|
path = "/",
|
||||||
|
|
@ -73,7 +76,7 @@ export const boot = async (
|
||||||
)
|
)
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
await injectSession(context, user)
|
await injectSession(context, user, nip07)
|
||||||
await injectEvents(context, events)
|
await injectEvents(context, events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
await context.addInitScript(key => {
|
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, {
|
Object.assign(window, {
|
||||||
nostr: {
|
nostr: {
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,16 @@ const TEST_SESSION_KEY = "__TEST_SESSION__"
|
||||||
const TEST_EVENTS_KEY = "__TEST_EVENTS__"
|
const TEST_EVENTS_KEY = "__TEST_EVENTS__"
|
||||||
|
|
||||||
// The {method, data} shape @welshman/app's session handlers deserialize, installed before navigating.
|
// 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(
|
context.addInitScript(
|
||||||
([key, session]) => {
|
([key, session]) => {
|
||||||
Object.assign(window, {[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.
|
// The local cache a returning user would boot with, loaded once the injected session is restored.
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ export type PageOptions = {
|
||||||
context?: BrowserContextOptions
|
context?: BrowserContextOptions
|
||||||
// VITE_ values applied over the ones derived from the scenario's relays. See BootOptions in app/boot.ts.
|
// VITE_ values applied over the ones derived from the scenario's relays. See BootOptions in app/boot.ts.
|
||||||
env?: Record<string, string>
|
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
|
nip07?: TestUser
|
||||||
// A WebLN provider on window, for a wallet that gets connected through an extension.
|
// A WebLN provider on window, for a wallet that gets connected through an extension.
|
||||||
webln?: WebLnInfo
|
webln?: WebLnInfo
|
||||||
|
|
@ -276,6 +276,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
||||||
|
|
||||||
return boot(context, {
|
return boot(context, {
|
||||||
user,
|
user,
|
||||||
|
nip07: Boolean(options.nip07),
|
||||||
path,
|
path,
|
||||||
env: options.env,
|
env: options.env,
|
||||||
checked: options.checked,
|
checked: options.checked,
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,9 @@ import type {TestUser} from "../harness"
|
||||||
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
||||||
type Seeded = {readonly id: string; readonly event: SignedEvent}
|
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) =>
|
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.
|
// 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()
|
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 alice = await as(users.alice, libraryPath)
|
||||||
const term = alice.getByPlaceholder("Search library...")
|
const term = alice.getByPlaceholder("Search library...")
|
||||||
|
|
||||||
await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible()
|
await expect(shelfCard(alice, "Getting Started")).toBeVisible()
|
||||||
await expect(alice.getByRole("button", {name: /Recipes/})).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.
|
// A term matches a shelf on its description as well as its title.
|
||||||
await term.fill("new members")
|
await term.fill("new members")
|
||||||
|
|
||||||
await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible()
|
await expect(shelfCard(alice, "Getting Started")).toBeVisible()
|
||||||
await expect(alice.getByRole("button", {name: /Recipes/})).toHaveCount(0)
|
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 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 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()
|
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.
|
// 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: "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.
|
// A shelf is edited from its own page, and someone else's is hers to add to and not to rewrite.
|
||||||
await shelfCard(alice, "Recipes").getByRole("button", {name: "More options"}).click()
|
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: "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}) => {
|
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 libraryPath = `${spacePath(url)}/library`
|
||||||
const pollPath = `${spacePath(url)}/polls/${poll.id}`
|
const pollPath = `${spacePath(url)}/polls/${poll.id}`
|
||||||
|
|
||||||
// Curating is nobody's privilege here, so this is an ordinary member doing all of it.
|
// An ordinary member curates, signing through an extension, which clones what it is handed.
|
||||||
const page = await as(users.bob, libraryPath)
|
const page = await as(users.bob, libraryPath, {nip07: users.bob})
|
||||||
|
|
||||||
const pollCard = page
|
const pollCard = page
|
||||||
.locator(".card.z-feature")
|
.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("Shelf title").fill("Reading List")
|
||||||
await shelfForm.getByPlaceholder("What's this shelf about?").fill("Things worth reading")
|
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()
|
await shelfForm.getByRole("button", {name: "Save changes"}).click()
|
||||||
|
|
||||||
// Creating a shelf drops its author straight into it.
|
// Creating a shelf drops its author straight onto it.
|
||||||
await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute(
|
await expect(page.getByRole("heading", {name: "Reading List"})).toBeVisible()
|
||||||
"aria-pressed",
|
await expect(page.getByText("#reading")).toBeVisible()
|
||||||
"true",
|
|
||||||
)
|
|
||||||
|
|
||||||
// An external url becomes a card of its own...
|
// Several links go in at once, each as 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.
|
|
||||||
const nevent = nip19.neventEncode({id: message.id, kind: MESSAGE, relays: [url]})
|
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 a link"}).click()
|
||||||
await page.getByRole("button", {name: "Add link", exact: true}).click()
|
|
||||||
|
|
||||||
const nostrLink = dialog(page, "Add Link")
|
const links = dialog(page, "Add a Link")
|
||||||
|
|
||||||
await nostrLink.getByPlaceholder("URL or nevent...").fill(`nostr:${nevent}`)
|
await links
|
||||||
await nostrLink.getByPlaceholder("Optional title").fill("Yesterday's incident")
|
.getByPlaceholder("URL or nevent...")
|
||||||
await nostrLink.getByRole("button", {name: "Add link"}).click()
|
.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()
|
await expect(page.getByText("the deploy broke again")).toBeVisible()
|
||||||
|
|
||||||
// Editing the shelf from its menu updates the list.
|
// Renaming a link republishes the pin the menu was opened from.
|
||||||
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
|
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()
|
await page.getByRole("button", {name: "Edit shelf"}).click()
|
||||||
|
|
||||||
const shelfEdit = dialog(page, "Edit Shelf")
|
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.getByPlaceholder("What's this shelf about?").fill("Things worth reading twice")
|
||||||
await shelfEdit.getByRole("button", {name: "Save changes"}).click()
|
await shelfEdit.getByRole("button", {name: "Save changes"}).click()
|
||||||
|
|
||||||
await expect(page.getByRole("button", {name: /Reading List/})).toContainText(
|
await expect(page.getByText("Things worth reading twice")).toBeVisible()
|
||||||
"Things worth reading twice",
|
|
||||||
)
|
|
||||||
|
|
||||||
// A post filed from its own menu lands on the shelf that was picked for it.
|
// A post filed from its own menu lands on the shelf that was picked for it.
|
||||||
await page.goto(pollPath)
|
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: "Add to Library"}).click()
|
||||||
await page.getByRole("button", {name: /Reading List/}).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.getByPlaceholder("Optional title").fill("Standup poll")
|
||||||
await fromPoll.getByRole("button", {name: "Add link"}).click()
|
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 expect(page.getByRole("alert")).toContainText("Link added!")
|
||||||
|
|
||||||
await page.goto(libraryPath)
|
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("Standup poll")).toBeVisible()
|
||||||
await expect(page.getByText("Should we move standup to 10?")).toBeVisible()
|
await expect(page.getByText("Should we move standup to 10?")).toBeVisible()
|
||||||
|
|
||||||
// Deleting the shelf from its menu takes it off the list.
|
// Deleting a shelf from its own page takes it off the library and puts its curator back there.
|
||||||
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
|
await page.getByRole("button", {name: "Shelf options"}).click()
|
||||||
await page.getByRole("button", {name: "Delete shelf"}).click()
|
await page.getByRole("button", {name: "Delete shelf"}).click()
|
||||||
await page.getByRole("button", {name: "Confirm"}).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}) => {
|
test("US-055 create community content from a room", async ({seed, as}) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {page} from "$app/state"
|
||||||
import type {PinboardReader} from "@welshman/domain"
|
import type {PinboardReader} from "@welshman/domain"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||||
|
|
@ -9,12 +9,15 @@
|
||||||
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"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
|
import Notice from "@lib/components/Notice.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import PinSave from "@app/components/PinSave.svelte"
|
|
||||||
import PinboardEdit from "@app/components/PinboardEdit.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 {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"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -25,26 +28,28 @@
|
||||||
|
|
||||||
const {url, board, onClick}: Props = $props()
|
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 showInfo = () => pushModal(EventInfo, {url, event: board.event})
|
||||||
|
|
||||||
const share = () => shareEvent(url, "Shelf", board.event)
|
const share = () => shareEvent(url, "Shelf", board.event)
|
||||||
|
|
||||||
const addLink = () => pushModal(PinSave, {url, address: board.address()})
|
|
||||||
|
|
||||||
const edit = () => pushModal(PinboardEdit, {url, board})
|
const edit = () => pushModal(PinboardEdit, {url, board})
|
||||||
|
|
||||||
const deleteBoard = async () => {
|
const deleteBoard = async () => {
|
||||||
try {
|
try {
|
||||||
const protect = await $relays.hasNip(url, 70)
|
const error = await deleteLibraryEvent(url, board.event)
|
||||||
const command = await $deletes.deleteEvent(board.event, writer =>
|
|
||||||
writer.setProtected(protect),
|
|
||||||
)
|
|
||||||
const error = (await command.publishToRelays([url]).settled()).getError()
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
} else {
|
} else {
|
||||||
pushToast({message: "Shelf deleted!"})
|
pushToast({message: "Shelf deleted!"})
|
||||||
|
|
||||||
|
if (page.url.pathname === makeLibraryPath(url, board.address())) {
|
||||||
|
navigate(makeLibraryPath(url))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -58,6 +63,12 @@
|
||||||
message: `Delete "${board.title() || "this shelf"}"?`,
|
message: `Delete "${board.title() || "this shelf"}"?`,
|
||||||
confirm: deleteBoard,
|
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>
|
</script>
|
||||||
|
|
||||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||||
|
|
@ -73,24 +84,18 @@
|
||||||
Share to chat
|
Share to chat
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
{#if isAuthor}
|
||||||
<Button onclick={addLink}>
|
|
||||||
<Icon icon={AddCircle} />
|
|
||||||
Add link
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{#if board.event.pubkey === $user.pubkey}
|
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={edit}>
|
<Button onclick={edit}>
|
||||||
<Icon icon={Pen} />
|
<Icon icon={Pen} />
|
||||||
Edit shelf
|
Edit shelf
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Button class="text-error" onclick={confirmDelete}>
|
|
||||||
<Icon icon={TrashBin} />
|
|
||||||
Delete shelf
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<li>
|
||||||
|
<Button class="text-error" onclick={canDelete ? confirmDelete : explainDelete}>
|
||||||
|
<Icon icon={TrashBin} />
|
||||||
|
Delete shelf
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import {Note} from "@welshman/domain"
|
import {Note} from "@welshman/domain"
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
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 Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
|
@ -129,7 +130,10 @@
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button class="button button-link" onclick={onCancel} disabled={loading}>Cancel</Button>
|
<Button class="button button-link" onclick={onCancel} disabled={loading}>Cancel</Button>
|
||||||
<Button type="submit" class="button button-primary button-sm" disabled={$uploading || loading}>
|
<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}
|
{#if parent}
|
||||||
Reply
|
Reply
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@
|
||||||
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
||||||
import {
|
import {
|
||||||
getUrlContentType,
|
getUrlContentType,
|
||||||
|
isImageUrl,
|
||||||
AUDIO_CONTENT_TYPES,
|
AUDIO_CONTENT_TYPES,
|
||||||
IMAGE_CONTENT_TYPES,
|
|
||||||
VIDEO_CONTENT_TYPES,
|
VIDEO_CONTENT_TYPES,
|
||||||
} from "@app/content"
|
} from "@app/content"
|
||||||
import {isRoomId} from "@app/rooms"
|
import {isRoomId} from "@app/rooms"
|
||||||
|
|
@ -62,8 +62,7 @@
|
||||||
|
|
||||||
const isVideo = Boolean(url.match(/\.(mov|webm|mp4)$/)) || VIDEO_CONTENT_TYPES.includes(fileType)
|
const isVideo = Boolean(url.match(/\.(mov|webm|mp4)$/)) || VIDEO_CONTENT_TYPES.includes(fileType)
|
||||||
|
|
||||||
const isImage =
|
const isImage = isImageUrl(url, event)
|
||||||
Boolean(url.match(/\.(jpe?g|png|gif|webp)$/)) || IMAGE_CONTENT_TYPES.includes(fileType)
|
|
||||||
|
|
||||||
const getVideoPoster = (videoUrl: string): string | undefined => {
|
const getVideoPoster = (videoUrl: string): string | undefined => {
|
||||||
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
|
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@
|
||||||
url?: string
|
url?: string
|
||||||
context: FeedContext
|
context: FeedContext
|
||||||
columnClass?: string
|
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 relays = removeUndefined([url])
|
||||||
const filters = getCommentFiltersForRoot([event])
|
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)}>
|
<div class={cx("flex w-full flex-wrap items-center justify-between gap-2 py-3", columnClass)}>
|
||||||
<h2 class="text-lg font-bold">
|
<h2 class="text-lg font-bold">
|
||||||
{nodes.length === 0
|
{nodes.length === 0
|
||||||
? "Discussion about this post"
|
? `Discussion about this ${noun}`
|
||||||
: `${$comments.length} ${$comments.length === 1 ? "comment" : "comments"}`}
|
: `${$comments.length} ${$comments.length === 1 ? "comment" : "comments"}`}
|
||||||
</h2>
|
</h2>
|
||||||
{#if url && replyTo?.id !== event.id}
|
{#if url && replyTo?.id !== event.id}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@
|
||||||
let element: HTMLElement
|
let element: HTMLElement
|
||||||
type WrapperProps = ComponentProps<typeof Dialog>
|
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(() => {
|
$effect(() => {
|
||||||
const stack = getModalStack()
|
const stack = getModalStack()
|
||||||
|
|
@ -47,11 +49,11 @@
|
||||||
const ids = stack.map(({id}) => id)
|
const ids = stack.map(({id}) => id)
|
||||||
const activeId = ids.at(-1)
|
const activeId = ids.at(-1)
|
||||||
|
|
||||||
for (const [id, {instance, props}] of Object.entries(instances)) {
|
for (const [id, {instance, state}] of Object.entries(instances)) {
|
||||||
props.active = id === activeId
|
state.active = id === activeId
|
||||||
|
|
||||||
if (!ids.includes(id)) {
|
if (!ids.includes(id)) {
|
||||||
props.restoreFocus = !activeId || Boolean(instances[activeId])
|
state.restoreFocus = !activeId || Boolean(instances[activeId])
|
||||||
unmount(instance, {outro: true})
|
unmount(instance, {outro: true})
|
||||||
delete instances[id]
|
delete instances[id]
|
||||||
}
|
}
|
||||||
|
|
@ -64,23 +66,29 @@
|
||||||
|
|
||||||
const {options, component, props} = item
|
const {options, component, props} = item
|
||||||
const wrapper = options.drawer ? Drawer : Dialog
|
const wrapper = options.drawer ? Drawer : Dialog
|
||||||
const wrapperProps = $state({
|
// Only the two props this container changes are reactive, so nothing proxies a modal's own.
|
||||||
active: item.id === activeId,
|
const state = $state({active: item.id === activeId, restoreFocus: true})
|
||||||
|
const wrapperProps: WrapperProps = {
|
||||||
|
get active() {
|
||||||
|
return state.active
|
||||||
|
},
|
||||||
|
get restoreFocus() {
|
||||||
|
return state.restoreFocus
|
||||||
|
},
|
||||||
onClose: closeModal,
|
onClose: closeModal,
|
||||||
label: options.label,
|
label: options.label,
|
||||||
restoreFocus: true,
|
|
||||||
size: options.size,
|
size: options.size,
|
||||||
noEscape: options.noEscape,
|
noEscape: options.noEscape,
|
||||||
fullscreen: options.fullscreen,
|
fullscreen: options.fullscreen,
|
||||||
children: {component, props},
|
children: {component, props},
|
||||||
})
|
}
|
||||||
|
|
||||||
instances[item.id] = {
|
instances[item.id] = {
|
||||||
instance: mount(wrapper as Component, {
|
instance: mount(wrapper as Component, {
|
||||||
target: element,
|
target: element,
|
||||||
props: wrapperProps,
|
props: wrapperProps,
|
||||||
}),
|
}),
|
||||||
props: wrapperProps,
|
state,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
31
src/app/components/PinContent.svelte
Normal file
31
src/app/components/PinContent.svelte
Normal 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>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
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 NoteItem from "@app/components/NoteItem.svelte"
|
||||||
import {events} from "@app/core"
|
import {events} from "@app/core"
|
||||||
import {makeFeedContext} from "@app/feeds"
|
import {makeFeedContext} from "@app/feeds"
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
{#if $event}
|
{#if $event}
|
||||||
<NoteItem {url} {context} event={$event} />
|
<NoteItem {url} {context} event={$event} />
|
||||||
{:else}
|
{:else}
|
||||||
<p class="flex justify-center py-8">
|
<LoadingFallback
|
||||||
<Spinner loading>Loading event...</Spinner>
|
class="py-8"
|
||||||
</p>
|
loadingMessage="Loading post..."
|
||||||
|
failureMessage="Failed to load this post." />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
99
src/app/components/PinDetail.svelte
Normal file
99
src/app/components/PinDetail.svelte
Normal 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>
|
||||||
|
|
@ -1,71 +1,84 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import {derived} from "svelte/store"
|
||||||
import {removeUndefined} from "@welshman/lib"
|
import {filter, formatTimestampRelative, spec, uniq} from "@welshman/lib"
|
||||||
import {Address} from "@welshman/util"
|
import {COMMENT} from "@welshman/util"
|
||||||
import type {PinReader} from "@welshman/domain"
|
import type {PinReader} from "@welshman/domain"
|
||||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
import {normalizeTopic} from "@lib/util"
|
||||||
import Content from "@app/components/Content.svelte"
|
import ChatRoundDots from "@assets/icons/chat-round-dots.svg?dataurl"
|
||||||
import PinContentEvent from "@app/components/PinContentEvent.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import PinMenu from "@app/components/PinMenu.svelte"
|
import Cv from "@lib/components/Cv.svelte"
|
||||||
import {pinToReference} from "@app/pinboards"
|
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 = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
pin: PinReader
|
pin: PinReader
|
||||||
minimal?: boolean
|
context: FeedContext
|
||||||
class?: string
|
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 topics = $derived(uniq(pin.topics().map(normalizeTopic)))
|
||||||
const reference = $derived(pin.reference())
|
const related = context.related(pin.event)
|
||||||
|
const comments = derived(related, $related => filter(spec({kind: COMMENT}), $related))
|
||||||
|
|
||||||
const content = $derived.by(() => {
|
const discuss = () => pushModal(PinDetail, {url, pin, context}, {size: "large"})
|
||||||
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)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="{props.class} flex h-full flex-col gap-2 {minimal ? '' : 'card relative'}">
|
{#snippet topicLabels()}
|
||||||
{#if !minimal}
|
{#each topics as topic (topic)}
|
||||||
<div class="absolute right-2 top-2 z-feature">
|
<span class="badge badge-neutral badge-sm font-normal">#{topic}</span>
|
||||||
<MenuButton
|
{/each}
|
||||||
class="button button-neutral button-sm button-square"
|
{/snippet}
|
||||||
aria-label="More options"
|
|
||||||
iconSize={4}
|
{#snippet body()}
|
||||||
component={PinMenu}
|
<div class="flex min-w-0 items-center gap-2 text-xs text-content-muted">
|
||||||
componentProps={{url, pin}} />
|
<ProfileCircle pubkey={pin.event.pubkey} {url} size={4} />
|
||||||
</div>
|
<span class="min-w-0 truncate">
|
||||||
{/if}
|
<ProfileLink unstyled pubkey={pin.event.pubkey} {url} />
|
||||||
|
</span>
|
||||||
|
<span class="whitespace-nowrap">{formatTimestampRelative(pin.event.created_at)}</span>
|
||||||
|
</div>
|
||||||
{#if pin.title()}
|
{#if pin.title()}
|
||||||
<strong class="truncate min-w-0 pr-8">{pin.title()}</strong>
|
<strong class="min-w-0">{pin.title()}</strong>
|
||||||
{/if}
|
{/if}
|
||||||
{#if pin.content()}
|
<PinContent {url} {pin} />
|
||||||
<Content event={{content: pin.content(), tags: []}} {url} />
|
<div class="mt-auto flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||||
{/if}
|
<Button class="button button-neutral button-xs" onclick={discuss}>
|
||||||
{#if reference?.type === "event" || reference?.type === "address"}
|
<Icon size={4} icon={ChatRoundDots} />
|
||||||
<PinContentEvent
|
{#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}
|
{url}
|
||||||
value={reference.type === "event" ? reference.id : reference.address}
|
event={pin.event}
|
||||||
relays={removeUndefined([reference.relay, url])} />
|
{context} />
|
||||||
{:else}
|
</div>
|
||||||
<Content event={{content, tags: []}} {url} />
|
{/snippet}
|
||||||
{/if}
|
|
||||||
{#if !minimal && pin.topics().length > 0}
|
{#if card}
|
||||||
<div class="mt-auto flex flex-wrap gap-1">
|
<Cv data-component="PinItem" class="card flex h-full flex-col gap-2">
|
||||||
{#each pin.topics() as topic (topic)}
|
{@render body()}
|
||||||
<span class="badge badge-neutral">#{topic}</span>
|
</Cv>
|
||||||
{/each}
|
{:else}
|
||||||
</div>
|
<Cv
|
||||||
{/if}
|
data-component="PinItem"
|
||||||
</div>
|
class="flex flex-col gap-2 border-b border-solid border-line px-4 py-4">
|
||||||
|
{@render body()}
|
||||||
|
</Cv>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
184
src/app/components/PinList.svelte
Normal file
184
src/app/components/PinList.svelte
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
32
src/app/components/PinReference.svelte
Normal file
32
src/app/components/PinReference.svelte
Normal 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}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
|
import {randomId} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {Pin} from "@welshman/domain"
|
import {Pin} from "@welshman/domain"
|
||||||
import {publish} from "@welshman/app"
|
import {publish} from "@welshman/app"
|
||||||
import type {PinReader} from "@welshman/domain"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
import {slide} from "@lib/transition"
|
import {slide} from "@lib/transition"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
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 GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -20,8 +23,8 @@
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {command, writer} from "@app/core"
|
import {command, reader, writer} from "@app/core"
|
||||||
import {pinToReference, setPinReference} from "@app/pinboards"
|
import {pinToReference, setPinReference, uploadFilesAsReferences} from "@app/pinboards"
|
||||||
import {addressWriterToSpace} from "@app/rooms"
|
import {addressWriterToSpace} from "@app/rooms"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {clearModals} from "@app/modal"
|
import {clearModals} from "@app/modal"
|
||||||
|
|
@ -30,53 +33,109 @@
|
||||||
url: string
|
url: string
|
||||||
address?: string
|
address?: string
|
||||||
reference?: 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() ?? "")
|
const {url, address, reference = "", event}: Props = $props()
|
||||||
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 fileInputId = randomId()
|
||||||
|
const pin = event ? reader(Pin)(event) : undefined
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
const back = () => history.back()
|
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(
|
const eventWriter = await addressWriterToSpace(
|
||||||
address ? writer(Pin).setIdentifier().addBoard(address) : writer(Pin, pin),
|
pin ? writer(Pin, pin) : writer(Pin).setIdentifier(),
|
||||||
url,
|
url,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!setPinReference(eventWriter, value)) {
|
if (address && !pin) {
|
||||||
return "Please enter a valid URL or nostr link."
|
eventWriter.addBoard(address)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ed = await editor
|
if (!setPinReference(eventWriter, target)) {
|
||||||
|
return `${target} is not a valid URL or nostr link.`
|
||||||
|
}
|
||||||
|
|
||||||
eventWriter
|
eventWriter.setTitle(fields.title).setTopics(fields.topics).setContent(fields.content)
|
||||||
.setTitle(title)
|
|
||||||
.setTopics(topics)
|
|
||||||
.setContent(ed.getText({blockSeparator: "\n"}).trim())
|
|
||||||
|
|
||||||
const publication = await command(eventWriter).then(publish)
|
const publication = await command(eventWriter).then(publish)
|
||||||
|
|
||||||
return (await publication.settled()).getError()
|
return (await publication.settled()).getError()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const submit = async () => {
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
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) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: 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 {
|
} else {
|
||||||
pushToast({message: pin ? "Link updated!" : "Link added!"})
|
pushToast({message: "Link added!"})
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -87,13 +146,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChange = (json: object) => {
|
const editor = makeEditor({url, submit, uploading, content: pin?.content() ?? ""})
|
||||||
content = json
|
|
||||||
}
|
|
||||||
|
|
||||||
const editor = makeEditor({url, submit: onSubmit, uploading, onChange, content})
|
|
||||||
|
|
||||||
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
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>
|
</script>
|
||||||
|
|
||||||
<Modal>
|
<Modal>
|
||||||
|
|
@ -103,7 +166,7 @@
|
||||||
{#if pin}
|
{#if pin}
|
||||||
Edit Link
|
Edit Link
|
||||||
{:else}
|
{:else}
|
||||||
Add Link
|
Add a Link
|
||||||
{/if}
|
{/if}
|
||||||
</ModalTitle>
|
</ModalTitle>
|
||||||
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||||
|
|
@ -113,13 +176,46 @@
|
||||||
Link
|
Link
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#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}
|
||||||
{#snippet input()}
|
{#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}
|
{/snippet}
|
||||||
</Field>
|
</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">
|
<div transition:slide class="card card-sm flex flex-col gap-4 opacity-75">
|
||||||
<Field>
|
<Field>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
|
|
@ -170,11 +266,13 @@
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
class="button button-primary"
|
class="button button-primary"
|
||||||
onclick={onSubmit}
|
onclick={submit}
|
||||||
disabled={loading || $uploading || !value.trim()}>
|
disabled={loading || $uploading || references.length === 0}>
|
||||||
<Spinner {loading}>
|
<Spinner {loading}>
|
||||||
{#if pin}
|
{#if pin}
|
||||||
Save changes
|
Save changes
|
||||||
|
{:else if references.length > 1}
|
||||||
|
Add {references.length} links
|
||||||
{:else}
|
{:else}
|
||||||
Add link
|
Add link
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
43
src/app/components/PinSelectionMenu.svelte
Normal file
43
src/app/components/PinSelectionMenu.svelte
Normal 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>
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {navigate} from "@app/modal"
|
|
||||||
import {getAddress} from "@welshman/util"
|
import {getAddress} from "@welshman/util"
|
||||||
import {publish} from "@welshman/app"
|
import {publish} from "@welshman/app"
|
||||||
import {Pinboard} from "@welshman/domain"
|
import {Pinboard} from "@welshman/domain"
|
||||||
import type {PinboardReader} from "@welshman/domain"
|
import type {PinboardReader} from "@welshman/domain"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
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 Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
|
@ -15,11 +15,15 @@
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.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 RelayName from "@app/components/RelayName.svelte"
|
||||||
import {command, writer} from "@app/core"
|
import {command, writer} from "@app/core"
|
||||||
|
import {navigate} from "@app/modal"
|
||||||
import {addressWriterToSpace} from "@app/rooms"
|
import {addressWriterToSpace} from "@app/rooms"
|
||||||
import {makeLibraryPath} from "@app/routes"
|
import {makeLibraryPath} from "@app/routes"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
import {resolveImageInput} from "@app/uploads"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -30,12 +34,31 @@
|
||||||
|
|
||||||
const back = () => history.back()
|
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 () => {
|
const submit = async () => {
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const eventWriter = await addressWriterToSpace(
|
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,
|
url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -65,6 +88,9 @@
|
||||||
|
|
||||||
let title = $state(board?.title() ?? "")
|
let title = $state(board?.title() ?? "")
|
||||||
let description = $state(board?.description() ?? "")
|
let description = $state(board?.description() ?? "")
|
||||||
|
let topics = $state<string[]>(board?.topics() ?? [])
|
||||||
|
let image = $state(board?.image() ?? "")
|
||||||
|
let imageFile = $state<File>()
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -93,6 +119,37 @@
|
||||||
placeholder="What's this shelf about?" />
|
placeholder="What's this shelf about?" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</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>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="button button-link" onclick={back}>
|
<Button class="button button-link" onclick={back}>
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,103 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import {derived} from "svelte/store"
|
||||||
import type {PinboardReader} from "@welshman/domain"
|
import {filter, formatTimestampRelative, max, spec, uniq} from "@welshman/lib"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import {COMMENT} from "@welshman/util"
|
||||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
import type {PinReader, PinboardReader} from "@welshman/domain"
|
||||||
import BoardMenu from "@app/components/BoardMenu.svelte"
|
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 UnreadDot from "@app/components/UnreadDot.svelte"
|
||||||
|
import type {FeedContext} from "@app/feeds"
|
||||||
|
import {shelfCoverImages} from "@app/pinboards"
|
||||||
import {makeLibraryPath} from "@app/routes"
|
import {makeLibraryPath} from "@app/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
board: PinboardReader
|
board: PinboardReader
|
||||||
selected: boolean
|
pins: PinReader[]
|
||||||
onclick: () => void
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="relative w-56 shrink-0">
|
<Cv
|
||||||
<Button
|
tag={Link}
|
||||||
{onclick}
|
data-component="PinboardItem"
|
||||||
aria-pressed={selected}
|
href={path}
|
||||||
class={cx("card card-sm card-interactive flex h-full w-full flex-col items-start gap-1 pr-10", {
|
aria-label={title}
|
||||||
"card-primary": selected,
|
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">
|
||||||
<strong class="truncate w-full text-left"
|
{#if cover.length > 0}
|
||||||
><UnreadDot {path} class="mr-1" />{board.title() || "Untitled shelf"}</strong>
|
<div
|
||||||
{#if board.description()}
|
class="grid h-full auto-cols-fr grid-flow-col gap-px transition-transform duration-300 group-hover:scale-105">
|
||||||
<span class="line-clamp-2 text-left text-sm opacity-70">{board.description()}</span>
|
{#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}
|
{/if}
|
||||||
</Button>
|
<UnreadDot {path} class="absolute right-3 top-3 z-feature" />
|
||||||
<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}} />
|
|
||||||
</div>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
back to the login screen.
|
back to the login screen.
|
||||||
</p>
|
</p>
|
||||||
<Button class="button button-primary" onclick={logout} disabled={loading}>
|
<Button class="button button-primary" onclick={logout} disabled={loading}>
|
||||||
<Spinner {loading} reserveSpace>Log out</Spinner>
|
<Spinner {loading}>Log out</Spinner>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,10 @@ export const getUrlContentType = (url: string, event: TrustedEvent) => {
|
||||||
return tagValue(tagSpec("m"), tags) || tagValue(tagSpec("file-type"), tags) || ""
|
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) => {
|
export const decryptUrl = async (url: string, event: TrustedEvent, signal?: AbortSignal) => {
|
||||||
const tags = getUrlTags(url, event)
|
const tags = getUrlTags(url, event)
|
||||||
const algorithm = tagValue(tagSpec("encryption-algorithm"), tags)
|
const algorithm = tagValue(tagSpec("encryption-algorithm"), tags)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,28 @@
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
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 {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 {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) => {
|
const encodeReference = (reference: PinReference) => {
|
||||||
if (reference.type === "event") {
|
if (reference.type === "event") {
|
||||||
|
|
@ -80,3 +100,98 @@ export const setPinReference = (writer: PinWriter, reference: string) => {
|
||||||
|
|
||||||
return false
|
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}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,16 +172,8 @@ export const makeCalendarPath = (url: string, address?: string) =>
|
||||||
|
|
||||||
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
|
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) =>
|
||||||
export const makeLibraryPath = (url: string, address?: string) => {
|
makeSpacePath(url, "library", address)
|
||||||
const path = makeSpacePath(url, "library")
|
|
||||||
|
|
||||||
if (address) {
|
|
||||||
return path + "?" + new URLSearchParams({board: address}).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeContentPath = (url: string, kind: number, idOrAddress?: string) => {
|
export const makeContentPath = (url: string, kind: number, idOrAddress?: string) => {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
29
src/lib/components/Notice.svelte
Normal file
29
src/lib/components/Notice.svelte
Normal 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>
|
||||||
|
|
@ -6,18 +6,10 @@
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
size?: "md" | "sm" | "xs"
|
size?: "md" | "sm" | "xs"
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
// Reserves the spinner's slot while idle, so toggling `loading` doesn't reflow the button.
|
|
||||||
reserveSpace?: boolean
|
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {children, size = "md", loading = true, ...restProps}: Props = $props()
|
||||||
children,
|
|
||||||
size = "md",
|
|
||||||
loading = true,
|
|
||||||
reserveSpace = false,
|
|
||||||
...restProps
|
|
||||||
}: Props = $props()
|
|
||||||
|
|
||||||
const spinnerClass = $derived(
|
const spinnerClass = $derived(
|
||||||
cx("spinner", {
|
cx("spinner", {
|
||||||
|
|
@ -30,8 +22,6 @@
|
||||||
<div class={cx("flex items-center gap-2", restProps.class)}>
|
<div class={cx("flex items-center gap-2", restProps.class)}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<span class={spinnerClass}></span>
|
<span class={spinnerClass}></span>
|
||||||
{:else if reserveSpace}
|
|
||||||
<span class={cx(spinnerClass, "invisible")}></span>
|
|
||||||
{/if}
|
{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,71 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
import {goto} from "$app/navigation"
|
import {max, pushToMapKey, sortBy} from "@welshman/lib"
|
||||||
import {page} from "$app/stores"
|
|
||||||
import {derived} from "svelte/store"
|
|
||||||
import {PIN, PINBOARD} from "@welshman/util"
|
import {PIN, PINBOARD} from "@welshman/util"
|
||||||
import {Pins} from "@welshman/app"
|
import type {PinReader} from "@welshman/domain"
|
||||||
import {Pinboard} from "@welshman/domain"
|
|
||||||
import type {PinboardReader} from "@welshman/domain"
|
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||||
import Add from "@assets/icons/add.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 Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Masonry from "@lib/components/Masonry.svelte"
|
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import PinItem from "@app/components/PinItem.svelte"
|
import PinItem from "@app/components/PinItem.svelte"
|
||||||
import PinSave from "@app/components/PinSave.svelte"
|
|
||||||
import PinboardItem from "@app/components/PinboardItem.svelte"
|
import PinboardItem from "@app/components/PinboardItem.svelte"
|
||||||
import PinboardEdit from "@app/components/PinboardEdit.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 {decodeRelay} from "@app/relays"
|
||||||
import {makeLibraryPath} from "@app/routes"
|
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import type {PageProps} from "./$types"
|
import type {PageProps} from "./$types"
|
||||||
|
|
||||||
const {params}: PageProps = $props()
|
const {params}: PageProps = $props()
|
||||||
|
|
||||||
const url = decodeRelay(params.relay)
|
const url = decodeRelay(params.relay)
|
||||||
|
const context = makeFeedContext({relays: [url]})
|
||||||
|
const shelves = deriveShelves(url)
|
||||||
|
const pins = derivePins(url)
|
||||||
|
|
||||||
let term = $state("")
|
const pinsByShelf = $derived.by(() => {
|
||||||
let loading = $state(true)
|
const result = new Map<string, PinReader[]>()
|
||||||
|
|
||||||
// A shelf belongs to the space it was published to rather than to the pubkey that signed it.
|
for (const pin of $pins) {
|
||||||
const boards = derived($events.forUrl(url, [{kinds: [PINBOARD]}]).$, $pinboards =>
|
for (const address of pin.boards()) {
|
||||||
$pinboards.map(reader(Pinboard)),
|
pushToMapKey(result, address, pin)
|
||||||
)
|
}
|
||||||
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => () =>
|
const term = $derived(search.trim().toLowerCase())
|
||||||
goto(makeLibraryPath(url, board.address()), {replaceState: true, noScroll: true})
|
|
||||||
|
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(() => {
|
onMount(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
@ -77,6 +78,8 @@
|
||||||
|
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onDestroy(context.cleanup)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SpaceBar>
|
<SpaceBar>
|
||||||
|
|
@ -87,53 +90,54 @@
|
||||||
<strong>Library</strong>
|
<strong>Library</strong>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
<Button class="button button-primary button-sm" onclick={createBoard}>
|
<Button class="button button-primary button-sm" onclick={createShelf}>
|
||||||
<Icon icon={Add} />
|
<Icon icon={Add} />
|
||||||
Create Shelf
|
Create Shelf
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</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">
|
<label class="card input input-group flex w-full items-center gap-2">
|
||||||
<Icon size={4} icon={Magnifier} />
|
<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>
|
</label>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="flex items-center justify-center py-20">
|
<p class="flex items-center justify-center py-20">
|
||||||
<Spinner loading>Loading library...</Spinner>
|
<Spinner loading>Loading library...</Spinner>
|
||||||
</p>
|
</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}
|
{:else}
|
||||||
{#if filtered.length > 0}
|
{#if matchingShelves.length > 0}
|
||||||
<div class="scroll-container flex shrink-0 gap-3 overflow-x-auto pb-2">
|
<div class="grid gap-4 @2xl:grid-cols-2 @4xl:grid-cols-3">
|
||||||
{#each filtered as board (board.address())}
|
{#each matchingShelves as board (board.address())}
|
||||||
<PinboardItem
|
<PinboardItem {url} {board} {context} pins={pinsByShelf.get(board.address()) ?? []} />
|
||||||
{url}
|
|
||||||
{board}
|
|
||||||
selected={board.address() === address}
|
|
||||||
onclick={selectBoard(board)} />
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
<div class="divider shrink-0"></div>
|
{#if term}
|
||||||
{#if selected && $pins.length > 0}
|
<h2 class="text-lg">
|
||||||
<Masonry items={$pins} getKey={pin => pin.id()} columnWidth={80} gap={3}>
|
{matchingPins.length === 1
|
||||||
{#snippet child(pin)}
|
? "1 link matches that"
|
||||||
<PinItem {url} {pin} />
|
: `${matchingPins.length} links match that`}
|
||||||
{/snippet}
|
</h2>
|
||||||
</Masonry>
|
<div class="grid gap-4 @2xl:grid-cols-2 @4xl:grid-cols-3">
|
||||||
{:else if selected}
|
{#each matchingPins as pin (pin.event.id)}
|
||||||
<div class="flex flex-col items-center gap-4 py-20 text-center">
|
<PinItem {url} {pin} {context} />
|
||||||
<p class="opacity-70">This shelf doesn't have any links yet.</p>
|
{/each}
|
||||||
<Button class="button button-primary" onclick={addLink}>
|
|
||||||
<Icon icon={AddCircle} />
|
|
||||||
Add a link
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<p class="py-20 text-center opacity-70">Select a shelf to see what's on it.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
|
||||||
184
src/routes/spaces/[relay]/library/[address]/+page.svelte
Normal file
184
src/routes/spaces/[relay]/library/[address]/+page.svelte
Normal 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>
|
||||||
Loading…
Reference in a new issue