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:
|
||||
|
||||
- Typing a term filters the shelf list to shelves whose title, description, or
|
||||
topics match.
|
||||
- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message
|
||||
instead.
|
||||
- Alice is offered "Create Shelf" and "Add a link", and "Add link" on someone
|
||||
else's shelf, but not that shelf's "Edit shelf" or "Delete shelf".
|
||||
- A shelf card carries its title, its description, how many links are on it and
|
||||
who has contributed.
|
||||
- Typing a term filters the shelves to those whose title, description or topics
|
||||
match, and lists the links that match from any shelf.
|
||||
- Opening a shelf shows its links; one that already holds a link still offers
|
||||
"Add a link".
|
||||
- Alice is offered "Create Shelf" and "Add a link" on someone else's shelf, and
|
||||
not its "Edit shelf". Its "Delete shelf" opens a dialog saying only the author
|
||||
or an admin can delete a shelf.
|
||||
|
||||
### US-054 — Curate the library
|
||||
|
||||
|
|
@ -899,13 +902,56 @@ good material first.
|
|||
|
||||
Acceptance:
|
||||
|
||||
- Creating a shelf with a title and description adds it to the list and
|
||||
navigates into it; editing and deleting it from its menu update the list.
|
||||
- Adding a pin with an external url shows it as a content card, and adding one
|
||||
with a nostr note link renders that note as a rich embed.
|
||||
- Creating a shelf with a title, description and topics opens it; editing it
|
||||
from its menu updates its page, and deleting it returns to the library.
|
||||
- Several links pasted at once each become a link of its own and are offered no
|
||||
title or description between them: an external url renders as the link, a
|
||||
nostr note link as the note it points at.
|
||||
- "Add to Library" on a post (for example a poll) with a shelf selected adds it
|
||||
as a pin visible in that shelf; with no shelves yet, the dialog prompts to
|
||||
create one.
|
||||
as a link on that shelf; with no shelves yet, the dialog prompts to create
|
||||
one.
|
||||
- Renaming a link from its menu republishes it under the new title.
|
||||
- bob signs through a browser extension, so everything he publishes here is
|
||||
signed by something outside the page.
|
||||
|
||||
### US-137 — Discuss a shelf and a link on it
|
||||
|
||||
As alice, I want to comment on a shelf and on one link on it, so that a question
|
||||
about what was collected sits with what it is about.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Opening a link raises its own discussion, and a comment posted there appears
|
||||
under it.
|
||||
- A link's card counts the comments on it.
|
||||
- The shelf has a discussion of its own, for anything about the collection
|
||||
rather than about one link.
|
||||
|
||||
### US-138 — Manage a shelf as a list
|
||||
|
||||
As bob, I want to work through a shelf as a list, so that tidying up many links
|
||||
costs one action rather than one each.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- A shelf renders as cards or as a list, and the list gives every link a
|
||||
checkbox with a select-all above them.
|
||||
- Moving the selected links to another shelf files them there and takes them off
|
||||
this one.
|
||||
- Deleting the selected links for everyone asks for confirmation first.
|
||||
|
||||
### US-139 — See who built a shelf and where its links point
|
||||
|
||||
As alice, I want to see who contributed to a shelf and what each link points at,
|
||||
so that a collection reads as somebody's work rather than a list of identifiers.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- A shelf header counts its contributors, opens the roster of them, and each name
|
||||
in it opens that person's profile.
|
||||
- A link to a post reads as a short entity label in the list view, the same way
|
||||
rendered content names one.
|
||||
- Following that label lands on the post.
|
||||
|
||||
### US-055 — Create community content from a room
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export type BootOptions = {
|
|||
indexers?: string[]
|
||||
spaces?: string[]
|
||||
user?: TestUser
|
||||
// Whether this user signs through the window.nostr injectNip07 installed, rather than with a key.
|
||||
nip07?: boolean
|
||||
// What this user's client already has in local storage, e.g. their room list.
|
||||
events?: TrustedEvent[]
|
||||
// Which paths this user has already read, keyed the way src/app/notifications.ts keys them.
|
||||
|
|
@ -33,6 +35,7 @@ export const boot = async (
|
|||
indexers = relays,
|
||||
spaces = [],
|
||||
user,
|
||||
nip07 = false,
|
||||
events = [],
|
||||
checked,
|
||||
path = "/",
|
||||
|
|
@ -73,7 +76,7 @@ export const boot = async (
|
|||
)
|
||||
|
||||
if (user) {
|
||||
await injectSession(context, user)
|
||||
await injectSession(context, user, nip07)
|
||||
await injectEvents(context, events)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
|
|||
})
|
||||
|
||||
await context.addInitScript(key => {
|
||||
const call = (request: Nip07Call) => Reflect.get(window, key)(request)
|
||||
// An extension answers over postMessage, which structured-clones every argument it is given.
|
||||
const call = (request: Nip07Call) => Reflect.get(window, key)(structuredClone(request))
|
||||
|
||||
Object.assign(window, {
|
||||
nostr: {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@ const TEST_SESSION_KEY = "__TEST_SESSION__"
|
|||
const TEST_EVENTS_KEY = "__TEST_EVENTS__"
|
||||
|
||||
// The {method, data} shape @welshman/app's session handlers deserialize, installed before navigating.
|
||||
export const injectSession = (context: BrowserContext, user: TestUser) =>
|
||||
// A nip07 session carries no key and signs through whatever injectNip07 put on window.
|
||||
export const injectSession = (context: BrowserContext, user: TestUser, nip07 = false) =>
|
||||
context.addInitScript(
|
||||
([key, session]) => {
|
||||
Object.assign(window, {[key]: session})
|
||||
},
|
||||
[TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const,
|
||||
[
|
||||
TEST_SESSION_KEY,
|
||||
nip07 ? {method: "nip07", data: {}} : {method: "nip01", data: {secret: user.secret}},
|
||||
] as const,
|
||||
)
|
||||
|
||||
// The local cache a returning user would boot with, loaded once the injected session is restored.
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export type PageOptions = {
|
|||
context?: BrowserContextOptions
|
||||
// VITE_ values applied over the ones derived from the scenario's relays. See BootOptions in app/boot.ts.
|
||||
env?: Record<string, string>
|
||||
// A NIP-07 provider signing as this user, for a login that goes through an extension.
|
||||
// A NIP-07 provider signing as this user, so that `as` signs the way an extension does.
|
||||
nip07?: TestUser
|
||||
// A WebLN provider on window, for a wallet that gets connected through an extension.
|
||||
webln?: WebLnInfo
|
||||
|
|
@ -276,6 +276,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
|
||||
return boot(context, {
|
||||
user,
|
||||
nip07: Boolean(options.nip07),
|
||||
path,
|
||||
env: options.env,
|
||||
checked: options.checked,
|
||||
|
|
|
|||
|
|
@ -46,9 +46,9 @@ import type {TestUser} from "../harness"
|
|||
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
||||
type Seeded = {readonly id: string; readonly event: SignedEvent}
|
||||
|
||||
// A shelf is a card and its menu button side by side, so the menu is reached through their wrapper.
|
||||
// A shelf card carries its cover, its counts and its menu, so the menu is reached through it.
|
||||
const shelfCard = (page: Page, title: string) =>
|
||||
page.getByRole("button", {name: new RegExp(title)}).locator("xpath=..")
|
||||
page.locator('[data-component="PinboardItem"]').filter({hasText: title})
|
||||
|
||||
// Button swallows both the default and the propagation of a click, and the middle of a card is one.
|
||||
const openCard = (card: Locator, title: string) => card.getByText(title).click()
|
||||
|
|
@ -672,35 +672,58 @@ test("US-053 browse and search the library", async ({seed, as}) => {
|
|||
const alice = await as(users.alice, libraryPath)
|
||||
const term = alice.getByPlaceholder("Search library...")
|
||||
|
||||
await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible()
|
||||
await expect(alice.getByRole("button", {name: /Recipes/})).toBeVisible()
|
||||
await expect(shelfCard(alice, "Getting Started")).toBeVisible()
|
||||
await expect(shelfCard(alice, "Recipes")).toBeVisible()
|
||||
|
||||
// A card says how much is on a shelf before anyone opens it.
|
||||
await expect(shelfCard(alice, "Getting Started")).toContainText("1 link")
|
||||
await expect(shelfCard(alice, "Recipes")).toContainText("0 links")
|
||||
|
||||
// A term matches a shelf on its description as well as its title.
|
||||
await term.fill("new members")
|
||||
|
||||
await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible()
|
||||
await expect(alice.getByRole("button", {name: /Recipes/})).toHaveCount(0)
|
||||
await expect(shelfCard(alice, "Getting Started")).toBeVisible()
|
||||
await expect(shelfCard(alice, "Recipes")).toHaveCount(0)
|
||||
|
||||
// A term also reaches past the shelves into the links on them.
|
||||
await term.fill("handbook")
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "1 link matches that"})).toBeVisible()
|
||||
|
||||
await term.fill("")
|
||||
|
||||
await alice.getByRole("button", {name: /Getting Started/}).click()
|
||||
await alice.getByRole("link", {name: "Getting Started", exact: true}).click()
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "Getting Started"})).toBeVisible()
|
||||
await expect(alice.getByText("The Handbook")).toBeVisible()
|
||||
|
||||
await alice.getByRole("button", {name: /Recipes/}).click()
|
||||
// A shelf that already holds a link still offers a way to add another.
|
||||
await expect(alice.getByRole("button", {name: "Add a link"})).toBeVisible()
|
||||
|
||||
await alice.goto(libraryPath)
|
||||
|
||||
// An empty shelf says so rather than drawing a blank page.
|
||||
await alice.getByRole("link", {name: "Recipes", exact: true}).click()
|
||||
|
||||
await expect(alice.getByText("This shelf doesn't have any links yet.")).toBeVisible()
|
||||
|
||||
// The library is the whole space's, so an ordinary member is offered the same controls.
|
||||
await alice.goto(libraryPath)
|
||||
|
||||
await expect(alice.getByRole("button", {name: "Create Shelf"})).toBeVisible()
|
||||
await expect(alice.getByRole("button", {name: "Add a link"})).toBeVisible()
|
||||
|
||||
// Someone else's shelf is hers to add to and not to rewrite.
|
||||
await shelfCard(alice, "Recipes").getByRole("button", {name: "More options"}).click()
|
||||
// A shelf is edited from its own page, and someone else's is hers to add to and not to rewrite.
|
||||
await alice.getByRole("link", {name: "Recipes", exact: true}).click()
|
||||
await alice.getByRole("button", {name: "Shelf options"}).click()
|
||||
|
||||
await expect(alice.getByRole("button", {name: "Add link", exact: true})).toBeVisible()
|
||||
await expect(alice.getByRole("button", {name: "Edit shelf"})).toHaveCount(0)
|
||||
await expect(alice.getByRole("button", {name: "Delete shelf"})).toHaveCount(0)
|
||||
|
||||
// Deleting is offered and says why it is refused, rather than going missing.
|
||||
await alice.getByRole("button", {name: "Delete shelf"}).click()
|
||||
|
||||
await expect(dialog(alice, "You can't delete this shelf")).toContainText(
|
||||
"or an admin of this space, can delete it",
|
||||
)
|
||||
})
|
||||
|
||||
test("US-054 curate the library", async ({seed, as}) => {
|
||||
|
|
@ -738,8 +761,8 @@ test("US-054 curate the library", async ({seed, as}) => {
|
|||
const libraryPath = `${spacePath(url)}/library`
|
||||
const pollPath = `${spacePath(url)}/polls/${poll.id}`
|
||||
|
||||
// Curating is nobody's privilege here, so this is an ordinary member doing all of it.
|
||||
const page = await as(users.bob, libraryPath)
|
||||
// An ordinary member curates, signing through an extension, which clones what it is handed.
|
||||
const page = await as(users.bob, libraryPath, {nip07: users.bob})
|
||||
|
||||
const pollCard = page
|
||||
.locator(".card.z-feature")
|
||||
|
|
@ -751,41 +774,50 @@ test("US-054 curate the library", async ({seed, as}) => {
|
|||
|
||||
await shelfForm.getByPlaceholder("Shelf title").fill("Reading List")
|
||||
await shelfForm.getByPlaceholder("What's this shelf about?").fill("Things worth reading")
|
||||
await shelfForm.getByPlaceholder("Add a topic...").fill("reading")
|
||||
await shelfForm.getByPlaceholder("Add a topic...").press("Enter")
|
||||
await shelfForm.getByRole("button", {name: "Save changes"}).click()
|
||||
|
||||
// Creating a shelf drops its author straight into it.
|
||||
await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
)
|
||||
// Creating a shelf drops its author straight onto it.
|
||||
await expect(page.getByRole("heading", {name: "Reading List"})).toBeVisible()
|
||||
await expect(page.getByText("#reading")).toBeVisible()
|
||||
|
||||
// An external url becomes a card of its own...
|
||||
await page.getByRole("button", {name: "Add a link"}).click()
|
||||
|
||||
const externalLink = dialog(page, "Add Link")
|
||||
|
||||
await externalLink.getByPlaceholder("URL or nevent...").fill("https://handbook.test/style-guide")
|
||||
await externalLink.getByPlaceholder("Optional title").fill("The Style Guide")
|
||||
await externalLink.getByRole("button", {name: "Add link"}).click()
|
||||
|
||||
await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible()
|
||||
|
||||
// ...and a nostr link becomes the note it points at.
|
||||
// Several links go in at once, each as a card of its own.
|
||||
const nevent = nip19.neventEncode({id: message.id, kind: MESSAGE, relays: [url]})
|
||||
|
||||
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
|
||||
await page.getByRole("button", {name: "Add link", exact: true}).click()
|
||||
await page.getByRole("button", {name: "Add a link"}).click()
|
||||
|
||||
const nostrLink = dialog(page, "Add Link")
|
||||
const links = dialog(page, "Add a Link")
|
||||
|
||||
await nostrLink.getByPlaceholder("URL or nevent...").fill(`nostr:${nevent}`)
|
||||
await nostrLink.getByPlaceholder("Optional title").fill("Yesterday's incident")
|
||||
await nostrLink.getByRole("button", {name: "Add link"}).click()
|
||||
await links
|
||||
.getByPlaceholder("URL or nevent...")
|
||||
.fill(`https://handbook.test/style-guide\nnostr:${nevent}`)
|
||||
// A title and a description are about one link, so a batch is offered neither.
|
||||
await expect(links.getByPlaceholder("Optional title")).toHaveCount(0)
|
||||
|
||||
await links.getByRole("button", {name: "Add 2 links"}).click()
|
||||
|
||||
await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible()
|
||||
await expect(page.getByText("the deploy broke again")).toBeVisible()
|
||||
|
||||
// Editing the shelf from its menu updates the list.
|
||||
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
|
||||
// Renaming a link republishes the pin the menu was opened from.
|
||||
const styleGuide = page
|
||||
.locator('[data-component="PinItem"]')
|
||||
.filter({hasText: "handbook.test/style-guide"})
|
||||
|
||||
await menuButton(styleGuide).click()
|
||||
await page.getByRole("button", {name: "Edit Link"}).click()
|
||||
|
||||
const linkEdit = dialog(page, "Edit Link")
|
||||
|
||||
await linkEdit.getByPlaceholder("Optional title").fill("House style guide")
|
||||
await linkEdit.getByRole("button", {name: "Save changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Link updated!")
|
||||
await expect(page.getByText("House style guide")).toBeVisible()
|
||||
|
||||
// Editing the shelf from its menu updates the page it was edited from.
|
||||
await page.getByRole("button", {name: "Shelf options"}).click()
|
||||
await page.getByRole("button", {name: "Edit shelf"}).click()
|
||||
|
||||
const shelfEdit = dialog(page, "Edit Shelf")
|
||||
|
|
@ -793,9 +825,7 @@ test("US-054 curate the library", async ({seed, as}) => {
|
|||
await shelfEdit.getByPlaceholder("What's this shelf about?").fill("Things worth reading twice")
|
||||
await shelfEdit.getByRole("button", {name: "Save changes"}).click()
|
||||
|
||||
await expect(page.getByRole("button", {name: /Reading List/})).toContainText(
|
||||
"Things worth reading twice",
|
||||
)
|
||||
await expect(page.getByText("Things worth reading twice")).toBeVisible()
|
||||
|
||||
// A post filed from its own menu lands on the shelf that was picked for it.
|
||||
await page.goto(pollPath)
|
||||
|
|
@ -803,7 +833,7 @@ test("US-054 curate the library", async ({seed, as}) => {
|
|||
await page.getByRole("button", {name: "Add to Library"}).click()
|
||||
await page.getByRole("button", {name: /Reading List/}).click()
|
||||
|
||||
const fromPoll = dialog(page, "Add Link")
|
||||
const fromPoll = dialog(page, "Add a Link")
|
||||
|
||||
await fromPoll.getByPlaceholder("Optional title").fill("Standup poll")
|
||||
await fromPoll.getByRole("button", {name: "Add link"}).click()
|
||||
|
|
@ -811,17 +841,297 @@ test("US-054 curate the library", async ({seed, as}) => {
|
|||
await expect(page.getByRole("alert")).toContainText("Link added!")
|
||||
|
||||
await page.goto(libraryPath)
|
||||
await page.getByRole("button", {name: /Reading List/}).click()
|
||||
|
||||
await expect(shelfCard(page, "Reading List")).toContainText("3 links")
|
||||
|
||||
await page.getByRole("link", {name: "Reading List", exact: true}).click()
|
||||
|
||||
await expect(page.getByText("Standup poll")).toBeVisible()
|
||||
await expect(page.getByText("Should we move standup to 10?")).toBeVisible()
|
||||
|
||||
// Deleting the shelf from its menu takes it off the list.
|
||||
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
|
||||
// Deleting a shelf from its own page takes it off the library and puts its curator back there.
|
||||
await page.getByRole("button", {name: "Shelf options"}).click()
|
||||
await page.getByRole("button", {name: "Delete shelf"}).click()
|
||||
await page.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(page.getByRole("button", {name: /Reading List/})).toHaveCount(0)
|
||||
await expect(page).toHaveURL(libraryPath)
|
||||
await expect(shelfCard(page, "Reading List")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-137 discuss a shelf and a link on it", async ({seed, as}) => {
|
||||
let shelf!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.admin, "general")
|
||||
space.join(user.alice, "general")
|
||||
space.profile(user.admin, {name: "Ada Admin"})
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
|
||||
shelf = space.event(
|
||||
user.admin,
|
||||
() =>
|
||||
space
|
||||
.kind(Pinboard)
|
||||
.writer()
|
||||
.setIdentifier()
|
||||
.setTitle("Getting Started")
|
||||
.setDescription("Reading for new members")
|
||||
.setCollaborative(true)
|
||||
.renderTemplate(),
|
||||
at(2, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.admin,
|
||||
() =>
|
||||
space
|
||||
.kind(Pin)
|
||||
.writer()
|
||||
.setIdentifier()
|
||||
.addBoard(getAddress(shelf.event))
|
||||
.setTitle("The Handbook")
|
||||
.setExternal("https://handbook.test/start-here")
|
||||
.renderTemplate(),
|
||||
at(1, HOUR),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const alice = await as(users.alice, `${spacePath(url)}/library`)
|
||||
|
||||
await alice.getByRole("link", {name: "Getting Started", exact: true}).click()
|
||||
|
||||
// A link carries a discussion of its own, so asking about one happens where it sits.
|
||||
await alice.getByRole("button", {name: "Discuss"}).click()
|
||||
|
||||
const detail = dialog(alice, "Link")
|
||||
|
||||
// The link's own title stands where the heading was, so nothing says "Link" twice.
|
||||
await expect(detail.getByText("The Handbook")).toBeVisible()
|
||||
|
||||
await detail.getByRole("button", {name: "Comment"}).click()
|
||||
|
||||
const linkComment = detail.locator("form")
|
||||
|
||||
await noteEditor(linkComment).pressSequentially("Is this still the current version?")
|
||||
await linkComment.getByRole("button", {name: "Comment"}).click()
|
||||
|
||||
await expect(
|
||||
detail
|
||||
.locator('[data-component="Comment"]')
|
||||
.filter({hasText: "Is this still the current version?"}),
|
||||
).toBeVisible()
|
||||
|
||||
await detail.getByRole("button", {name: "Close dialog"}).click()
|
||||
|
||||
// The card counts it, so the next person can see there is something to read.
|
||||
await expect(alice.getByRole("button", {name: "1 comment"})).toBeVisible()
|
||||
|
||||
// The shelf has one too, for anything about the collection rather than about one link.
|
||||
await alice.getByRole("button", {name: "Add a comment"}).click()
|
||||
|
||||
const shelfComment = alice.locator("form").filter({has: alice.locator(".note-editor")})
|
||||
|
||||
await noteEditor(shelfComment).pressSequentially("Should we split this in two?")
|
||||
await shelfComment.getByRole("button", {name: "Comment"}).click()
|
||||
|
||||
await expect(
|
||||
alice.locator('[data-component="Comment"]').filter({hasText: "Should we split this in two?"}),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-138 manage a shelf as a list", async ({seed, as}) => {
|
||||
let readingList!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
readingList = space.event(
|
||||
user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Pinboard)
|
||||
.writer()
|
||||
.setIdentifier()
|
||||
.setTitle("Reading List")
|
||||
.setCollaborative(true)
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Pinboard)
|
||||
.writer()
|
||||
.setIdentifier()
|
||||
.setTitle("Archive")
|
||||
.setCollaborative(true)
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
for (const [title, href] of [
|
||||
["The Handbook", "https://handbook.test/start-here"],
|
||||
["The Style Guide", "https://handbook.test/style-guide"],
|
||||
]) {
|
||||
space.event(
|
||||
user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Pin)
|
||||
.writer()
|
||||
.setIdentifier()
|
||||
.addBoard(getAddress(readingList.event))
|
||||
.setTitle(title)
|
||||
.setExternal(href)
|
||||
.renderTemplate(),
|
||||
at(1, HOUR),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const libraryPath = `${spacePath(url)}/library`
|
||||
const page = await as(users.bob, libraryPath)
|
||||
|
||||
await page.getByRole("link", {name: "Reading List", exact: true}).click()
|
||||
await page.getByRole("button", {name: "List"}).click()
|
||||
|
||||
await page.getByRole("checkbox", {name: "Select The Handbook"}).check()
|
||||
await page.getByRole("checkbox", {name: "Select The Style Guide"}).check()
|
||||
|
||||
await expect(page.getByText("2 selected")).toBeVisible()
|
||||
|
||||
// Moving files them on the destination and takes them off here, in one action.
|
||||
await page.getByRole("button", {name: "Bulk actions"}).click()
|
||||
await page.getByRole("button", {name: "Move to Archive"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("2 moved.")
|
||||
await expect(page.getByRole("button", {name: "The Handbook"})).toHaveCount(0)
|
||||
|
||||
await page.goto(libraryPath)
|
||||
|
||||
await expect(shelfCard(page, "Reading List")).toContainText("0 links")
|
||||
await expect(shelfCard(page, "Archive")).toContainText("2 links")
|
||||
|
||||
// Select all reaches everything on the shelf, and deleting for everyone asks first.
|
||||
await page.getByRole("link", {name: "Archive", exact: true}).click()
|
||||
await page.getByRole("button", {name: "List"}).click()
|
||||
await page.getByRole("checkbox", {name: "Select all"}).check()
|
||||
|
||||
await expect(page.getByText("2 selected")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Bulk actions"}).click()
|
||||
await page.getByRole("button", {name: "Delete links"}).click()
|
||||
await page.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("2 deleted.")
|
||||
})
|
||||
|
||||
test("US-139 see who built a shelf and where its links point", async ({seed, as}) => {
|
||||
let shelf!: Seeded
|
||||
let message!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.admin, "general")
|
||||
space.join(user.alice, "general")
|
||||
space.profile(user.admin, {name: "Ada Admin"})
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
|
||||
message = space.message(user.alice, "general", "the anchor came loose again", at(30, MINUTE))
|
||||
|
||||
shelf = space.event(
|
||||
user.admin,
|
||||
() =>
|
||||
space
|
||||
.kind(Pinboard)
|
||||
.writer()
|
||||
.setIdentifier()
|
||||
.setTitle("Field Notes")
|
||||
.setCollaborative(true)
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.admin,
|
||||
() =>
|
||||
space
|
||||
.kind(Pin)
|
||||
.writer()
|
||||
.setIdentifier()
|
||||
.addBoard(getAddress(shelf.event))
|
||||
.setTitle("The Handbook")
|
||||
.setExternal("https://handbook.test/start-here")
|
||||
.renderTemplate(),
|
||||
at(2, HOUR),
|
||||
)
|
||||
|
||||
// A link to a post carries no words of its own, so the post is all the row has to show.
|
||||
space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Pin)
|
||||
.writer()
|
||||
.setIdentifier()
|
||||
.addBoard(getAddress(shelf.event))
|
||||
.setEvent(message.id, space.url)
|
||||
.renderTemplate(),
|
||||
at(1, HOUR),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const libraryPath = `${spacePath(url)}/library`
|
||||
const alice = await as(users.alice, libraryPath)
|
||||
|
||||
await alice.getByRole("link", {name: "Field Notes", exact: true}).click()
|
||||
|
||||
// A card embeds the post it points at, which is also proof the post has loaded.
|
||||
await expect(alice.getByText("the anchor came loose again")).toBeVisible()
|
||||
|
||||
await alice.getByRole("button", {name: "List"}).click()
|
||||
|
||||
const untitled = alice
|
||||
.locator('[data-component="PinList"] > div')
|
||||
.filter({hasText: "Untitled link"})
|
||||
const pointer = untitled.getByRole("button", {name: /^nevent1/})
|
||||
|
||||
// A row names a post the way the rest of the app does, rather than printing its bech32.
|
||||
await expect(pointer).toHaveText(/^nevent1[a-z0-9]{9}…$/)
|
||||
|
||||
await pointer.click()
|
||||
|
||||
await expect(alice).toHaveURL(new RegExp(`${roomPath(url, "general")}\\?`))
|
||||
await expect(alice.getByText("the anchor came loose again")).toBeVisible()
|
||||
|
||||
await alice.goBack()
|
||||
|
||||
// Whoever made the shelf or added to it is a contributor, and the header opens the roster.
|
||||
await alice.getByRole("button", {name: /2 contributors/}).click()
|
||||
|
||||
const contributors = dialog(alice, "Contributors")
|
||||
|
||||
await expect(contributors.getByText("Ada Admin")).toBeVisible()
|
||||
await expect(contributors.getByText("Alice Anderson")).toBeVisible()
|
||||
|
||||
// A name in the roster is a way through to that person.
|
||||
await contributors.getByRole("button", {name: "Ada Admin", exact: true}).click()
|
||||
|
||||
await expect(dialog(alice, "Profile details")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-055 create community content from a room", async ({seed, as}) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {page} from "$app/state"
|
||||
import type {PinboardReader} from "@welshman/domain"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||
|
|
@ -9,12 +9,15 @@
|
|||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import Notice from "@lib/components/Notice.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import PinSave from "@app/components/PinSave.svelte"
|
||||
import PinboardEdit from "@app/components/PinboardEdit.svelte"
|
||||
import {deletes, relays, user} from "@app/core"
|
||||
import {user} from "@app/core"
|
||||
import {deriveSpacePermissions} from "@app/management"
|
||||
import {deleteLibraryEvent} from "@app/pinboards"
|
||||
import {shareEvent} from "@app/share"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {navigate, pushModal} from "@app/modal"
|
||||
import {makeLibraryPath} from "@app/routes"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -25,26 +28,28 @@
|
|||
|
||||
const {url, board, onClick}: Props = $props()
|
||||
|
||||
const permissions = deriveSpacePermissions(url)
|
||||
const isAuthor = $derived(board.event.pubkey === $user.pubkey)
|
||||
const canDelete = $derived(isAuthor || $permissions.deleteContent)
|
||||
|
||||
const showInfo = () => pushModal(EventInfo, {url, event: board.event})
|
||||
|
||||
const share = () => shareEvent(url, "Shelf", board.event)
|
||||
|
||||
const addLink = () => pushModal(PinSave, {url, address: board.address()})
|
||||
|
||||
const edit = () => pushModal(PinboardEdit, {url, board})
|
||||
|
||||
const deleteBoard = async () => {
|
||||
try {
|
||||
const protect = await $relays.hasNip(url, 70)
|
||||
const command = await $deletes.deleteEvent(board.event, writer =>
|
||||
writer.setProtected(protect),
|
||||
)
|
||||
const error = (await command.publishToRelays([url]).settled()).getError()
|
||||
const error = await deleteLibraryEvent(url, board.event)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Shelf deleted!"})
|
||||
|
||||
if (page.url.pathname === makeLibraryPath(url, board.address())) {
|
||||
navigate(makeLibraryPath(url))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
|
@ -58,6 +63,12 @@
|
|||
message: `Delete "${board.title() || "this shelf"}"?`,
|
||||
confirm: deleteBoard,
|
||||
})
|
||||
|
||||
const explainDelete = () =>
|
||||
pushModal(Notice, {
|
||||
title: "You can't delete this shelf",
|
||||
message: "Only the person who made a shelf, or an admin of this space, can delete it.",
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
|
|
@ -73,24 +84,18 @@
|
|||
Share to chat
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={addLink}>
|
||||
<Icon icon={AddCircle} />
|
||||
Add link
|
||||
</Button>
|
||||
</li>
|
||||
{#if board.event.pubkey === $user.pubkey}
|
||||
{#if isAuthor}
|
||||
<li>
|
||||
<Button onclick={edit}>
|
||||
<Icon icon={Pen} />
|
||||
Edit shelf
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button class="text-error" onclick={confirmDelete}>
|
||||
<Icon icon={TrashBin} />
|
||||
Delete shelf
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<Button class="text-error" onclick={canDelete ? confirmDelete : explainDelete}>
|
||||
<Icon icon={TrashBin} />
|
||||
Delete shelf
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import {Note} from "@welshman/domain"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||
import Plane from "@assets/icons/plane-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
|
|
@ -129,7 +130,10 @@
|
|||
<div class="flex justify-end gap-2">
|
||||
<Button class="button button-link" onclick={onCancel} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" class="button button-primary button-sm" disabled={$uploading || loading}>
|
||||
<Spinner {loading} reserveSpace>
|
||||
<Spinner size="sm" {loading}>
|
||||
{#if !loading}
|
||||
<Icon icon={Plane} size={4} />
|
||||
{/if}
|
||||
{#if parent}
|
||||
Reply
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@
|
|||
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
||||
import {
|
||||
getUrlContentType,
|
||||
isImageUrl,
|
||||
AUDIO_CONTENT_TYPES,
|
||||
IMAGE_CONTENT_TYPES,
|
||||
VIDEO_CONTENT_TYPES,
|
||||
} from "@app/content"
|
||||
import {isRoomId} from "@app/rooms"
|
||||
|
|
@ -62,8 +62,7 @@
|
|||
|
||||
const isVideo = Boolean(url.match(/\.(mov|webm|mp4)$/)) || VIDEO_CONTENT_TYPES.includes(fileType)
|
||||
|
||||
const isImage =
|
||||
Boolean(url.match(/\.(jpe?g|png|gif|webp)$/)) || IMAGE_CONTENT_TYPES.includes(fileType)
|
||||
const isImage = isImageUrl(url, event)
|
||||
|
||||
const getVideoPoster = (videoUrl: string): string | undefined => {
|
||||
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@
|
|||
url?: string
|
||||
context: FeedContext
|
||||
columnClass?: string
|
||||
noun?: string
|
||||
}
|
||||
|
||||
const {event, url, context, columnClass}: Props = $props()
|
||||
const {event, url, context, columnClass, noun = "post"}: Props = $props()
|
||||
|
||||
const relays = removeUndefined([url])
|
||||
const filters = getCommentFiltersForRoot([event])
|
||||
|
|
@ -54,7 +55,7 @@
|
|||
<div class={cx("flex w-full flex-wrap items-center justify-between gap-2 py-3", columnClass)}>
|
||||
<h2 class="text-lg font-bold">
|
||||
{nodes.length === 0
|
||||
? "Discussion about this post"
|
||||
? `Discussion about this ${noun}`
|
||||
: `${$comments.length} ${$comments.length === 1 ? "comment" : "comments"}`}
|
||||
</h2>
|
||||
{#if url && replyTo?.id !== event.id}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@
|
|||
let element: HTMLElement
|
||||
type WrapperProps = ComponentProps<typeof Dialog>
|
||||
|
||||
const instances: Record<string, {instance: ReturnType<typeof mount>; props: WrapperProps}> = {}
|
||||
type WrapperState = {active: boolean; restoreFocus: boolean}
|
||||
|
||||
const instances: Record<string, {instance: ReturnType<typeof mount>; state: WrapperState}> = {}
|
||||
|
||||
$effect(() => {
|
||||
const stack = getModalStack()
|
||||
|
|
@ -47,11 +49,11 @@
|
|||
const ids = stack.map(({id}) => id)
|
||||
const activeId = ids.at(-1)
|
||||
|
||||
for (const [id, {instance, props}] of Object.entries(instances)) {
|
||||
props.active = id === activeId
|
||||
for (const [id, {instance, state}] of Object.entries(instances)) {
|
||||
state.active = id === activeId
|
||||
|
||||
if (!ids.includes(id)) {
|
||||
props.restoreFocus = !activeId || Boolean(instances[activeId])
|
||||
state.restoreFocus = !activeId || Boolean(instances[activeId])
|
||||
unmount(instance, {outro: true})
|
||||
delete instances[id]
|
||||
}
|
||||
|
|
@ -64,23 +66,29 @@
|
|||
|
||||
const {options, component, props} = item
|
||||
const wrapper = options.drawer ? Drawer : Dialog
|
||||
const wrapperProps = $state({
|
||||
active: item.id === activeId,
|
||||
// Only the two props this container changes are reactive, so nothing proxies a modal's own.
|
||||
const state = $state({active: item.id === activeId, restoreFocus: true})
|
||||
const wrapperProps: WrapperProps = {
|
||||
get active() {
|
||||
return state.active
|
||||
},
|
||||
get restoreFocus() {
|
||||
return state.restoreFocus
|
||||
},
|
||||
onClose: closeModal,
|
||||
label: options.label,
|
||||
restoreFocus: true,
|
||||
size: options.size,
|
||||
noEscape: options.noEscape,
|
||||
fullscreen: options.fullscreen,
|
||||
children: {component, props},
|
||||
})
|
||||
}
|
||||
|
||||
instances[item.id] = {
|
||||
instance: mount(wrapper as Component, {
|
||||
target: element,
|
||||
props: wrapperProps,
|
||||
}),
|
||||
props: wrapperProps,
|
||||
state,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
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">
|
||||
import {onDestroy} from "svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
import {events} from "@app/core"
|
||||
import {makeFeedContext} from "@app/feeds"
|
||||
|
|
@ -22,7 +22,8 @@
|
|||
{#if $event}
|
||||
<NoteItem {url} {context} event={$event} />
|
||||
{:else}
|
||||
<p class="flex justify-center py-8">
|
||||
<Spinner loading>Loading event...</Spinner>
|
||||
</p>
|
||||
<LoadingFallback
|
||||
class="py-8"
|
||||
loadingMessage="Loading post..."
|
||||
failureMessage="Failed to load this post." />
|
||||
{/if}
|
||||
|
|
|
|||
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">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {Address} from "@welshman/util"
|
||||
import {derived} from "svelte/store"
|
||||
import {filter, formatTimestampRelative, spec, uniq} from "@welshman/lib"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import type {PinReader} from "@welshman/domain"
|
||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import PinContentEvent from "@app/components/PinContentEvent.svelte"
|
||||
import PinMenu from "@app/components/PinMenu.svelte"
|
||||
import {pinToReference} from "@app/pinboards"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import ChatRoundDots from "@assets/icons/chat-round-dots.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ContentActions from "@app/components/ContentActions.svelte"
|
||||
import PinContent from "@app/components/PinContent.svelte"
|
||||
import PinDetail from "@app/components/PinDetail.svelte"
|
||||
import PinSave from "@app/components/PinSave.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import type {FeedContext} from "@app/feeds"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
pin: PinReader
|
||||
minimal?: boolean
|
||||
class?: string
|
||||
context: FeedContext
|
||||
card?: boolean
|
||||
}
|
||||
|
||||
const {url, pin, minimal, ...props}: Props = $props()
|
||||
const {url, pin, context, card = true}: Props = $props()
|
||||
|
||||
// Encoded as nostr: entities (i tags are already urls) so Content parses them into embeds.
|
||||
const reference = $derived(pin.reference())
|
||||
const topics = $derived(uniq(pin.topics().map(normalizeTopic)))
|
||||
const related = context.related(pin.event)
|
||||
const comments = derived(related, $related => filter(spec({kind: COMMENT}), $related))
|
||||
|
||||
const content = $derived.by(() => {
|
||||
if (reference?.type === "event") {
|
||||
return "nostr:" + nip19.neventEncode({id: reference.id, relays: [url]})
|
||||
}
|
||||
|
||||
if (reference?.type === "address") {
|
||||
const {kind, pubkey, identifier} = Address.from(reference.address)
|
||||
|
||||
return "nostr:" + nip19.naddrEncode({kind, pubkey, identifier, relays: [url]})
|
||||
}
|
||||
|
||||
return pinToReference(pin)
|
||||
})
|
||||
const discuss = () => pushModal(PinDetail, {url, pin, context}, {size: "large"})
|
||||
</script>
|
||||
|
||||
<div class="{props.class} flex h-full flex-col gap-2 {minimal ? '' : 'card relative'}">
|
||||
{#if !minimal}
|
||||
<div class="absolute right-2 top-2 z-feature">
|
||||
<MenuButton
|
||||
class="button button-neutral button-sm button-square"
|
||||
aria-label="More options"
|
||||
iconSize={4}
|
||||
component={PinMenu}
|
||||
componentProps={{url, pin}} />
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet topicLabels()}
|
||||
{#each topics as topic (topic)}
|
||||
<span class="badge badge-neutral badge-sm font-normal">#{topic}</span>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet body()}
|
||||
<div class="flex min-w-0 items-center gap-2 text-xs text-content-muted">
|
||||
<ProfileCircle pubkey={pin.event.pubkey} {url} size={4} />
|
||||
<span class="min-w-0 truncate">
|
||||
<ProfileLink unstyled pubkey={pin.event.pubkey} {url} />
|
||||
</span>
|
||||
<span class="whitespace-nowrap">{formatTimestampRelative(pin.event.created_at)}</span>
|
||||
</div>
|
||||
{#if pin.title()}
|
||||
<strong class="truncate min-w-0 pr-8">{pin.title()}</strong>
|
||||
<strong class="min-w-0">{pin.title()}</strong>
|
||||
{/if}
|
||||
{#if pin.content()}
|
||||
<Content event={{content: pin.content(), tags: []}} {url} />
|
||||
{/if}
|
||||
{#if reference?.type === "event" || reference?.type === "address"}
|
||||
<PinContentEvent
|
||||
<PinContent {url} {pin} />
|
||||
<div class="mt-auto flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||
<Button class="button button-neutral button-xs" onclick={discuss}>
|
||||
<Icon size={4} icon={ChatRoundDots} />
|
||||
{#if $comments.length > 0}
|
||||
{$comments.length}
|
||||
{$comments.length === 1 ? "comment" : "comments"}
|
||||
{:else}
|
||||
Discuss
|
||||
{/if}
|
||||
</Button>
|
||||
<ContentActions
|
||||
noun="Link"
|
||||
editForm={PinSave}
|
||||
labels={topics.length > 0 ? topicLabels : undefined}
|
||||
{url}
|
||||
value={reference.type === "event" ? reference.id : reference.address}
|
||||
relays={removeUndefined([reference.relay, url])} />
|
||||
{:else}
|
||||
<Content event={{content, tags: []}} {url} />
|
||||
{/if}
|
||||
{#if !minimal && pin.topics().length > 0}
|
||||
<div class="mt-auto flex flex-wrap gap-1">
|
||||
{#each pin.topics() as topic (topic)}
|
||||
<span class="badge badge-neutral">#{topic}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
event={pin.event}
|
||||
{context} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if card}
|
||||
<Cv data-component="PinItem" class="card flex h-full flex-col gap-2">
|
||||
{@render body()}
|
||||
</Cv>
|
||||
{:else}
|
||||
<Cv
|
||||
data-component="PinItem"
|
||||
class="flex flex-col gap-2 border-b border-solid border-line px-4 py-4">
|
||||
{@render body()}
|
||||
</Cv>
|
||||
{/if}
|
||||
|
|
|
|||
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">
|
||||
import {writable} from "svelte/store"
|
||||
import {randomId} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {Pin} from "@welshman/domain"
|
||||
import {publish} from "@welshman/app"
|
||||
import type {PinReader} from "@welshman/domain"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
import {slide} from "@lib/transition"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import CloudUpload from "@assets/icons/cloud-upload.svg?dataurl"
|
||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -20,8 +23,8 @@
|
|||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {command, writer} from "@app/core"
|
||||
import {pinToReference, setPinReference} from "@app/pinboards"
|
||||
import {command, reader, writer} from "@app/core"
|
||||
import {pinToReference, setPinReference, uploadFilesAsReferences} from "@app/pinboards"
|
||||
import {addressWriterToSpace} from "@app/rooms"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {clearModals} from "@app/modal"
|
||||
|
|
@ -30,53 +33,109 @@
|
|||
url: string
|
||||
address?: string
|
||||
reference?: string
|
||||
pin?: PinReader
|
||||
event?: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, address, reference = "", pin}: Props = $props()
|
||||
type Fields = {
|
||||
title: string
|
||||
content: string
|
||||
topics: string[]
|
||||
}
|
||||
|
||||
let title = $state(pin?.title() ?? "")
|
||||
let topics = $state<string[]>(pin?.topics() ?? [])
|
||||
let value = $state(pin ? pinToReference(pin) : reference)
|
||||
let content = $state<string | object>(pin?.content() ?? "")
|
||||
let loading = $state(false)
|
||||
const {url, address, reference = "", event}: Props = $props()
|
||||
|
||||
const fileInputId = randomId()
|
||||
const pin = event ? reader(Pin)(event) : undefined
|
||||
const uploading = writable(false)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const submit = async () => {
|
||||
const upload = async (files: File[]) => {
|
||||
$uploading = true
|
||||
|
||||
try {
|
||||
const {references: uploaded, errors} = await uploadFilesAsReferences(url, files)
|
||||
|
||||
value = [...references, ...uploaded].join("\n")
|
||||
|
||||
if (errors.length > 0) {
|
||||
pushToast({theme: "error", message: errors.join(" ")})
|
||||
}
|
||||
} finally {
|
||||
$uploading = false
|
||||
}
|
||||
}
|
||||
|
||||
const onFileChange = (e: Event) => {
|
||||
if (e.target instanceof HTMLInputElement && e.target.files?.length) {
|
||||
upload(Array.from(e.target.files))
|
||||
e.target.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const onDrop = (e: Event) => {
|
||||
dropActive = false
|
||||
|
||||
if (e instanceof DragEvent && e.dataTransfer?.files.length) {
|
||||
upload(Array.from(e.dataTransfer.files))
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = () => {
|
||||
dropActive = true
|
||||
}
|
||||
|
||||
const onDragLeave = () => {
|
||||
dropActive = false
|
||||
}
|
||||
|
||||
const savePin = async (target: string, fields: Fields) => {
|
||||
const eventWriter = await addressWriterToSpace(
|
||||
address ? writer(Pin).setIdentifier().addBoard(address) : writer(Pin, pin),
|
||||
pin ? writer(Pin, pin) : writer(Pin).setIdentifier(),
|
||||
url,
|
||||
)
|
||||
|
||||
if (!setPinReference(eventWriter, value)) {
|
||||
return "Please enter a valid URL or nostr link."
|
||||
if (address && !pin) {
|
||||
eventWriter.addBoard(address)
|
||||
}
|
||||
|
||||
const ed = await editor
|
||||
if (!setPinReference(eventWriter, target)) {
|
||||
return `${target} is not a valid URL or nostr link.`
|
||||
}
|
||||
|
||||
eventWriter
|
||||
.setTitle(title)
|
||||
.setTopics(topics)
|
||||
.setContent(ed.getText({blockSeparator: "\n"}).trim())
|
||||
eventWriter.setTitle(fields.title).setTopics(fields.topics).setContent(fields.content)
|
||||
|
||||
const publication = await command(eventWriter).then(publish)
|
||||
|
||||
return (await publication.settled()).getError()
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
const submit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const error = await submit()
|
||||
const ed = await editor
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
|
||||
// A title and a description are about one link, so a batch is filed with neither.
|
||||
const fields =
|
||||
references.length === 1
|
||||
? {title, content, topics}
|
||||
: {title: "", content: "", topics: [] as string[]}
|
||||
|
||||
const errors = await Promise.all(references.map(target => savePin(target, fields)))
|
||||
const error = errors.find(Boolean)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else if (pin) {
|
||||
pushToast({message: "Link updated!"})
|
||||
clearModals()
|
||||
} else if (references.length > 1) {
|
||||
pushToast({message: `${references.length} links added!`})
|
||||
clearModals()
|
||||
} else {
|
||||
pushToast({message: pin ? "Link updated!" : "Link added!"})
|
||||
pushToast({message: "Link added!"})
|
||||
clearModals()
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -87,13 +146,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
const onChange = (json: object) => {
|
||||
content = json
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, submit: onSubmit, uploading, onChange, content})
|
||||
const editor = makeEditor({url, submit, uploading, content: pin?.content() ?? ""})
|
||||
|
||||
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||
|
||||
let title = $state(pin?.title() ?? "")
|
||||
let topics = $state<string[]>(pin?.topics() ?? [])
|
||||
let value = $state(pin ? pinToReference(pin) : reference)
|
||||
let loading = $state(false)
|
||||
let dropActive = $state(false)
|
||||
|
||||
const references = $derived(value.split(/\s+/).filter(Boolean))
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
|
|
@ -103,7 +166,7 @@
|
|||
{#if pin}
|
||||
Edit Link
|
||||
{:else}
|
||||
Add Link
|
||||
Add a Link
|
||||
{/if}
|
||||
</ModalTitle>
|
||||
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||
|
|
@ -113,13 +176,46 @@
|
|||
Link
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<p>A URL or nostr link (note, nevent, naddr, npub, or nprofile).</p>
|
||||
<p>
|
||||
A URL or a nostr link (note, nevent, naddr, npub, or nprofile). Drop files here to upload
|
||||
them. Paste several links, one per line, to file them all at once.
|
||||
</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<input bind:value class="input w-full" placeholder="URL or nevent..." />
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
role="presentation"
|
||||
ondragover={stopPropagation(preventDefault(onDragOver))}
|
||||
ondragleave={stopPropagation(preventDefault(onDragLeave))}
|
||||
ondrop={stopPropagation(preventDefault(onDrop))}>
|
||||
<textarea
|
||||
bind:value
|
||||
rows={pin ? 2 : 3}
|
||||
class="textarea input w-full"
|
||||
style:border-color={dropActive ? "var(--primary)" : undefined}
|
||||
placeholder="URL or nevent..."></textarea>
|
||||
{#if !pin}
|
||||
<label
|
||||
for={fileInputId}
|
||||
class="button button-neutral button-sm cursor-pointer self-start">
|
||||
{#if $uploading}
|
||||
<Spinner size="xs" />
|
||||
{:else}
|
||||
<Icon size={4} icon={CloudUpload} />
|
||||
{/if}
|
||||
Upload files
|
||||
</label>
|
||||
<input id={fileInputId} type="file" multiple class="hidden" onchange={onFileChange} />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
{#if value.trim()}
|
||||
{#if references.length > 1}
|
||||
<p transition:slide class="card card-sm text-sm opacity-75">
|
||||
{references.length} links, each filed on its own. A title and a description describe one link,
|
||||
so add them from its menu once it is on the shelf.
|
||||
</p>
|
||||
{:else if references.length === 1}
|
||||
<div transition:slide class="card card-sm flex flex-col gap-4 opacity-75">
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
|
|
@ -170,11 +266,13 @@
|
|||
</Button>
|
||||
<Button
|
||||
class="button button-primary"
|
||||
onclick={onSubmit}
|
||||
disabled={loading || $uploading || !value.trim()}>
|
||||
onclick={submit}
|
||||
disabled={loading || $uploading || references.length === 0}>
|
||||
<Spinner {loading}>
|
||||
{#if pin}
|
||||
Save changes
|
||||
{:else if references.length > 1}
|
||||
Add {references.length} links
|
||||
{:else}
|
||||
Add link
|
||||
{/if}
|
||||
|
|
|
|||
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">
|
||||
import {navigate} from "@app/modal"
|
||||
import {getAddress} from "@welshman/util"
|
||||
import {publish} from "@welshman/app"
|
||||
import {Pinboard} from "@welshman/domain"
|
||||
import type {PinboardReader} from "@welshman/domain"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
|
|
@ -15,11 +15,15 @@
|
|||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
|
||||
import ImageInputButton from "@app/components/ImageInputButton.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {command, writer} from "@app/core"
|
||||
import {navigate} from "@app/modal"
|
||||
import {addressWriterToSpace} from "@app/rooms"
|
||||
import {makeLibraryPath} from "@app/routes"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {resolveImageInput} from "@app/uploads"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
|
@ -30,12 +34,31 @@
|
|||
|
||||
const back = () => history.back()
|
||||
|
||||
const onImageSelect = (selected: File | string) => {
|
||||
if (typeof selected === "string") {
|
||||
imageFile = undefined
|
||||
image = selected
|
||||
} else {
|
||||
imageFile = selected
|
||||
image = URL.createObjectURL(selected)
|
||||
}
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
imageFile = undefined
|
||||
image = ""
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const eventWriter = await addressWriterToSpace(
|
||||
writer(Pinboard, board).setTitle(title).setDescription(description),
|
||||
writer(Pinboard, board)
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.setImage((await resolveImageInput(imageFile, image)) ?? "")
|
||||
.setTopics(topics),
|
||||
url,
|
||||
)
|
||||
|
||||
|
|
@ -65,6 +88,9 @@
|
|||
|
||||
let title = $state(board?.title() ?? "")
|
||||
let description = $state(board?.description() ?? "")
|
||||
let topics = $state<string[]>(board?.topics() ?? [])
|
||||
let image = $state(board?.image() ?? "")
|
||||
let imageFile = $state<File>()
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
|
|
@ -93,6 +119,37 @@
|
|||
placeholder="What's this shelf about?" />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Cover image
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex items-center gap-3">
|
||||
{#if image}
|
||||
<img src={image} alt="" class="h-16 w-24 rounded-2xl object-cover" />
|
||||
{/if}
|
||||
<ImageInputButton
|
||||
onSelect={onImageSelect}
|
||||
initialValue={imageFile ? undefined : image}
|
||||
aria-label="Add a cover image"
|
||||
class="button button-neutral button-sm">
|
||||
<Icon icon={UploadMinimalistic} size={4} />
|
||||
{image ? "Replace" : "Add an image"}
|
||||
</ImageInputButton>
|
||||
{#if image}
|
||||
<Button class="button button-link button-sm" onclick={clearImage}>Remove</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Topics
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
|
||||
{/snippet}
|
||||
</Field>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
|
|
|
|||
|
|
@ -1,43 +1,103 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import type {PinboardReader} from "@welshman/domain"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||
import BoardMenu from "@app/components/BoardMenu.svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {filter, formatTimestampRelative, max, spec, uniq} from "@welshman/lib"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import type {PinReader, PinboardReader} from "@welshman/domain"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import ChatRoundDots from "@assets/icons/chat-round-dots.svg?dataurl"
|
||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import UnreadDot from "@app/components/UnreadDot.svelte"
|
||||
import type {FeedContext} from "@app/feeds"
|
||||
import {shelfCoverImages} from "@app/pinboards"
|
||||
import {makeLibraryPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
board: PinboardReader
|
||||
selected: boolean
|
||||
onclick: () => void
|
||||
pins: PinReader[]
|
||||
context: FeedContext
|
||||
}
|
||||
|
||||
const {url, board, selected, onclick}: Props = $props()
|
||||
const {url, board, pins, context}: Props = $props()
|
||||
|
||||
const path = $derived(makeLibraryPath(url, board.address()))
|
||||
const path = makeLibraryPath(url, board.address())
|
||||
const related = context.related(board.event)
|
||||
const comments = derived(related, $related => filter(spec({kind: COMMENT}), $related))
|
||||
|
||||
const title = $derived(board.title() || "Untitled shelf")
|
||||
const topics = $derived(uniq(board.topics().map(normalizeTopic)))
|
||||
const contributors = $derived(uniq([board.event.pubkey, ...pins.map(pin => pin.event.pubkey)]))
|
||||
|
||||
const cover = $derived.by(() => {
|
||||
const image = board.image()
|
||||
|
||||
return image ? [image] : shelfCoverImages(pins)
|
||||
})
|
||||
|
||||
const lastActive = $derived(
|
||||
max([
|
||||
board.event.created_at,
|
||||
...pins.map(pin => pin.event.created_at),
|
||||
...$comments.map(comment => comment.created_at),
|
||||
]),
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="relative w-56 shrink-0">
|
||||
<Button
|
||||
{onclick}
|
||||
aria-pressed={selected}
|
||||
class={cx("card card-sm card-interactive flex h-full w-full flex-col items-start gap-1 pr-10", {
|
||||
"card-primary": selected,
|
||||
})}>
|
||||
<strong class="truncate w-full text-left"
|
||||
><UnreadDot {path} class="mr-1" />{board.title() || "Untitled shelf"}</strong>
|
||||
{#if board.description()}
|
||||
<span class="line-clamp-2 text-left text-sm opacity-70">{board.description()}</span>
|
||||
<Cv
|
||||
tag={Link}
|
||||
data-component="PinboardItem"
|
||||
href={path}
|
||||
aria-label={title}
|
||||
class="group card card-interactive card-media flex h-full w-full flex-col">
|
||||
<div class="relative h-32 w-full shrink-0 overflow-hidden bg-surface-more">
|
||||
{#if cover.length > 0}
|
||||
<div
|
||||
class="grid h-full auto-cols-fr grid-flow-col gap-px transition-transform duration-300 group-hover:scale-105">
|
||||
{#each cover as image (image)}
|
||||
<img src={image} alt="" class="h-full w-full object-cover" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<Icon icon={GalleryWide} size={10} class="opacity-20" />
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="absolute right-2 top-2 z-feature">
|
||||
<MenuButton
|
||||
class="button button-neutral button-sm button-square"
|
||||
aria-label="More options"
|
||||
iconSize={4}
|
||||
component={BoardMenu}
|
||||
componentProps={{url, board}} />
|
||||
<UnreadDot {path} class="absolute right-3 top-3 z-feature" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 grow flex-col gap-2 p-4">
|
||||
<p class="wrap-break-word text-xl leading-snug">{title}</p>
|
||||
{#if board.description()}
|
||||
<p class="line-clamp-2 wrap-break-word text-sm text-content-muted">{board.description()}</p>
|
||||
{/if}
|
||||
{#if topics.length > 0}
|
||||
<div class="flex min-w-0 flex-wrap gap-1">
|
||||
{#each topics.slice(0, 2) as topic (topic)}
|
||||
<span class="badge badge-sm rounded-full font-normal">#{topic}</span>
|
||||
{/each}
|
||||
{#if topics.length > 2}
|
||||
<span class="badge badge-sm rounded-full font-normal">+{topics.length - 2}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-2 border-t border-solid border-line px-4 py-3 text-xs text-content-muted">
|
||||
<span class="flex items-center gap-3">
|
||||
<span>{pins.length} {pins.length === 1 ? "link" : "links"}</span>
|
||||
{#if $comments.length > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon size={3} icon={ChatRoundDots} />
|
||||
{$comments.length}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<ProfileCircles pubkeys={contributors} size={5} limit={4} />
|
||||
<span class="whitespace-nowrap">{formatTimestampRelative(lastActive)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
back to the login screen.
|
||||
</p>
|
||||
<Button class="button button-primary" onclick={logout} disabled={loading}>
|
||||
<Spinner {loading} reserveSpace>Log out</Spinner>
|
||||
<Spinner {loading}>Log out</Spinner>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ export const getUrlContentType = (url: string, event: TrustedEvent) => {
|
|||
return tagValue(tagSpec("m"), tags) || tagValue(tagSpec("file-type"), tags) || ""
|
||||
}
|
||||
|
||||
export const isImageUrl = (url: string, event: TrustedEvent) =>
|
||||
Boolean(url.match(/\.(jpe?g|png|gif|webp)$/)) ||
|
||||
IMAGE_CONTENT_TYPES.includes(getUrlContentType(url, event))
|
||||
|
||||
export const decryptUrl = async (url: string, event: TrustedEvent, signal?: AbortSignal) => {
|
||||
const tags = getUrlTags(url, event)
|
||||
const algorithm = tagValue(tagSpec("encryption-algorithm"), tags)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,28 @@
|
|||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {Address, fromNostrURI, getAddress, isReplaceable} from "@welshman/util"
|
||||
import {derived} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {removeUndefined, sortBy} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {Address, PIN, PINBOARD, fromNostrURI, getAddress, isReplaceable} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import type {PinReader, PinReference, PinWriter} from "@welshman/domain"
|
||||
import {Pin, Pinboard} from "@welshman/domain"
|
||||
import type {PinReader, PinReference, PinWriter, PinboardReader} from "@welshman/domain"
|
||||
import {isLink, parse} from "@welshman/content"
|
||||
import {publish} from "@welshman/app"
|
||||
import {
|
||||
app,
|
||||
command,
|
||||
deletes,
|
||||
events,
|
||||
reader,
|
||||
relayManagement,
|
||||
relays,
|
||||
user,
|
||||
writer,
|
||||
} from "@app/core"
|
||||
import {addressWriterToSpace} from "@app/rooms"
|
||||
import {isImageUrl} from "@app/content"
|
||||
import {compressFileForUpload, uploadFile} from "@app/uploads"
|
||||
|
||||
const encodeReference = (reference: PinReference) => {
|
||||
if (reference.type === "event") {
|
||||
|
|
@ -80,3 +100,98 @@ export const setPinReference = (writer: PinWriter, reference: string) => {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
export const deriveShelves = (url: string): Readable<PinboardReader[]> =>
|
||||
derived(events.get().forUrl(url, [{kinds: [PINBOARD]}]).$, $events =>
|
||||
$events.map(reader(Pinboard)),
|
||||
)
|
||||
|
||||
export const derivePins = (url: string): Readable<PinReader[]> =>
|
||||
derived(events.get().forUrl(url, [{kinds: [PIN]}]).$, $events => $events.map(reader(Pin)))
|
||||
|
||||
export const deriveShelf = (url: string, address: string): Readable<Maybe<PinboardReader>> =>
|
||||
derived(events.get().one(address, [url]).$, $event =>
|
||||
$event ? reader(Pinboard)($event) : undefined,
|
||||
)
|
||||
|
||||
const matchesTerm = (term: string, fields: Maybe<string>[]) =>
|
||||
fields.some(field => field?.toLowerCase().includes(term))
|
||||
|
||||
export const shelfMatchesTerm = (shelf: PinboardReader, term: string) =>
|
||||
matchesTerm(term, [shelf.title(), shelf.description(), ...shelf.topics()])
|
||||
|
||||
export const pinMatchesTerm = (pin: PinReader, term: string) =>
|
||||
matchesTerm(term, [pin.title(), pin.content(), pinToReference(pin), ...pin.topics()])
|
||||
|
||||
// A pin's own words. Its reference is rendered as a link rather than named here.
|
||||
export const displayPin = (pin: PinReader) => pin.title() || pin.content() || "Untitled link"
|
||||
|
||||
const pinImage = (pin: PinReader) => {
|
||||
const reference = pin.reference()
|
||||
|
||||
if (reference?.type === "external" && isImageUrl(reference.id, pin.event)) {
|
||||
return reference.id
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// A shelf with no cover of its own is illustrated by the pictures on it.
|
||||
export const shelfCoverImages = (pins: PinReader[]) =>
|
||||
removeUndefined(sortBy(pin => -pin.event.created_at, pins).map(pinImage)).slice(0, 3)
|
||||
|
||||
// A library pin is published to the space rather than to an author outbox, so every write goes there.
|
||||
const publishToSpace = async (url: string, eventWriter: PinWriter) => {
|
||||
await addressWriterToSpace(eventWriter, url)
|
||||
|
||||
const publication = await command(eventWriter).then(publish)
|
||||
|
||||
return (await publication.settled()).getError()
|
||||
}
|
||||
|
||||
// One write per pin: a second update built from the same reader would undo the first.
|
||||
export const movePinsToShelf = (url: string, pins: PinReader[], from: string, to: string) =>
|
||||
Promise.all(pins.map(pin => publishToSpace(url, writer(Pin, pin).addBoard(to).removeBoard(from))))
|
||||
|
||||
export const removePinsFromShelf = (url: string, pins: PinReader[], address: string) =>
|
||||
Promise.all(pins.map(pin => publishToSpace(url, writer(Pin, pin).removeBoard(address))))
|
||||
|
||||
// Your own goes as a kind 5; anyone else's is a NIP-86 ban, which the repository never hears about.
|
||||
export const deleteLibraryEvent = async (url: string, event: TrustedEvent) => {
|
||||
if (event.pubkey === user.get().pubkey) {
|
||||
const protect = await relays.get().hasNip(url, 70)
|
||||
const request = await deletes
|
||||
.get()
|
||||
.deleteEvent(event, deleteWriter => deleteWriter.setProtected(protect))
|
||||
|
||||
return (await request.publishToRelays([url]).settled()).getError()
|
||||
}
|
||||
|
||||
const {error} = await relayManagement.get().forUrl(url).banEvent(event.id)
|
||||
|
||||
if (!error) {
|
||||
app.get().repository.removeEvent(event.id)
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
export const deletePins = (url: string, pins: PinReader[]) =>
|
||||
Promise.all(pins.map(pin => deleteLibraryEvent(url, pin.event)))
|
||||
|
||||
export const uploadFilesAsReferences = async (url: string, files: File[]) => {
|
||||
const references: string[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
|
||||
|
||||
if (result) {
|
||||
references.push(result.url)
|
||||
} else {
|
||||
errors.push(error ?? `${file.name} could not be uploaded.`)
|
||||
}
|
||||
}
|
||||
|
||||
return {references, errors}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,16 +172,8 @@ export const makeCalendarPath = (url: string, address?: string) =>
|
|||
|
||||
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
|
||||
|
||||
// Shelves are selected in place on the library page, so the address goes in a query param.
|
||||
export const makeLibraryPath = (url: string, address?: string) => {
|
||||
const path = makeSpacePath(url, "library")
|
||||
|
||||
if (address) {
|
||||
return path + "?" + new URLSearchParams({board: address}).toString()
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
export const makeLibraryPath = (url: string, address?: string) =>
|
||||
makeSpacePath(url, "library", address)
|
||||
|
||||
export const makeContentPath = (url: string, kind: number, idOrAddress?: string) => {
|
||||
switch (kind) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
size?: "md" | "sm" | "xs"
|
||||
loading?: boolean
|
||||
// Reserves the spinner's slot while idle, so toggling `loading` doesn't reflow the button.
|
||||
reserveSpace?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {
|
||||
children,
|
||||
size = "md",
|
||||
loading = true,
|
||||
reserveSpace = false,
|
||||
...restProps
|
||||
}: Props = $props()
|
||||
const {children, size = "md", loading = true, ...restProps}: Props = $props()
|
||||
|
||||
const spinnerClass = $derived(
|
||||
cx("spinner", {
|
||||
|
|
@ -30,8 +22,6 @@
|
|||
<div class={cx("flex items-center gap-2", restProps.class)}>
|
||||
{#if loading}
|
||||
<span class={spinnerClass}></span>
|
||||
{:else if reserveSpace}
|
||||
<span class={cx(spinnerClass, "invisible")}></span>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,70 +1,71 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import {page} from "$app/stores"
|
||||
import {derived} from "svelte/store"
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
import {max, pushToMapKey, sortBy} from "@welshman/lib"
|
||||
import {PIN, PINBOARD} from "@welshman/util"
|
||||
import {Pins} from "@welshman/app"
|
||||
import {Pinboard} from "@welshman/domain"
|
||||
import type {PinboardReader} from "@welshman/domain"
|
||||
import type {PinReader} from "@welshman/domain"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||
import Add from "@assets/icons/add.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Masonry from "@lib/components/Masonry.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import PinItem from "@app/components/PinItem.svelte"
|
||||
import PinSave from "@app/components/PinSave.svelte"
|
||||
import PinboardItem from "@app/components/PinboardItem.svelte"
|
||||
import PinboardEdit from "@app/components/PinboardEdit.svelte"
|
||||
import {app, events, network, reader} from "@app/core"
|
||||
import {network} from "@app/core"
|
||||
import {makeFeedContext} from "@app/feeds"
|
||||
import {derivePins, deriveShelves, pinMatchesTerm, shelfMatchesTerm} from "@app/pinboards"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {makeLibraryPath} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
import type {PageProps} from "./$types"
|
||||
|
||||
const {params}: PageProps = $props()
|
||||
|
||||
const url = decodeRelay(params.relay)
|
||||
const context = makeFeedContext({relays: [url]})
|
||||
const shelves = deriveShelves(url)
|
||||
const pins = derivePins(url)
|
||||
|
||||
let term = $state("")
|
||||
let loading = $state(true)
|
||||
const pinsByShelf = $derived.by(() => {
|
||||
const result = new Map<string, PinReader[]>()
|
||||
|
||||
// A shelf belongs to the space it was published to rather than to the pubkey that signed it.
|
||||
const boards = derived($events.forUrl(url, [{kinds: [PINBOARD]}]).$, $pinboards =>
|
||||
$pinboards.map(reader(Pinboard)),
|
||||
)
|
||||
|
||||
const address = $derived($page.url.searchParams.get("board") ?? "")
|
||||
|
||||
const selected = $derived($boards.find(board => board.address() === address))
|
||||
|
||||
const pins = $derived($app.use(Pins).forBoard(address).$)
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const value = term.trim().toLowerCase()
|
||||
|
||||
if (value) {
|
||||
return $boards.filter(board =>
|
||||
[board.title(), board.description(), ...board.topics()].some(field =>
|
||||
field?.toLowerCase().includes(value),
|
||||
),
|
||||
)
|
||||
for (const pin of $pins) {
|
||||
for (const address of pin.boards()) {
|
||||
pushToMapKey(result, address, pin)
|
||||
}
|
||||
}
|
||||
|
||||
return $boards
|
||||
return result
|
||||
})
|
||||
|
||||
const createBoard = () => pushModal(PinboardEdit, {url})
|
||||
// A shelf is as recent as the newest thing on it, so curating one brings it back to the front.
|
||||
const sorted = $derived(
|
||||
sortBy(
|
||||
shelf =>
|
||||
-max([
|
||||
shelf.event.created_at,
|
||||
...(pinsByShelf.get(shelf.address()) ?? []).map(pin => pin.event.created_at),
|
||||
]),
|
||||
$shelves,
|
||||
),
|
||||
)
|
||||
|
||||
const addLink = () => pushModal(PinSave, {url, address})
|
||||
let search = $state("")
|
||||
let loading = $state(true)
|
||||
|
||||
const selectBoard = (board: PinboardReader) => () =>
|
||||
goto(makeLibraryPath(url, board.address()), {replaceState: true, noScroll: true})
|
||||
const term = $derived(search.trim().toLowerCase())
|
||||
|
||||
const matchingShelves = $derived(
|
||||
term ? sorted.filter(shelf => shelfMatchesTerm(shelf, term)) : sorted,
|
||||
)
|
||||
|
||||
const matchingPins = $derived(
|
||||
term ? $pins.filter(pin => pin.boards().length > 0 && pinMatchesTerm(pin, term)) : [],
|
||||
)
|
||||
|
||||
const createShelf = () => pushModal(PinboardEdit, {url})
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
|
|
@ -77,6 +78,8 @@
|
|||
|
||||
return () => controller.abort()
|
||||
})
|
||||
|
||||
onDestroy(context.cleanup)
|
||||
</script>
|
||||
|
||||
<SpaceBar>
|
||||
|
|
@ -87,53 +90,54 @@
|
|||
<strong>Library</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button class="button button-primary button-sm" onclick={createBoard}>
|
||||
<Button class="button button-primary button-sm" onclick={createShelf}>
|
||||
<Icon icon={Add} />
|
||||
Create Shelf
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
||||
<PageContent class="flex flex-col gap-4 p-4">
|
||||
<PageContent class="@container flex flex-col gap-4 p-4">
|
||||
<label class="card input input-group flex w-full items-center gap-2">
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
<input bind:value={term} class="min-w-0 grow" type="text" placeholder="Search library..." />
|
||||
<input bind:value={search} class="min-w-0 grow" type="text" placeholder="Search library..." />
|
||||
</label>
|
||||
{#if loading}
|
||||
<p class="flex items-center justify-center py-20">
|
||||
<Spinner loading>Loading library...</Spinner>
|
||||
</p>
|
||||
{:else if $shelves.length === 0}
|
||||
<div class="card flex flex-col items-center gap-4 py-16 text-center">
|
||||
<p class="text-xl">This space has no shelves yet.</p>
|
||||
<p class="opacity-75">
|
||||
A shelf collects links, notes and files worth keeping where everyone can find them.
|
||||
</p>
|
||||
<Button class="button button-primary" onclick={createShelf}>
|
||||
<Icon icon={Add} />
|
||||
Create the first shelf
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if filtered.length > 0}
|
||||
<div class="scroll-container flex shrink-0 gap-3 overflow-x-auto pb-2">
|
||||
{#each filtered as board (board.address())}
|
||||
<PinboardItem
|
||||
{url}
|
||||
{board}
|
||||
selected={board.address() === address}
|
||||
onclick={selectBoard(board)} />
|
||||
{#if matchingShelves.length > 0}
|
||||
<div class="grid gap-4 @2xl:grid-cols-2 @4xl:grid-cols-3">
|
||||
{#each matchingShelves as board (board.address())}
|
||||
<PinboardItem {url} {board} {context} pins={pinsByShelf.get(board.address()) ?? []} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="py-8 text-center opacity-70">No shelves found.</p>
|
||||
<p class="py-8 text-center opacity-70">No shelves match that.</p>
|
||||
{/if}
|
||||
<div class="divider shrink-0"></div>
|
||||
{#if selected && $pins.length > 0}
|
||||
<Masonry items={$pins} getKey={pin => pin.id()} columnWidth={80} gap={3}>
|
||||
{#snippet child(pin)}
|
||||
<PinItem {url} {pin} />
|
||||
{/snippet}
|
||||
</Masonry>
|
||||
{:else if selected}
|
||||
<div class="flex flex-col items-center gap-4 py-20 text-center">
|
||||
<p class="opacity-70">This shelf doesn't have any links yet.</p>
|
||||
<Button class="button button-primary" onclick={addLink}>
|
||||
<Icon icon={AddCircle} />
|
||||
Add a link
|
||||
</Button>
|
||||
{#if term}
|
||||
<h2 class="text-lg">
|
||||
{matchingPins.length === 1
|
||||
? "1 link matches that"
|
||||
: `${matchingPins.length} links match that`}
|
||||
</h2>
|
||||
<div class="grid gap-4 @2xl:grid-cols-2 @4xl:grid-cols-3">
|
||||
{#each matchingPins as pin (pin.event.id)}
|
||||
<PinItem {url} {pin} {context} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="py-20 text-center opacity-70">Select a shelf to see what's on it.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
|
|
|||
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