Redesign the goal list and detail pages (#582)
This commit is contained in:
parent
590803f0a4
commit
fa489d4b72
24 changed files with 768 additions and 241 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}) => {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
41
src/app/components/GoalMeter.svelte
Normal file
41
src/app/components/GoalMeter.svelte
Normal 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>
|
||||||
42
src/app/components/GoalStatus.svelte
Normal file
42
src/app/components/GoalStatus.svelte
Normal 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}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
111
src/app/components/GoalSupporters.svelte
Normal file
111
src/app/components/GoalSupporters.svelte
Normal 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>
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
41
src/app/goals.ts
Normal 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue