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:
- 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
progress bar.
- A goal with contributions shows a contributor count and how long it has been
running.
progress meter, with what is left to raise and how long it has left or has
been 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

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 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 sats", {exact: true})).toBeVisible()
await expect(goalCard.locator("progress")).toHaveJSProperty("value", 0)
await expect(goalCard.locator("progress")).toHaveJSProperty("max", 6000)
await expect(goalCard.getByText("0%", {exact: true})).toBeVisible()
await expect(goalCard.getByText("sats to go", {exact: true}).locator("xpath=..")).toContainText(
/6,?000/,
)
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(
"0",
await expect(paCard.getByText("backers", {exact: true}).locator("xpath=..")).toContainText("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()`
// 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}`)
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.locator("progress")).toHaveJSProperty("value", 1500)
await expect(soundCard.getByText(/^1,?500$/)).toBeVisible()
await expect(soundCard.getByText("8%", {exact: true})).toBeVisible()
await expect(soundCard.getByText("backers", {exact: true}).locator("xpath=..")).toContainText("2")
await expect(
soundCard.getByText("contributors", {exact: true}).locator("xpath=.."),
soundCard.getByText("days running", {exact: true}).locator("xpath=.."),
).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}) => {

View file

@ -133,7 +133,7 @@
<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}>
<Spinner {loading} reserveSpace>
{#if parent}
Reply
{:else}

View file

@ -31,7 +31,7 @@
const createReaction = (values: EventContent) => publishReaction(event, values, {url})
</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}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
@ -39,6 +39,7 @@
{/if}
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
hideZaps
{url}
{event}
{context}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import {writable} from "svelte/store"
import {now} from "@welshman/lib"
import {relay, toMsats} from "@welshman/util"
import {ZapGoal} from "@welshman/domain"
import {publish} from "@welshman/app"
@ -12,6 +13,8 @@
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.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 ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@ -24,11 +27,14 @@
import {publishRoomQuote} from "@app/rooms"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts"
import {compressFileForUpload, uploadFile} from "@app/uploads"
type Values = {
title: string
content: string | object
amount: number
images: (string | File)[]
closedAt?: number
}
type Props = {
@ -76,9 +82,30 @@
})
}
if (closedAt && closedAt < now()) {
return pushToast({
theme: "error",
message: "Please choose a deadline in the future.",
})
}
loading = true
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 eventWriter = writer(ZapGoal)
.setTitle(title)
@ -89,6 +116,14 @@
.addTags(...ed.storage.nostr.getEditorTags())
.forceRoutes(relay(url))
if (imageUrl) {
eventWriter.setImage(imageUrl)
}
if (closedAt) {
eventWriter.setClosedAt(closedAt)
}
if (h) {
eventWriter.setRoom(url, h)
}
@ -116,6 +151,8 @@
let title = $state(initialValues?.title ?? "")
let amount = $state(initialValues?.amount ?? 1000)
let content = $state(initialValues?.content ?? "")
let images = $state(initialValues?.images ?? [])
let closedAt: number | undefined = $state(initialValues?.closedAt)
const onChange = (json: object) => {
content = json
@ -131,7 +168,7 @@
})
$effect(() => {
draftKey.update({title, content, amount})
draftKey.update({title, content, amount, images, closedAt})
})
</script>
@ -204,6 +241,25 @@
step="1000"
bind:value={amount} />
</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>
</ModalBody>
<ModalFooter>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import type {Readable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util"
import {ZapGoal} from "@welshman/domain"
import Link from "@lib/components/Link.svelte"
@ -8,6 +9,7 @@
import ProfileLink from "@app/components/ProfileLink.svelte"
import GoalActions from "@app/components/GoalActions.svelte"
import type {FeedContext} from "@app/feeds"
import type {GoalProgress} from "@app/goals"
import GoalSummary from "@app/components/GoalSummary.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
@ -17,31 +19,40 @@
url: string
event: TrustedEvent
context: FeedContext
progress?: Readable<GoalProgress>
}
const {url, event, context}: Props = $props()
const {url, event, context, progress}: Props = $props()
const goal = reader(ZapGoal)(event)
const path = makeGoalPath(url, event.id)
const title = goal.title()
const summary = goal.summary()
const image = goal.image()
const h = goal.room()
</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" />
<p class="text-2xl">{title}</p>
<Content
event={{content: summary, tags: event.tags}}
{url}
expandMode="inline"
minLength={50}
maxLength={300} />
<GoalSummary {url} {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
{#if image}
<img src={image} alt="" class="h-40 w-full max-w-full rounded-2xl object-cover" />
{/if}
<p class="line-clamp-2 wrap-break-word text-xl font-bold">{title}</p>
{#if summary}
<div class="text-sm text-content-muted">
<Content
event={{content: summary, tags: event.tags}}
{url}
expandMode="inline"
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}
in <RoomLink {url} {h} />
{/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">
import {now, DAY, removeUndefined} from "@welshman/lib"
import cx from "classnames"
import type {Readable} from "svelte/store"
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 Icon from "@lib/components/Icon.svelte"
import ProfileCircles from "@app/components/ProfileCircles.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 = {
url?: string
event: TrustedEvent
progress?: Readable<GoalProgress>
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>
<div class="flex flex-col gap-8 {props.class}">
<div class="flex gap-8">
<div>
<p class="text-xl text-primary">{fromMsats($progress.amount)} sats</p>
<p class="text-sm opacity-75">funded of {fromMsats($progress.target)} sats</p>
</div>
<div>
<p class="text-xl">{$progress.contributors.length}</p>
<p class="text-sm opacity-75">
{$progress.contributors.length === 1 ? "contributor" : "contributors"}
</p>
</div>
<div>
<p class="text-xl">{daysOld}</p>
<p class="text-sm opacity-75">{daysOld === 1 ? "day" : "days"} old</p>
<div class={cx("flex flex-col gap-3", props.class)}>
<GoalMeter progress={$progress} />
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
{#if backers > 0}
<ProfileCircles pubkeys={$progress.backers} size={6} limit={5} />
{/if}
<span class="truncate text-sm text-content-muted">
{#if backers === 0}
No contributions yet
{:else}
{backers}
{backers === 1 ? "backer" : "backers"}
{/if}
</span>
</div>
<GoalStatus progress={$progress} />
</div>
<progress class="progress" value={fromMsats($progress.amount)} max={fromMsats($progress.target)}
></progress>
<ZapButton {url} {event} class="button button-primary lg:m-auto lg:px-20">
<Icon icon={Bolt} />
Contribute to this goal
</ZapButton>
{#if ENABLE_ZAPS && !$progress.isEnded}
<ZapButton {url} {event} class="button button-primary button-block">
<Icon icon={Bolt} />
{$progress.isFunded ? "Chip in anyway" : "Contribute"}
</ZapButton>
{/if}
</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 title = goal.title()
const image = goal.image()
const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags})
</script>
<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>
<Content {...props} event={summaryEvent} expandMode="inline" minLength={50} maxLength={300} />
<GoalSummary url={props.url} event={props.event} />

View file

@ -1,13 +1,11 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {removeUndefined} from "@welshman/lib"
import {fromMsats} from "@welshman/util"
import {ZapGoal} from "@welshman/domain"
import {ZapGoals} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.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()
@ -16,14 +14,14 @@
const title = goal.title()
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>
<div class="flex justify-between">
<span class="text-sm">{title}</span>
<div class="flex items-center gap-1">
<Icon icon={Bolt} size={4} />
{fromMsats($progress.amount)}/{fromMsats($progress.target)} sats funded
{$progress.raised.toLocaleString()}/{$progress.target.toLocaleString()} sats funded
</div>
</div>
<ContentMinimal {...props} event={summaryEvent} />

View file

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

View file

@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {stopPropagation} from "svelte/legacy"
import {noop} from "@welshman/lib"
import type {BaseThunk} from "@welshman/app"
@ -13,10 +14,12 @@
type Props = {
thunk: BaseThunk
showToastOnRetry?: boolean
// See ThunkPending's `inline` — same trade-off, same default.
inline?: boolean
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)
@ -47,7 +50,7 @@
{#if showFailure}
<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)}>
<Tippy
class="flex items-center"

View file

@ -1,21 +1,25 @@
<script lang="ts">
import cx from "classnames"
import {stopPropagation} from "svelte/legacy"
import {PublishStatus} from "@welshman/net"
import type {BaseThunk} from "@welshman/app"
type Props = {
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
}
const {thunk, ...restProps}: Props = $props()
const {thunk, inline = false, ...restProps}: Props = $props()
const abort = () => thunk.abort()
const isSending = $derived($thunk.hasStatus(PublishStatus.Sending))
</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="spinner spinner-xs mx-1 translate-y-px"></span>
<span class="opacity-50">Sending...</span>

View file

@ -6,17 +6,18 @@
interface Props {
thunk: BaseThunk
showToastOnRetry?: boolean
inline?: boolean
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 showPending = $derived(!$thunk.isComplete())
</script>
{#if showFailure}
<ThunkFailure class={restProps.class} {thunk} {showToastOnRetry} />
<ThunkFailure class={restProps.class} {thunk} {showToastOnRetry} {inline} />
{:else if showPending}
<ThunkPending class={restProps.class} {thunk} />
<ThunkPending class={restProps.class} {thunk} {inline} />
{/if}

View file

@ -24,7 +24,7 @@
<div class="button button-error button-xs rounded-full">Deleted</div>
{:else}
{#if $thunk.thunks.length > 0 && !$thunk.hasStatus(PublishStatus.Success)}
<ThunkStatus {thunk} />
<ThunkStatus {thunk} inline />
{:else}
{@render status?.()}
{/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">
import cx from "classnames"
import type {Maybe} from "@welshman/lib"
import {randomId} from "@welshman/lib"
import {removeAt, insertAt} from "@welshman/lib"
@ -26,7 +27,8 @@
const addFiles = (files: FileList | File[]) => {
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) => {
@ -87,44 +89,52 @@
</script>
<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)}
<div
class="relative aspect-square cursor-move rounded-2xl"
style:border={draggedIndex === index
? "var(--border-thin) solid var(--primary)"
: undefined}
draggable="true"
class={cx(
"border-line bg-surface-more relative overflow-hidden rounded-2xl border border-solid",
multiple ? "aspect-square cursor-move" : "aspect-video",
)}
style:border-color={draggedIndex === index ? "var(--primary)" : undefined}
draggable={multiple}
role="listitem"
aria-label="Draggable image"
aria-label={multiple ? "Draggable image" : "Image"}
ondragstart={e => handleDragStart(e, index)}
ondragover={e => handleDragOver(e, index)}
ondragend={handleDragEnd}>
<img
src={getImageUrl(item)}
alt="Upload preview"
class="h-full w-full object-cover rounded-2xl" />
<img src={getImageUrl(item)} alt="Upload preview" class="h-full w-full object-cover" />
<Button
aria-label="Remove image"
class="button button-neutral button-xs button-circle bg-surface absolute right-1 top-1"
onclick={() => removeItem(index)}>
<Icon icon={CloseCircle} size={4} />
</Button>
</div>
{/each}
<label
for={id}
class="flex aspect-square cursor-pointer items-center justify-center rounded-2xl border-2 border-dashed text-sm"
style:border-color={dropActive ? "var(--primary)" : undefined}
aria-label="Drag and drop images here or click to select"
ondragenter={stopPropagation(preventDefault(onDragEnter))}
ondragover={stopPropagation(preventDefault(onDragOver))}
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 images or click to select</p>
</div>
</label>
{#if multiple || value.length === 0}
<label
for={id}
class={cx(
"border-line flex cursor-pointer items-center justify-center rounded-2xl border-2 border-dashed p-3 text-sm",
multiple ? "aspect-square" : "aspect-video",
)}
style:border-color={dropActive ? "var(--primary)" : undefined}
aria-label={multiple
? "Drag and drop images here or click to select"
: "Drag and drop an image here or click to select"}
ondragenter={stopPropagation(preventDefault(onDragEnter))}
ondragover={stopPropagation(preventDefault(onDragOver))}
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>
<input {id} type="file" accept="image/*" {multiple} onchange={onFileChange} class="hidden" />
</div>

View file

@ -6,11 +6,15 @@
children,
size = "md",
loading = true,
reserveSpace = false,
...restProps
}: {
children?: Snippet
size?: "md" | "sm" | "xs"
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
} = $props()
@ -25,6 +29,8 @@
<div class={cx("flex items-center gap-2", restProps.class)}>
{#if loading}
<span class={spinnerClass}></span>
{:else if reserveSpace}
<span class={cx(spinnerClass, "invisible")}></span>
{/if}
{@render children?.()}
</div>

View file

@ -23,6 +23,12 @@
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 {
background: color-mix(in srgb, var(--warning) 14%, transparent);
color: var(--warning);

View file

@ -89,7 +89,8 @@
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);
box-shadow: 2px 2px 0 rgba(var(--shadow-color), 0.3);
}

View file

@ -1,14 +1,18 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte"
import {readable} from "svelte/store"
import {derived, 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 {TrustedEvent} from "@welshman/util"
import {ZAP_GOAL, tagSpec, tagValue} from "@welshman/util"
import {fly} from "@lib/transition"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.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 Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
@ -19,6 +23,8 @@
import {decodeRelay} from "@app/relays"
import {makeCommentFilter} from "@app/content"
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 type {PageProps} from "./$types"
@ -29,6 +35,13 @@
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()
const loading = $derived(isFeedLoading($older))
@ -38,19 +51,96 @@
const createGoal = () => pushModal(GoalCreate, {url})
const items = $derived.by(() => {
const scores = new Map<string, number[]>()
const [goals, comments] = partition(spec({kind: ZAP_GOAL}), $events)
let tab = $state("all")
let sort = $state("active")
for (const comment of comments) {
const id = tagValue(tagSpec("E"), comment.tags)
// Stable references — the array and its stores — unless the goal set itself changes,
// 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) {
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(() => {
@ -88,21 +178,84 @@
{/snippet}
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#each items as event (event.id)}
<div in:fly>
<GoalItem {url} {context} event={$state.snapshot(event)} />
<PageContent bind:element class="flex flex-col gap-3 p-2 sm:gap-4 sm:p-4">
{#if goals.length > 0}
<div class="grid grid-cols-3 gap-2 card card-primary card-sm bg-surface w-full text-center">
<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>
{/each}
<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 found.
{:else if exhausted}
That's all!
{/if}
</Spinner>
</p>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap gap-2">
{#each tabs as { value, label } (value)}
{@const onClick = () => (tab = value)}
<Button
aria-pressed={tab === value}
class={cx(
"button button-sm rounded-full",
tab === value ? "button-primary" : "button-neutral",
)}
onclick={onClick}>
{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>

View file

@ -1,24 +1,27 @@
<script lang="ts">
import {onDestroy} from "svelte"
import {derived} from "svelte/store"
import {sleep} from "@welshman/lib"
import {getCommentFiltersForRoot} from "@welshman/util"
import {deriveEventsAsc} from "@welshman/store"
import {DAY, formatTimestampRelative, now, sleep} from "@welshman/lib"
import {ZapGoal} from "@welshman/domain"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.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 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 CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
import {network, reader} from "@app/core"
import {deriveEvent, deriveEventsById} from "@app/repository"
import GoalMeter from "@app/components/GoalMeter.svelte"
import GoalStatus from "@app/components/GoalStatus.svelte"
import GoalSupporters from "@app/components/GoalSupporters.svelte"
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 {decodeRelay} from "@app/relays"
import type {PageProps} from "./$types"
@ -29,83 +32,90 @@
const url = decodeRelay(relay)
const context = makeFeedContext({relays: [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 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 openReply = () => {
showReply = true
}
const closeReply = () => {
showReply = false
}
const expand = () => {
showAll = true
}
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>
<SpaceBar {back}>
{#snippet title()}
<h1 class="text-xl">{$event?.content}</h1>
<h1 class="truncate text-xl">{$goal?.title() ?? "Funding goal"}</h1>
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#if $event}
<div class="flex flex-col gap-3">
<NoteCard event={$event} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={{...$event, content: summary}} {url} />
<GoalActions showRoom event={$event} {url} {context} />
</div>
</NoteCard>
{#if !showAll && $replies.length > 4}
<div class="flex justify-center">
<Button class="button button-link" onclick={expand}>
<Icon icon={SortVertical} />
Show all {$replies.length} replies
</Button>
</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} />
<PageContent class="flex flex-col gap-3 p-2 sm:gap-4 sm:p-4">
{#if $event && $goal && $progress}
<NoteCard event={$event} {url} class="card z-feature w-full">
<div class="flex flex-col gap-4">
{#if $goal.image()}
<img src={$goal.image()} alt="" class="h-56 w-full max-w-full rounded-2xl object-cover" />
{/if}
<div class="flex flex-col gap-2">
<h2 class="wrap-break-word text-3xl">{$goal.title()}</h2>
<div class="flex flex-wrap items-center gap-2 text-sm text-content-muted">
<GoalStatus progress={$progress} />
{#if $goal.room()}
<span>in <RoomLink {url} h={$goal.room()!} /></span>
{/if}
<span>started {formatTimestampRelative($event.created_at)}</span>
</div>
</NoteCard>
{/each}
</div>
{#if showReply}
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
{:else}
<div class="flex justify-end">
<Button class="button button-primary" onclick={openReply}>
<Icon icon={Reply} />
Comment on this goal
</Button>
</div>
<GoalMeter progress={$progress} />
<div class="grid grid-cols-3 gap-2 text-center">
<div class="flex flex-col items-center gap-1 rounded-2xl bg-surface-more p-3">
<span class="text-xl font-bold">{$progress.backers.length}</span>
{#if $progress.backers.length > 0}
<ProfileCircles pubkeys={$progress.backers} size={5} limit={5} />
{/if}
<span class="text-xs text-content-muted">
{$progress.backers.length === 1 ? "backer" : "backers"}
</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>
{/if}
<GoalSupporters {url} event={$event} progress={$progress} />
<EventComments event={$event} {url} {context} />
{:else}
<div class="flex justify-center py-20">
{#await sleep(5000)}