Redesign the goal list and detail pages (#582)

This commit is contained in:
Coracle Bot 2026-09-21 20:25:01 +00:00 committed by hodlbod
parent 590803f0a4
commit fa489d4b72
24 changed files with 768 additions and 241 deletions

View file

@ -812,11 +812,14 @@ members can see the momentum.
Acceptance: Acceptance:
- Creating a goal requires a title and details; the sats target defaults to 1000 - Creating a goal requires a title and details; the sats target defaults to 1000
and can be set by field or slider. and can be set by field or slider, and a deadline and cover image are optional.
- The goals page is a board: space-wide totals, live/funded/ended filters, and a
sort by recent activity, age, progress or deadline.
- The goal's detail page shows the amount funded against its target on a - The goal's detail page shows the amount funded against its target on a
progress bar. progress meter, with what is left to raise and how long it has left or has
- A goal with contributions shows a contributor count and how long it has been been running.
running. - A goal with contributions names its backers, ranked by what each gave, with
the comment that came with the zap.
### US-051 — Post, edit, and close out a classified listing ### US-051 — Post, edit, and close out a classified listing

View file

@ -594,23 +594,25 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) =
await openCard(roof, "Repair the roof") await openCard(roof, "Repair the roof")
await expect(page.getByRole("heading", {name: "Repair the roof"})).toBeVisible() // The space bar names the goal alongside the hero, so both carry it as a heading.
await expect(page.getByRole("heading", {name: "Repair the roof"}).first()).toBeVisible()
const goalCard = page.locator(".card.z-feature").filter({hasText: "funded of 6000 sats"}) // Amounts are localized, so the thousands separator is the environment's to decide.
const goalCard = page.locator(".card.z-feature").filter({hasText: /of 6,?000 sats/})
// Exactly, since "funded of 6000 sats" carries "0 sats" inside it. await expect(goalCard.getByText("0%", {exact: true})).toBeVisible()
await expect(goalCard.getByText("0 sats", {exact: true})).toBeVisible() await expect(goalCard.getByText("sats to go", {exact: true}).locator("xpath=..")).toContainText(
await expect(goalCard.locator("progress")).toHaveJSProperty("value", 0) /6,?000/,
await expect(goalCard.locator("progress")).toHaveJSProperty("max", 6000) )
await page.goto(`${spacePath(url)}/goals/${running.id}`) await page.goto(`${spacePath(url)}/goals/${running.id}`)
const paCard = page.locator(".card.z-feature").filter({hasText: "funded of 50000 sats"}) const paCard = page.locator(".card.z-feature").filter({hasText: /of 50,?000 sats/})
await expect(paCard.getByText("contributors", {exact: true}).locator("xpath=..")).toContainText( await expect(paCard.getByText("backers", {exact: true}).locator("xpath=..")).toContainText("0")
"0", await expect(paCard.getByText("days running", {exact: true}).locator("xpath=..")).toContainText(
"3",
) )
await expect(paCard.getByText("days old", {exact: true}).locator("xpath=..")).toContainText("3")
// Registered after the page was opened, so it answers ahead of the empty dufflepud `as()` // Registered after the page was opened, so it answers ahead of the empty dufflepud `as()`
// installs, and before the navigation below, since a zapper is looked up once per page load. // installs, and before the navigation below, since a zapper is looked up once per page load.
@ -625,16 +627,24 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) =
await page.goto(`${spacePath(url)}/goals/${soundproofing.id}`) await page.goto(`${spacePath(url)}/goals/${soundproofing.id}`)
const soundCard = page.locator(".card.z-feature").filter({hasText: "funded of 20000 sats"}) const soundCard = page.locator(".card.z-feature").filter({hasText: /of 20,?000 sats/})
await expect(soundCard.getByText("1500 sats", {exact: true})).toBeVisible() await expect(soundCard.getByText(/^1,?500$/)).toBeVisible()
await expect(soundCard.locator("progress")).toHaveJSProperty("value", 1500) await expect(soundCard.getByText("8%", {exact: true})).toBeVisible()
await expect(soundCard.getByText("backers", {exact: true}).locator("xpath=..")).toContainText("2")
await expect( await expect(
soundCard.getByText("contributors", {exact: true}).locator("xpath=.."), soundCard.getByText("days running", {exact: true}).locator("xpath=.."),
).toContainText("2") ).toContainText("2")
await expect(soundCard.getByText("days old", {exact: true}).locator("xpath=..")).toContainText(
"2", // Each backer is named and ranked by what they gave, biggest first.
) const supporters = page
.locator(".card")
.filter({has: page.getByRole("heading", {name: "Supporters"})})
.last()
await expect(supporters).toContainText("Alice Anderson")
await expect(supporters.getByText(/^1,?000$/)).toBeVisible()
await expect(supporters.getByText(/^500$/)).toBeVisible()
}) })
test("US-051 post, edit, and close out a classified listing", async ({seed, as}) => { test("US-051 post, edit, and close out a classified listing", async ({seed, as}) => {

View file

@ -133,7 +133,7 @@
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button class="button button-link" onclick={onCancel} disabled={loading}>Cancel</Button> <Button class="button button-link" onclick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" class="button button-primary button-sm" disabled={$uploading || loading}> <Button type="submit" class="button button-primary button-sm" disabled={$uploading || loading}>
<Spinner {loading}> <Spinner {loading} reserveSpace>
{#if parent} {#if parent}
Reply Reply
{:else} {:else}

View file

@ -31,7 +31,7 @@
const createReaction = (values: EventContent) => publishReaction(event, values, {url}) const createReaction = (values: EventContent) => publishReaction(event, values, {url})
</script> </script>
<div class="flex grow flex-wrap justify-end gap-2"> <div class="flex min-w-0 grow flex-wrap justify-end gap-2">
{#if h && showRoom} {#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full"> <Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} /> Posted in #<RoomName {h} {url} />
@ -39,6 +39,7 @@
{/if} {/if}
<ThunkStatusOrDeleted {event} {context}> <ThunkStatusOrDeleted {event} {context}>
<ReactionSummary <ReactionSummary
hideZaps
{url} {url}
{event} {event}
{context} {context}

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {now} from "@welshman/lib"
import {relay, toMsats} from "@welshman/util" import {relay, toMsats} from "@welshman/util"
import {ZapGoal} from "@welshman/domain" import {ZapGoal} from "@welshman/domain"
import {publish} from "@welshman/app" import {publish} from "@welshman/app"
@ -12,6 +13,8 @@
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import ImagesInput from "@lib/components/ImagesInput.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@ -24,11 +27,14 @@
import {publishRoomQuote} from "@app/rooms" import {publishRoomQuote} from "@app/rooms"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts" import {DraftKey} from "@app/drafts"
import {compressFileForUpload, uploadFile} from "@app/uploads"
type Values = { type Values = {
title: string title: string
content: string | object content: string | object
amount: number amount: number
images: (string | File)[]
closedAt?: number
} }
type Props = { type Props = {
@ -76,9 +82,30 @@
}) })
} }
if (closedAt && closedAt < now()) {
return pushToast({
theme: "error",
message: "Please choose a deadline in the future.",
})
}
loading = true loading = true
try { try {
const [image] = images
let imageUrl = typeof image === "string" ? image : undefined
if (image instanceof File) {
const {result, error} = await uploadFile(await compressFileForUpload(image), {url})
if (error) {
return pushToast({theme: "error", message: `Failed to upload ${image.name}`})
}
imageUrl = result?.url
}
const protect = await shouldProtect const protect = await shouldProtect
const eventWriter = writer(ZapGoal) const eventWriter = writer(ZapGoal)
.setTitle(title) .setTitle(title)
@ -89,6 +116,14 @@
.addTags(...ed.storage.nostr.getEditorTags()) .addTags(...ed.storage.nostr.getEditorTags())
.forceRoutes(relay(url)) .forceRoutes(relay(url))
if (imageUrl) {
eventWriter.setImage(imageUrl)
}
if (closedAt) {
eventWriter.setClosedAt(closedAt)
}
if (h) { if (h) {
eventWriter.setRoom(url, h) eventWriter.setRoom(url, h)
} }
@ -116,6 +151,8 @@
let title = $state(initialValues?.title ?? "") let title = $state(initialValues?.title ?? "")
let amount = $state(initialValues?.amount ?? 1000) let amount = $state(initialValues?.amount ?? 1000)
let content = $state(initialValues?.content ?? "") let content = $state(initialValues?.content ?? "")
let images = $state(initialValues?.images ?? [])
let closedAt: number | undefined = $state(initialValues?.closedAt)
const onChange = (json: object) => { const onChange = (json: object) => {
content = json content = json
@ -131,7 +168,7 @@
}) })
$effect(() => { $effect(() => {
draftKey.update({title, content, amount}) draftKey.update({title, content, amount, images, closedAt})
}) })
</script> </script>
@ -204,6 +241,25 @@
step="1000" step="1000"
bind:value={amount} /> bind:value={amount} />
</div> </div>
<Field>
{#snippet label()}
<p>Deadline (optional)</p>
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={closedAt} />
{/snippet}
{#snippet info()}
Contributions made after this date won't count toward the goal.
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Cover image (optional)</p>
{/snippet}
{#snippet input()}
<ImagesInput bind:value={images} multiple={false} />
{/snippet}
</Field>
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {Readable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {ZapGoal} from "@welshman/domain" import {ZapGoal} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
@ -8,6 +9,7 @@
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import GoalActions from "@app/components/GoalActions.svelte" import GoalActions from "@app/components/GoalActions.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import type {GoalProgress} from "@app/goals"
import GoalSummary from "@app/components/GoalSummary.svelte" import GoalSummary from "@app/components/GoalSummary.svelte"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte" import UnreadDot from "@app/components/UnreadDot.svelte"
@ -17,31 +19,40 @@
url: string url: string
event: TrustedEvent event: TrustedEvent
context: FeedContext context: FeedContext
progress?: Readable<GoalProgress>
} }
const {url, event, context}: Props = $props() const {url, event, context, progress}: Props = $props()
const goal = reader(ZapGoal)(event) const goal = reader(ZapGoal)(event)
const path = makeGoalPath(url, event.id) const path = makeGoalPath(url, event.id)
const title = goal.title() const title = goal.title()
const summary = goal.summary() const summary = goal.summary()
const image = goal.image()
const h = goal.room() const h = goal.room()
</script> </script>
<Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}> <Cv tag={Link} class="relative flex flex-col gap-3 card card-interactive w-full" href={path}>
<UnreadDot {path} class="absolute right-3 top-3" /> <UnreadDot {path} class="absolute right-3 top-3" />
<p class="text-2xl">{title}</p> {#if image}
<Content <img src={image} alt="" class="h-40 w-full max-w-full rounded-2xl object-cover" />
event={{content: summary, tags: event.tags}} {/if}
{url} <p class="line-clamp-2 wrap-break-word text-xl font-bold">{title}</p>
expandMode="inline" {#if summary}
minLength={50} <div class="text-sm text-content-muted">
maxLength={300} /> <Content
<GoalSummary {url} {event} /> event={{content: summary, tags: event.tags}}
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> {url}
<span class="whitespace-nowrap py-1 text-sm opacity-75"> expandMode="inline"
Posted by <ProfileLink pubkey={event.pubkey} {url} /> minLength={50}
maxLength={180} />
</div>
{/if}
<GoalSummary {url} {event} {progress} />
<div class="flex w-full min-w-0 flex-wrap items-center justify-between gap-2">
<span class="min-w-0 py-1 text-sm text-content-muted">
by <ProfileLink pubkey={event.pubkey} {url} />
{#if h} {#if h}
in <RoomLink {url} {h} /> in <RoomLink {url} {h} />
{/if} {/if}

View file

@ -0,0 +1,41 @@
<script lang="ts">
import cx from "classnames"
import type {GoalProgress} from "@app/goals"
type Props = {
progress: GoalProgress
class?: string
}
const {progress, ...props}: Props = $props()
const width = $derived(Math.min(100, progress.percent))
</script>
<div class={cx("flex flex-col gap-2", props.class)}>
<div class="flex flex-wrap items-baseline justify-between gap-x-2">
<p class="flex flex-wrap items-baseline gap-x-1">
<span class={cx("text-2xl font-bold", progress.isFunded ? "text-success" : "text-primary")}>
{progress.raised.toLocaleString()}
</span>
<span class="text-sm text-content-muted">
of {progress.target.toLocaleString()} sats
</span>
</p>
<p class={cx("text-sm font-bold", progress.isFunded && "text-success")}>
{progress.percent}%
</p>
</div>
<div class="relative h-3 w-full overflow-hidden rounded-full bg-surface-more">
<div
class={cx(
"h-full rounded-full transition-[width] duration-700 ease-out",
progress.isFunded ? "bg-success" : "bg-gradient-to-r from-primary to-secondary",
)}
style:width="{width}%">
</div>
{#each [25, 50, 75] as tick (tick)}
<div class="absolute inset-y-0 w-px bg-surface opacity-40" style:left="{tick}%"></div>
{/each}
</div>
</div>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import cx from "classnames"
import {DAY, formatTimestampRelative, int, now} from "@welshman/lib"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Confetti from "@assets/icons/confetti.svg?dataurl"
import Hourglass from "@assets/icons/hourglass.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import type {GoalProgress} from "@app/goals"
type Props = {
progress: GoalProgress
class?: string
}
const {progress, ...props}: Props = $props()
const endsSoon = $derived(
Boolean(progress.closedAt && !progress.isEnded && progress.closedAt < now() + int(3, DAY)),
)
</script>
{#if progress.isFunded}
<span class={cx("badge badge-success badge-sm", props.class)}>
<Icon icon={Confetti} size={4} />
Funded
</span>
{:else if progress.isEnded}
<span class={cx("badge badge-neutral badge-sm", props.class)}>
<Icon icon={Hourglass} size={4} />
Ended
</span>
{:else if progress.closedAt}
<span class={cx("badge badge-sm", endsSoon ? "badge-warning" : "badge-neutral", props.class)}>
<Icon icon={Hourglass} size={4} />
Ends {formatTimestampRelative(progress.closedAt)}
</span>
{:else}
<span class={cx("badge badge-primary badge-sm", props.class)}>
<Icon icon={Bolt} size={4} />
Fundraising
</span>
{/if}

View file

@ -1,47 +1,54 @@
<script lang="ts"> <script lang="ts">
import {now, DAY, removeUndefined} from "@welshman/lib" import cx from "classnames"
import type {Readable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {fromMsats} from "@welshman/util"
import {ZapGoals} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ZapButton from "@app/components/ZapButton.svelte" import ZapButton from "@app/components/ZapButton.svelte"
import {app} from "@app/core" import GoalMeter from "@app/components/GoalMeter.svelte"
import GoalStatus from "@app/components/GoalStatus.svelte"
import {ENABLE_ZAPS} from "@app/env"
import {deriveGoalProgress} from "@app/goals"
import type {GoalProgress} from "@app/goals"
type Props = { type Props = {
url?: string url?: string
event: TrustedEvent event: TrustedEvent
progress?: Readable<GoalProgress>
class?: string class?: string
} }
const {url, event, ...props}: Props = $props() const {url, event, progress: providedProgress, ...props}: Props = $props()
const progress = $app.use(ZapGoals).progress(event, removeUndefined([url])).$ // The list page already has a store per goal; reuse it instead of resubscribing.
const progress = providedProgress ?? deriveGoalProgress(event, url)
const daysOld = Math.ceil((now() - event.created_at) / DAY) const backers = $derived($progress.backers.length)
</script> </script>
<div class="flex flex-col gap-8 {props.class}"> <div class={cx("flex flex-col gap-3", props.class)}>
<div class="flex gap-8"> <GoalMeter progress={$progress} />
<div> <div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xl text-primary">{fromMsats($progress.amount)} sats</p> <div class="flex min-w-0 items-center gap-2">
<p class="text-sm opacity-75">funded of {fromMsats($progress.target)} sats</p> {#if backers > 0}
</div> <ProfileCircles pubkeys={$progress.backers} size={6} limit={5} />
<div> {/if}
<p class="text-xl">{$progress.contributors.length}</p> <span class="truncate text-sm text-content-muted">
<p class="text-sm opacity-75"> {#if backers === 0}
{$progress.contributors.length === 1 ? "contributor" : "contributors"} No contributions yet
</p> {:else}
</div> {backers}
<div> {backers === 1 ? "backer" : "backers"}
<p class="text-xl">{daysOld}</p> {/if}
<p class="text-sm opacity-75">{daysOld === 1 ? "day" : "days"} old</p> </span>
</div> </div>
<GoalStatus progress={$progress} />
</div> </div>
<progress class="progress" value={fromMsats($progress.amount)} max={fromMsats($progress.target)} {#if ENABLE_ZAPS && !$progress.isEnded}
></progress> <ZapButton {url} {event} class="button button-primary button-block">
<ZapButton {url} {event} class="button button-primary lg:m-auto lg:px-20"> <Icon icon={Bolt} />
<Icon icon={Bolt} /> {$progress.isFunded ? "Chip in anyway" : "Contribute"}
Contribute to this goal </ZapButton>
</ZapButton> {/if}
</div> </div>

View file

@ -0,0 +1,111 @@
<script lang="ts">
import {formatTimestampRelative, groupBy, max, sortBy, sum} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {fromMsats} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Crown from "@assets/icons/crown.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import {ENABLE_ZAPS} from "@app/env"
import type {GoalProgress} from "@app/goals"
type Props = {
url: string
event: TrustedEvent
progress: GoalProgress
}
const {url, event, progress}: Props = $props()
const supporters = $derived(
sortBy(
supporter => -supporter.amount,
Array.from(groupBy(zap => zap.request.pubkey, progress.zaps)).map(([pubkey, zaps]) => ({
pubkey,
amount: fromMsats(sum(zaps.map(zap => zap.invoiceAmount))),
comment: zaps.map(zap => zap.request.content.trim()).find(content => content),
zappedAt: max(zaps.map(zap => zap.response.created_at)),
})),
),
)
const topAmount = $derived(max(supporters.map(supporter => supporter.amount)))
let showAll = $state(false)
const visible = $derived(showAll ? supporters : supporters.slice(0, 5))
const expand = () => {
showAll = true
}
</script>
<div class="flex flex-col gap-4 card w-full">
<div class="flex items-center justify-between gap-2">
<h2 class="text-lg font-bold">Supporters</h2>
{#if supporters.length > 0}
<span class="badge badge-primary badge-sm">
<Icon icon={Bolt} size={4} />
{progress.raised.toLocaleString()} sats from {supporters.length}
</span>
{/if}
</div>
{#if supporters.length > 0}
<div class="flex flex-col gap-3">
{#each visible as supporter, rank (supporter.pubkey)}
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<div class="flex w-6 shrink-0 justify-center">
{#if rank === 0}
<Icon icon={Crown} size={5} class="text-warning" />
{:else}
<span class="text-sm text-content-subtle">{rank + 1}</span>
{/if}
</div>
<ProfileCircle pubkey={supporter.pubkey} {url} size={8} />
<div class="flex min-w-0 grow flex-col">
<span class="truncate font-bold">
<ProfileName pubkey={supporter.pubkey} {url} />
</span>
<span class="text-xs text-content-subtle">
{formatTimestampRelative(supporter.zappedAt)}
</span>
</div>
<span class="shrink-0 whitespace-nowrap font-bold text-primary">
{supporter.amount.toLocaleString()}
</span>
</div>
<div class="ml-9 h-1 overflow-hidden rounded-full bg-surface-more">
<div
class="h-full rounded-full bg-primary opacity-60"
style:width="{topAmount > 0 ? (supporter.amount / topAmount) * 100 : 0}%">
</div>
</div>
{#if supporter.comment}
<p class="ml-9 wrap-break-word text-sm text-content-muted">{supporter.comment}</p>
{/if}
</div>
{/each}
</div>
{#if supporters.length > visible.length}
<Button class="button button-link self-center" onclick={expand}>
Show all {supporters.length} supporters
</Button>
{/if}
{:else}
<div class="flex flex-col items-center gap-3 py-4 text-center">
<p class="text-content-muted">
No one has contributed yet. Send the first zap and get top billing.
</p>
{#if ENABLE_ZAPS && !progress.isEnded}
<ZapButton {url} {event} class="button button-primary">
<Icon icon={Bolt} />
Be the first
</ZapButton>
{/if}
</div>
{/if}
</div>

View file

@ -10,10 +10,14 @@
const goal = reader(ZapGoal)(props.event) const goal = reader(ZapGoal)(props.event)
const title = goal.title() const title = goal.title()
const image = goal.image()
const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags}) const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags})
</script> </script>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if image}
<img src={image} alt="" class="h-40 w-full rounded-2xl object-cover" />
{/if}
<p class="text-2xl">{title}</p> <p class="text-2xl">{title}</p>
<Content {...props} event={summaryEvent} expandMode="inline" minLength={50} maxLength={300} /> <Content {...props} event={summaryEvent} expandMode="inline" minLength={50} maxLength={300} />
<GoalSummary url={props.url} event={props.event} /> <GoalSummary url={props.url} event={props.event} />

View file

@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {removeUndefined} from "@welshman/lib"
import {fromMsats} from "@welshman/util"
import {ZapGoal} from "@welshman/domain" import {ZapGoal} from "@welshman/domain"
import {ZapGoals} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte" import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {app, reader} from "@app/core" import {reader} from "@app/core"
import {deriveGoalProgress} from "@app/goals"
const props: ComponentProps<typeof ContentMinimal> = $props() const props: ComponentProps<typeof ContentMinimal> = $props()
@ -16,14 +14,14 @@
const title = goal.title() const title = goal.title()
const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags}) const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags})
const progress = $app.use(ZapGoals).progress(props.event, removeUndefined([props.url])).$ const progress = deriveGoalProgress(props.event, props.url)
</script> </script>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm">{title}</span> <span class="text-sm">{title}</span>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Icon icon={Bolt} size={4} /> <Icon icon={Bolt} size={4} />
{fromMsats($progress.amount)}/{fromMsats($progress.target)} sats funded {$progress.raised.toLocaleString()}/{$progress.target.toLocaleString()} sats funded
</div> </div>
</div> </div>
<ContentMinimal {...props} event={summaryEvent} /> <ContentMinimal {...props} event={summaryEvent} />

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {derived} from "svelte/store" import {derived, readable} from "svelte/store"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import { import {
displayList, displayList,
@ -38,6 +38,7 @@
createReaction: (event: EventContent) => void createReaction: (event: EventContent) => void
url?: string url?: string
reactionClass?: string reactionClass?: string
hideZaps?: boolean
noTooltip?: boolean noTooltip?: boolean
innerEvent?: TrustedEvent innerEvent?: TrustedEvent
context: FeedContext context: FeedContext
@ -50,6 +51,7 @@
createReaction, createReaction,
url, url,
reactionClass = "", reactionClass = "",
hideZaps = false,
noTooltip = false, noTooltip = false,
innerEvent = undefined, innerEvent = undefined,
context, context,
@ -66,18 +68,22 @@
const reactions = derived(related, $related => filter(spec({kind: REACTION}), $related)) const reactions = derived(related, $related => filter(spec({kind: REACTION}), $related))
const receipts = derived(related, $related => filter(spec({kind: ZAP_RECEIPT}), $related)) const receipts = derived(related, $related => filter(spec({kind: ZAP_RECEIPT}), $related))
// A receipt can be a zap of either the event or the one it wraps // A receipt can be a zap of either the event or the one it wraps; skip validating
const zaps = derived<typeof receipts, Zap[]>( // (each zapper's lnurl) entirely when zaps won't even be shown.
receipts, const zaps = hideZaps
($receipts, set) => ? readable<Zap[]>([])
derived( : derived<typeof receipts, Zap[]>(
removeUndefined([event, innerEvent]).map( receipts,
parent => $app.use(Zappers).validZapReceipts($receipts, parent, removeUndefined([url])).$, ($receipts, set) =>
), derived(
(zapsByParent: Zap[][]) => uniqBy(zap => zap.response.id, zapsByParent.flat()), removeUndefined([event, innerEvent]).map(
).subscribe(set), parent =>
[], $app.use(Zappers).validZapReceipts($receipts, parent, removeUndefined([url])).$,
) ),
(zapsByParent: Zap[][]) => uniqBy(zap => zap.response.id, zapsByParent.flat()),
).subscribe(set),
[],
)
const reactorPubkeys = $derived( const reactorPubkeys = $derived(
uniq([...$reactions.map(e => e.pubkey), ...$zaps.map(zap => zap.request.pubkey)]), uniq([...$reactions.map(e => e.pubkey), ...$zaps.map(zap => zap.request.pubkey)]),
@ -149,7 +155,7 @@
const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps.values())) const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps.values()))
</script> </script>
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children} {#if $reactions.length > 0 || (!hideZaps && $zaps.length > 0) || $reports.length > 0 || children}
<div class="flex min-w-0 flex-wrap gap-2"> <div class="flex min-w-0 flex-wrap gap-2">
{#if url && $reports.length > 0 && canBanEvent} {#if url && $reports.length > 0 && canBanEvent}
<Button <Button
@ -165,29 +171,31 @@
<span>{$reports.length}</span> <span>{$reports.length}</span>
</Button> </Button>
{/if} {/if}
{#each groupedZaps.entries() as [key, zaps] (key)} {#if !hideZaps}
{@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))} {#each groupedZaps.entries() as [key, zaps] (key)}
{@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))} {@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))}
{@const isOwn = pubkeys.includes($user.pubkey)} {@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))}
{@const info = displayList(pubkeys.map(pubkey => $displays.get(pubkey) ?? ""))} {@const isOwn = pubkeys.includes($user.pubkey)}
{@const tooltip = `${info} zapped`} {@const info = displayList(pubkeys.map(pubkey => $displays.get(pubkey) ?? ""))}
{@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)} {@const tooltip = `${info} zapped`}
<Button {@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)}
data-tip={tooltip} <Button
class={cx( data-tip={tooltip}
reactionClass, class={cx(
"button button-xs flex-inline flex items-center gap-1 rounded-full text-xs font-normal", reactionClass,
{ "button button-xs flex-inline flex items-center gap-1 rounded-full text-xs font-normal",
tip: !noTooltip && !isMobile, {
"button-primary": isOwn, tip: !noTooltip && !isMobile,
"button-neutral": !isOwn, "button-primary": isOwn,
}, "button-neutral": !isOwn,
)} },
onclick={stopPropagation(onZapClickHandler)}> )}
<Reaction event={zaps[0].request} /> onclick={stopPropagation(onZapClickHandler)}>
<span>{amount}</span> <Reaction event={zaps[0].request} />
</Button> <span>{amount}</span>
{/each} </Button>
{/each}
{/if}
{#each groupedReactions.entries() as [key, events] (key)} {#each groupedReactions.entries() as [key, events] (key)}
{@const pubkeys = events.map(e => e.pubkey)} {@const pubkeys = events.map(e => e.pubkey)}
{@const isOwn = pubkeys.includes($user.pubkey)} {@const isOwn = pubkeys.includes($user.pubkey)}

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {stopPropagation} from "svelte/legacy" import {stopPropagation} from "svelte/legacy"
import {noop} from "@welshman/lib" import {noop} from "@welshman/lib"
import type {BaseThunk} from "@welshman/app" import type {BaseThunk} from "@welshman/app"
@ -13,10 +14,12 @@
type Props = { type Props = {
thunk: BaseThunk thunk: BaseThunk
showToastOnRetry?: boolean showToastOnRetry?: boolean
// See ThunkPending's `inline` — same trade-off, same default.
inline?: boolean
class?: string class?: string
} }
const {thunk, showToastOnRetry, ...restProps}: Props = $props() const {thunk, showToastOnRetry, inline = false, ...restProps}: Props = $props()
const showFailure = $derived($thunk.isComplete() && $thunk.getFailedUrls().length > 0) const showFailure = $derived($thunk.isComplete() && $thunk.getFailedUrls().length > 0)
@ -47,7 +50,7 @@
{#if showFailure} {#if showFailure}
<button <button
class="flex w-full justify-end px-1 text-xs {restProps.class}" class={cx("flex px-1 text-xs", {"w-full justify-end": !inline}, restProps.class)}
onclick={stopPropagation(noop)}> onclick={stopPropagation(noop)}>
<Tippy <Tippy
class="flex items-center" class="flex items-center"

View file

@ -1,21 +1,25 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {stopPropagation} from "svelte/legacy" import {stopPropagation} from "svelte/legacy"
import {PublishStatus} from "@welshman/net" import {PublishStatus} from "@welshman/net"
import type {BaseThunk} from "@welshman/app" import type {BaseThunk} from "@welshman/app"
type Props = { type Props = {
thunk: BaseThunk thunk: BaseThunk
// A standalone usage (a DM bubble) needs to fill and justify its own row; one already
// inside a flex row (an actions row) doesn't, and forcing it costs that row a line.
inline?: boolean
class?: string class?: string
} }
const {thunk, ...restProps}: Props = $props() const {thunk, inline = false, ...restProps}: Props = $props()
const abort = () => thunk.abort() const abort = () => thunk.abort()
const isSending = $derived($thunk.hasStatus(PublishStatus.Sending)) const isSending = $derived($thunk.hasStatus(PublishStatus.Sending))
</script> </script>
<div class="flex w-full justify-end px-1 text-xs {restProps.class}"> <div class={cx("flex px-1 text-xs", {"w-full justify-end": !inline}, restProps.class)}>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<span class="spinner spinner-xs mx-1 translate-y-px"></span> <span class="spinner spinner-xs mx-1 translate-y-px"></span>
<span class="opacity-50">Sending...</span> <span class="opacity-50">Sending...</span>

View file

@ -6,17 +6,18 @@
interface Props { interface Props {
thunk: BaseThunk thunk: BaseThunk
showToastOnRetry?: boolean showToastOnRetry?: boolean
inline?: boolean
class?: string class?: string
} }
const {thunk, showToastOnRetry, ...restProps}: Props = $props() const {thunk, showToastOnRetry, inline = false, ...restProps}: Props = $props()
const showFailure = $derived($thunk.isComplete() && $thunk.getFailedUrls().length > 0) const showFailure = $derived($thunk.isComplete() && $thunk.getFailedUrls().length > 0)
const showPending = $derived(!$thunk.isComplete()) const showPending = $derived(!$thunk.isComplete())
</script> </script>
{#if showFailure} {#if showFailure}
<ThunkFailure class={restProps.class} {thunk} {showToastOnRetry} /> <ThunkFailure class={restProps.class} {thunk} {showToastOnRetry} {inline} />
{:else if showPending} {:else if showPending}
<ThunkPending class={restProps.class} {thunk} /> <ThunkPending class={restProps.class} {thunk} {inline} />
{/if} {/if}

View file

@ -24,7 +24,7 @@
<div class="button button-error button-xs rounded-full">Deleted</div> <div class="button button-error button-xs rounded-full">Deleted</div>
{:else} {:else}
{#if $thunk.thunks.length > 0 && !$thunk.hasStatus(PublishStatus.Success)} {#if $thunk.thunks.length > 0 && !$thunk.hasStatus(PublishStatus.Success)}
<ThunkStatus {thunk} /> <ThunkStatus {thunk} inline />
{:else} {:else}
{@render status?.()} {@render status?.()}
{/if} {/if}

41
src/app/goals.ts Normal file
View file

@ -0,0 +1,41 @@
import {derived} from "svelte/store"
import type {Readable} from "svelte/store"
import {now, removeUndefined} from "@welshman/lib"
import {fromMsats} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {ZapGoal} from "@welshman/domain"
import type {Zap} from "@welshman/domain"
import {ZapGoals} from "@welshman/app"
import {app, reader} from "@app/core"
export type GoalProgress = {
zaps: Zap[]
raised: number
target: number
percent: number
backers: string[]
isFunded: boolean
isEnded: boolean
closedAt?: number
}
// Welshman tallies a goal in millisats; the pages show sats, and compare in millisats so a
// goal isn't funded a rounded sat early.
export const deriveGoalProgress = (event: TrustedEvent, url?: string): Readable<GoalProgress> => {
const closedAt = reader(ZapGoal)(event).closedAt()
const progress = app
.get()
.use(ZapGoals)
.progress(event, removeUndefined([url])).$
return derived(progress, $progress => ({
zaps: $progress.zaps,
raised: fromMsats($progress.amount),
target: fromMsats($progress.target),
percent: $progress.target > 0 ? Math.round(($progress.amount / $progress.target) * 100) : 0,
backers: $progress.contributors,
isFunded: $progress.target > 0 && $progress.amount >= $progress.target,
isEnded: Boolean(closedAt && closedAt < now()),
closedAt,
}))
}

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import {randomId} from "@welshman/lib" import {randomId} from "@welshman/lib"
import {removeAt, insertAt} from "@welshman/lib" import {removeAt, insertAt} from "@welshman/lib"
@ -26,7 +27,8 @@
const addFiles = (files: FileList | File[]) => { const addFiles = (files: FileList | File[]) => {
const newFiles = Array.from(files).filter(file => file.type.startsWith("image/")) const newFiles = Array.from(files).filter(file => file.type.startsWith("image/"))
value = [...value, ...newFiles]
value = multiple ? [...value, ...newFiles] : newFiles.slice(0, 1)
} }
const removeItem = (index: number) => { const removeItem = (index: number) => {
@ -87,44 +89,52 @@
</script> </script>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="grid grid-cols-3 gap-3" role="list"> <div class={cx("grid gap-3", multiple ? "grid-cols-3" : "grid-cols-1")} role="list">
{#each value as item, index (index)} {#each value as item, index (index)}
<div <div
class="relative aspect-square cursor-move rounded-2xl" class={cx(
style:border={draggedIndex === index "border-line bg-surface-more relative overflow-hidden rounded-2xl border border-solid",
? "var(--border-thin) solid var(--primary)" multiple ? "aspect-square cursor-move" : "aspect-video",
: undefined} )}
draggable="true" style:border-color={draggedIndex === index ? "var(--primary)" : undefined}
draggable={multiple}
role="listitem" role="listitem"
aria-label="Draggable image" aria-label={multiple ? "Draggable image" : "Image"}
ondragstart={e => handleDragStart(e, index)} ondragstart={e => handleDragStart(e, index)}
ondragover={e => handleDragOver(e, index)} ondragover={e => handleDragOver(e, index)}
ondragend={handleDragEnd}> ondragend={handleDragEnd}>
<img <img src={getImageUrl(item)} alt="Upload preview" class="h-full w-full object-cover" />
src={getImageUrl(item)}
alt="Upload preview"
class="h-full w-full object-cover rounded-2xl" />
<Button <Button
aria-label="Remove image"
class="button button-neutral button-xs button-circle bg-surface absolute right-1 top-1" class="button button-neutral button-xs button-circle bg-surface absolute right-1 top-1"
onclick={() => removeItem(index)}> onclick={() => removeItem(index)}>
<Icon icon={CloseCircle} size={4} /> <Icon icon={CloseCircle} size={4} />
</Button> </Button>
</div> </div>
{/each} {/each}
<label {#if multiple || value.length === 0}
for={id} <label
class="flex aspect-square cursor-pointer items-center justify-center rounded-2xl border-2 border-dashed text-sm" for={id}
style:border-color={dropActive ? "var(--primary)" : undefined} class={cx(
aria-label="Drag and drop images here or click to select" "border-line flex cursor-pointer items-center justify-center rounded-2xl border-2 border-dashed p-3 text-sm",
ondragenter={stopPropagation(preventDefault(onDragEnter))} multiple ? "aspect-square" : "aspect-video",
ondragover={stopPropagation(preventDefault(onDragOver))} )}
ondragleave={stopPropagation(preventDefault(onDragLeave))} style:border-color={dropActive ? "var(--primary)" : undefined}
ondrop={stopPropagation(preventDefault(onDrop))}> aria-label={multiple
<div class="flex flex-col items-center gap-2 text-center"> ? "Drag and drop images here or click to select"
<Icon icon={GallerySend} size={8} /> : "Drag and drop an image here or click to select"}
<p class="text-content-muted text-sm">Drag and drop images or click to select</p> ondragenter={stopPropagation(preventDefault(onDragEnter))}
</div> ondragover={stopPropagation(preventDefault(onDragOver))}
</label> ondragleave={stopPropagation(preventDefault(onDragLeave))}
ondrop={stopPropagation(preventDefault(onDrop))}>
<div class="flex flex-col items-center gap-2 text-center">
<Icon icon={GallerySend} size={8} />
<p class="text-content-muted text-sm">
Drag and drop {multiple ? "images" : "an image"} or click to select
</p>
</div>
</label>
{/if}
</div> </div>
<input {id} type="file" accept="image/*" {multiple} onchange={onFileChange} class="hidden" /> <input {id} type="file" accept="image/*" {multiple} onchange={onFileChange} class="hidden" />
</div> </div>

View file

@ -6,11 +6,15 @@
children, children,
size = "md", size = "md",
loading = true, loading = true,
reserveSpace = false,
...restProps ...restProps
}: { }: {
children?: Snippet children?: Snippet
size?: "md" | "sm" | "xs" size?: "md" | "sm" | "xs"
loading?: boolean loading?: boolean
// Reserves the spinner's slot even while idle, so toggling `loading` doesn't resize
// (and reflow) the button. Off by default since it costs unused width otherwise.
reserveSpace?: boolean
class?: string class?: string
} = $props() } = $props()
@ -25,6 +29,8 @@
<div class={cx("flex items-center gap-2", restProps.class)}> <div class={cx("flex items-center gap-2", restProps.class)}>
{#if loading} {#if loading}
<span class={spinnerClass}></span> <span class={spinnerClass}></span>
{:else if reserveSpace}
<span class={cx(spinnerClass, "invisible")}></span>
{/if} {/if}
{@render children?.()} {@render children?.()}
</div> </div>

View file

@ -23,6 +23,12 @@
border-color: color-mix(in srgb, var(--primary) 22%, transparent); border-color: color-mix(in srgb, var(--primary) 22%, transparent);
} }
&.badge-success {
background: color-mix(in srgb, var(--success) 14%, transparent);
color: var(--success);
border-color: color-mix(in srgb, var(--success) 25%, transparent);
}
&.badge-warning { &.badge-warning {
background: color-mix(in srgb, var(--warning) 14%, transparent); background: color-mix(in srgb, var(--warning) 14%, transparent);
color: var(--warning); color: var(--warning);

View file

@ -89,7 +89,8 @@
var(--shadow-inset); var(--shadow-inset);
} }
[data-fl-theme="clay"] .card-interactive:active { /* excludes presses on nested buttons/links, which :active otherwise cascades to too */
[data-fl-theme="clay"] .card-interactive:active:not(:has(button:active, a:active)) {
transform: translateY(2px); transform: translateY(2px);
box-shadow: 2px 2px 0 rgba(var(--shadow-color), 0.3); box-shadow: 2px 2px 0 rgba(var(--shadow-color), 0.3);
} }

View file

@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import {onDestroy, onMount} from "svelte" import {onDestroy, onMount} from "svelte"
import {readable} from "svelte/store" import {derived, readable} from "svelte/store"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib" import cx from "classnames"
import {equals, max, now, pluck, sortBy, spec} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {ZAP_GOAL, tagSpec, tagValue} from "@welshman/util" import {ZAP_GOAL, tagSpec, tagValue} from "@welshman/util"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl" import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl" import Add from "@assets/icons/add.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Confetti from "@assets/icons/confetti.svg?dataurl"
import UsersGroupRounded from "@assets/icons/users-group-rounded.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
@ -19,6 +23,8 @@
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {makeCommentFilter} from "@app/content" import {makeCommentFilter} from "@app/content"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
import {deriveGoalProgress} from "@app/goals"
import type {GoalProgress} from "@app/goals"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import type {PageProps} from "./$types" import type {PageProps} from "./$types"
@ -29,6 +35,13 @@
onDestroy(context.cleanup) onDestroy(context.cleanup)
const tabs = [
{value: "all", label: "All"},
{value: "live", label: "Live"},
{value: "funded", label: "Funded"},
{value: "ended", label: "Ended"},
]
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state() let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
const loading = $derived(isFeedLoading($older)) const loading = $derived(isFeedLoading($older))
@ -38,19 +51,96 @@
const createGoal = () => pushModal(GoalCreate, {url}) const createGoal = () => pushModal(GoalCreate, {url})
const items = $derived.by(() => { let tab = $state("all")
const scores = new Map<string, number[]>() let sort = $state("active")
const [goals, comments] = partition(spec({kind: ZAP_GOAL}), $events)
for (const comment of comments) { // Stable references — the array and its stores — unless the goal set itself changes,
const id = tagValue(tagSpec("E"), comment.tags) // since progressById otherwise resubscribes every goal's zap receipts on any update.
let goals: TrustedEvent[] = $state([])
let progressStoresById: Map<string, Readable<GoalProgress>> = $state(new Map())
$effect(() => {
const next = $events.filter(spec({kind: ZAP_GOAL}))
if (!equals(pluck("id", next), pluck("id", goals))) {
goals = next
progressStoresById = new Map(
next.map(event => [
event.id,
progressStoresById.get(event.id) ?? deriveGoalProgress(event, url),
]),
)
}
})
// `derived` over no stores never fires, so an empty board needs a store of its own.
const progressById = $derived.by(() => {
const ids = Array.from(progressStoresById.keys())
const stores = Array.from(progressStoresById.values())
return stores.length > 0
? derived(stores, progresses => new Map(progresses.map((progress, i) => [ids[i]!, progress])))
: readable(new Map<string, GoalProgress>())
})
// A goal is "active" as of its own creation or the newest comment on it.
const activeAt = $derived.by(() => {
const result = new Map<string, number>()
for (const event of $events) {
const id = event.kind === ZAP_GOAL ? event.id : tagValue(tagSpec("E"), event.tags)
if (id) { if (id) {
pushToMapKey(scores, id, comment.created_at) result.set(id, max([result.get(id), event.created_at]))
} }
} }
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), goals) return result
})
const totals = $derived.by(() => {
const backers = new Set<string>()
let raised = 0
let funded = 0
for (const progress of $progressById.values()) {
raised += progress.raised
funded += progress.isFunded ? 1 : 0
for (const backer of progress.backers) {
backers.add(backer)
}
}
return {raised, funded, backers: backers.size}
})
const items = $derived.by(() => {
const matches = goals.filter(event => {
const progress = $progressById.get(event.id)
if (tab === "live") return Boolean(progress && !progress.isEnded && !progress.isFunded)
if (tab === "funded") return Boolean(progress?.isFunded)
if (tab === "ended") return Boolean(progress?.isEnded)
return true
})
if (sort === "new") return sortBy(event => -event.created_at, matches)
if (sort === "progress") {
return sortBy(event => -($progressById.get(event.id)?.percent ?? 0), matches)
}
if (sort === "ending") {
return sortBy(event => {
const closedAt = $progressById.get(event.id)?.closedAt
return closedAt && closedAt > now() ? closedAt : Infinity
}, matches)
}
return sortBy(event => -(activeAt.get(event.id) ?? event.created_at), matches)
}) })
onMount(() => { onMount(() => {
@ -88,21 +178,84 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4"> <PageContent bind:element class="flex flex-col gap-3 p-2 sm:gap-4 sm:p-4">
{#each items as event (event.id)} {#if goals.length > 0}
<div in:fly> <div class="grid grid-cols-3 gap-2 card card-primary card-sm bg-surface w-full text-center">
<GoalItem {url} {context} event={$state.snapshot(event)} /> <div class="flex flex-col items-center gap-1">
<Icon icon={Bolt} class="text-primary" />
<span class="text-xl font-bold">{totals.raised.toLocaleString()}</span>
<span class="text-xs text-content-muted">sats raised</span>
</div>
<div class="flex flex-col items-center gap-1">
<Icon icon={UsersGroupRounded} class="text-primary" />
<span class="text-xl font-bold">{totals.backers}</span>
<span class="text-xs text-content-muted">
{totals.backers === 1 ? "backer" : "backers"}
</span>
</div>
<div class="flex flex-col items-center gap-1">
<Icon icon={Confetti} class="text-primary" />
<span class="text-xl font-bold">{totals.funded}/{goals.length}</span>
<span class="text-xs text-content-muted">funded</span>
</div>
</div> </div>
{/each} <div class="flex flex-wrap items-center justify-between gap-2">
<p class="flex h-10 items-center justify-center py-20"> <div class="flex flex-wrap gap-2">
<Spinner {loading}> {#each tabs as { value, label } (value)}
{#if loading} {@const onClick = () => (tab = value)}
Looking for goals... <Button
{:else if exhausted && items.length === 0} aria-pressed={tab === value}
No goals found. class={cx(
{:else if exhausted} "button button-sm rounded-full",
That's all! tab === value ? "button-primary" : "button-neutral",
{/if} )}
</Spinner> onclick={onClick}>
</p> {label}
</Button>
{/each}
</div>
<select class="select input input-sm w-auto" bind:value={sort}>
<option value="active">Recently active</option>
<option value="new">Newest</option>
<option value="progress">Closest to goal</option>
<option value="ending">Ending soonest</option>
</select>
</div>
{/if}
<div class="columns-1 gap-2 sm:gap-4 lg:columns-2 2xl:columns-3">
{#each items as event (event.id)}
<div in:fly class="mb-2 min-w-0 break-inside-avoid sm:mb-4">
<GoalItem
{url}
{context}
event={$state.snapshot(event)}
progress={progressStoresById.get(event.id)} />
</div>
{/each}
</div>
{#if exhausted && goals.length === 0}
<div class="flex flex-col items-center gap-3 card w-full py-16 text-center">
<Icon icon={StarFallMinimalistic} size={10} class="text-primary" />
<h2 class="text-xl font-bold">No funding goals yet</h2>
<p class="text-content-muted">
Funding goals let this space rally sats behind something worth doing.
</p>
<Button class="button button-primary" onclick={createGoal}>
<Icon icon={Add} />
Start the first one
</Button>
</div>
{:else}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for goals...
{:else if exhausted && items.length === 0}
No goals match this filter.
{:else if exhausted}
That's all!
{/if}
</Spinner>
</p>
{/if}
</PageContent> </PageContent>

View file

@ -1,24 +1,27 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {sleep} from "@welshman/lib" import {DAY, formatTimestampRelative, now, sleep} from "@welshman/lib"
import {getCommentFiltersForRoot} from "@welshman/util"
import {deriveEventsAsc} from "@welshman/store"
import {ZapGoal} from "@welshman/domain" import {ZapGoal} from "@welshman/domain"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import Content from "@app/components/Content.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import GoalActions from "@app/components/GoalActions.svelte" import GoalActions from "@app/components/GoalActions.svelte"
import CommentActions from "@app/components/CommentActions.svelte" import GoalMeter from "@app/components/GoalMeter.svelte"
import EventReply from "@app/components/EventReply.svelte" import GoalStatus from "@app/components/GoalStatus.svelte"
import {network, reader} from "@app/core" import GoalSupporters from "@app/components/GoalSupporters.svelte"
import {deriveEvent, deriveEventsById} from "@app/repository" import EventComments from "@app/components/EventComments.svelte"
import {reader} from "@app/core"
import {ENABLE_ZAPS} from "@app/env"
import {deriveGoalProgress} from "@app/goals"
import {deriveEvent} from "@app/repository"
import {makeFeedContext} from "@app/feeds" import {makeFeedContext} from "@app/feeds"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import type {PageProps} from "./$types" import type {PageProps} from "./$types"
@ -29,83 +32,90 @@
const url = decodeRelay(relay) const url = decodeRelay(relay)
const context = makeFeedContext({relays: [url]}) const context = makeFeedContext({relays: [url]})
const event = deriveEvent(id, [url]) const event = deriveEvent(id, [url])
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const replies = $derived(deriveEventsAsc(deriveEventsById(filters)))
const goal = derived(event, $event => ($event ? reader(ZapGoal)($event) : undefined)) const goal = derived(event, $event => ($event ? reader(ZapGoal)($event) : undefined))
const summary = $derived($goal?.summary() ?? "") const progress = $derived($event ? deriveGoalProgress($event, url) : undefined)
const remaining = $derived($progress ? Math.max(0, $progress.target - $progress.raised) : 0)
const deadline = $derived($progress?.isEnded ? undefined : $progress?.closedAt)
const days = $derived(
deadline
? Math.ceil((deadline - now()) / DAY)
: Math.ceil((now() - ($event?.created_at ?? now())) / DAY),
)
const back = () => history.back() const back = () => history.back()
const openReply = () => {
showReply = true
}
const closeReply = () => {
showReply = false
}
const expand = () => {
showAll = true
}
onDestroy(context.cleanup) onDestroy(context.cleanup)
let showAll = $state(false)
let showReply = $state(false)
$effect(() => {
if (filters.length > 0) {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
return () => controller.abort()
}
})
</script> </script>
<SpaceBar {back}> <SpaceBar {back}>
{#snippet title()} {#snippet title()}
<h1 class="text-xl">{$event?.content}</h1> <h1 class="truncate text-xl">{$goal?.title() ?? "Funding goal"}</h1>
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4"> <PageContent class="flex flex-col gap-3 p-2 sm:gap-4 sm:p-4">
{#if $event} {#if $event && $goal && $progress}
<div class="flex flex-col gap-3"> <NoteCard event={$event} {url} class="card z-feature w-full">
<NoteCard event={$event} {url} class="card z-feature w-full"> <div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 ml-12"> {#if $goal.image()}
<NoteContent showEntire event={{...$event, content: summary}} {url} /> <img src={$goal.image()} alt="" class="h-56 w-full max-w-full rounded-2xl object-cover" />
<GoalActions showRoom event={$event} {url} {context} /> {/if}
</div> <div class="flex flex-col gap-2">
</NoteCard> <h2 class="wrap-break-word text-3xl">{$goal.title()}</h2>
{#if !showAll && $replies.length > 4} <div class="flex flex-wrap items-center gap-2 text-sm text-content-muted">
<div class="flex justify-center"> <GoalStatus progress={$progress} />
<Button class="button button-link" onclick={expand}> {#if $goal.room()}
<Icon icon={SortVertical} /> <span>in <RoomLink {url} h={$goal.room()!} /></span>
Show all {$replies.length} replies {/if}
</Button> <span>started {formatTimestampRelative($event.created_at)}</span>
</div>
{/if}
{#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)}
<NoteCard event={reply} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={reply} {url} />
<CommentActions event={reply} {url} {context} />
</div> </div>
</NoteCard> </div>
{/each} <GoalMeter progress={$progress} />
</div> <div class="grid grid-cols-3 gap-2 text-center">
{#if showReply} <div class="flex flex-col items-center gap-1 rounded-2xl bg-surface-more p-3">
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} /> <span class="text-xl font-bold">{$progress.backers.length}</span>
{:else} {#if $progress.backers.length > 0}
<div class="flex justify-end"> <ProfileCircles pubkeys={$progress.backers} size={5} limit={5} />
<Button class="button button-primary" onclick={openReply}> {/if}
<Icon icon={Reply} /> <span class="text-xs text-content-muted">
Comment on this goal {$progress.backers.length === 1 ? "backer" : "backers"}
</Button> </span>
</div>
<div class="flex flex-col items-center gap-1 rounded-2xl bg-surface-more p-3">
<span class="text-xl font-bold">{remaining.toLocaleString()}</span>
<span class="text-xs text-content-muted">sats to go</span>
</div>
<div class="flex flex-col items-center gap-1 rounded-2xl bg-surface-more p-3">
<span class="text-xl font-bold">{$progress.isEnded ? "—" : days}</span>
<span class="text-xs text-content-muted">
{#if $progress.isEnded && $progress.closedAt}
ended {formatTimestampRelative($progress.closedAt)}
{:else if deadline}
{days === 1 ? "day" : "days"} left
{:else}
{days === 1 ? "day" : "days"} running
{/if}
</span>
</div>
</div>
{#if ENABLE_ZAPS && !$progress.isEnded}
<ZapButton {url} event={$event} class="button button-primary button-block lg:px-20">
<Icon icon={Bolt} />
{$progress.isFunded ? "Chip in anyway" : "Contribute to this goal"}
</ZapButton>
{/if}
<GoalActions event={$event} {url} {context} />
</div>
</NoteCard>
{#if $goal.summary()}
<div class="flex flex-col gap-3 card w-full">
<h2 class="text-lg font-bold">About this goal</h2>
<Content showEntire event={{content: $goal.summary(), tags: $event.tags}} {url} />
</div> </div>
{/if} {/if}
<GoalSupporters {url} event={$event} progress={$progress} />
<EventComments event={$event} {url} {context} />
{:else} {:else}
<div class="flex justify-center py-20"> <div class="flex justify-center py-20">
{#await sleep(5000)} {#await sleep(5000)}