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:
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
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">
|
||||
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>
|
||||
|
|
|
|||
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 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} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
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">
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue