mirror of
https://github.com/coracle-social/flotilla.git
synced 2025-12-10 10:57:04 +00:00
Add funding goals
This commit is contained in:
@@ -1,28 +1,10 @@
|
||||
<script lang="ts">
|
||||
import {deriveZapperForPubkey} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Zap from "@app/components/Zap.svelte"
|
||||
import InfoZapperError from "@app/components/InfoZapperError.svelte"
|
||||
import WalletConnect from "@app/components/WalletConnect.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {wallet} from "@app/state"
|
||||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
|
||||
const {url, event} = $props()
|
||||
|
||||
const zapper = deriveZapperForPubkey(event.pubkey)
|
||||
|
||||
const onClick = () => {
|
||||
if (!$zapper?.allowsNostr) {
|
||||
pushModal(InfoZapperError, {url, pubkey: event.pubkey, eventId: event.id})
|
||||
} else if ($wallet) {
|
||||
pushModal(Zap, {url, pubkey: event.pubkey, eventId: event.id})
|
||||
} else {
|
||||
pushModal(WalletConnect)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button onclick={onClick} class="btn join-item btn-xs">
|
||||
<ZapButton {url} {event} class="btn join-item btn-xs">
|
||||
<Icon icon="bolt" size={4} />
|
||||
</Button>
|
||||
</ZapButton>
|
||||
|
||||
35
src/app/components/GoalActions.svelte
Normal file
35
src/app/components/GoalActions.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import {publishDelete, publishReaction} from "@app/commands"
|
||||
import {makeGoalPath} from "@app/routes"
|
||||
|
||||
interface Props {
|
||||
url: any
|
||||
event: any
|
||||
showActivity?: boolean
|
||||
}
|
||||
|
||||
const {url, event, showActivity = false}: Props = $props()
|
||||
|
||||
const path = makeGoalPath(url, event.id)
|
||||
|
||||
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
|
||||
|
||||
const createReaction = (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url]})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Goal" />
|
||||
</div>
|
||||
</div>
|
||||
146
src/app/components/GoalCreate.svelte
Normal file
146
src/app/components/GoalCreate.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import {writable} from "svelte/store"
|
||||
import {makeEvent, ZAP_GOAL} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {PROTECTED} from "@app/state"
|
||||
import {makeEditor} from "@app/editor"
|
||||
|
||||
const {url} = $props()
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
|
||||
if (!content) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Please provide a title for your funding goal.",
|
||||
})
|
||||
}
|
||||
|
||||
const ed = await editor
|
||||
const summary = ed.getText({blockSeparator: "\n"}).trim()
|
||||
|
||||
if (!summary.trim()) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Please provide details about your funding goal.",
|
||||
})
|
||||
}
|
||||
|
||||
const tags = [
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
["summary", summary],
|
||||
["amount", String(amount)],
|
||||
["relays", url],
|
||||
PROTECTED,
|
||||
]
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(ZAP_GOAL, {content, tags}),
|
||||
})
|
||||
|
||||
history.back()
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
|
||||
|
||||
let content = $state("")
|
||||
let amount = $state(1000)
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Create a Funding Goal</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Request contributions for your fundraiser.</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<div class="col-8 relative">
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Title*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus={!isMobile}
|
||||
bind:value={content}
|
||||
class="grow"
|
||||
type="text"
|
||||
placeholder="What do funds go towards?" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<div class="relative">
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Details*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="note-editor flex-grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="tooltip tooltip-left absolute bottom-1 right-2"
|
||||
onclick={selectFiles}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Icon icon="paperclip" size={3} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
Goal Amount (sats)*
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex flex-grow justify-end">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<Icon icon="bolt" />
|
||||
<input bind:value={amount} type="number" class="w-28" />
|
||||
<p class="opacity-50">sats</p>
|
||||
</label>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<input
|
||||
class="range range-primary -mt-2"
|
||||
type="range"
|
||||
min="1000"
|
||||
max="100000"
|
||||
step="1000"
|
||||
bind:value={amount} />
|
||||
</div>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon="alt-arrow-left" />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary">Create Goal</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
36
src/app/components/GoalItem.svelte
Normal file
36
src/app/components/GoalItem.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import GoalActions from "@app/components/GoalActions.svelte"
|
||||
import GoalSummary from "@app/components/GoalSummary.svelte"
|
||||
import {makeGoalPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const summary = getTagValue("summary", event.tags)
|
||||
</script>
|
||||
|
||||
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
|
||||
<p class="text-2xl">{event.content}</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} />
|
||||
</span>
|
||||
<GoalActions showActivity {url} {event} />
|
||||
</div>
|
||||
</Link>
|
||||
49
src/app/components/GoalSummary.svelte
Normal file
49
src/app/components/GoalSummary.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import {now, DAY, uniq, sum} from "@welshman/lib"
|
||||
import type {Zap, TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
|
||||
import {deriveEventsMapped} from "@welshman/store"
|
||||
import {repository, getValidZap} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const zaps = deriveEventsMapped<Zap>(repository, {
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||
itemToEvent: item => item.response,
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||
})
|
||||
|
||||
const goalAmount = parseInt(getTagValue("amount", event.tags) || "0")
|
||||
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
|
||||
const contributorsCount = $derived(uniq($zaps.map(zap => zap.request.pubkey)).length)
|
||||
const daysOld = Math.ceil((now() - event.created_at) / DAY)
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt flex flex-col gap-8">
|
||||
<div class="flex gap-8">
|
||||
<div>
|
||||
<p class="text-xl text-primary">{zapAmount} sats</p>
|
||||
<p class="text-sm opacity-75">funded of {goalAmount} sats</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xl">{contributorsCount}</p>
|
||||
<p class="text-sm opacity-75">{contributorsCount === 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>
|
||||
</div>
|
||||
<progress class="progress progress-primary" value={zapAmount} max={goalAmount}></progress>
|
||||
<ZapButton {url} {event} class="btn btn-primary lg:m-auto lg:px-20">
|
||||
<Icon icon="bolt" />
|
||||
Contribute to this goal
|
||||
</ZapButton>
|
||||
</div>
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
const relay = deriveRelay(url)
|
||||
const chatPath = makeSpacePath(url, "chat")
|
||||
const goalsPath = makeSpacePath(url, "goals")
|
||||
const threadsPath = makeSpacePath(url, "threads")
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
const userRooms = deriveUserRooms(url)
|
||||
@@ -130,6 +131,12 @@
|
||||
<SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
|
||||
<Icon icon="home-smile" /> Home
|
||||
</SecondaryNavItem>
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={goalsPath}
|
||||
notification={$notifications.has(goalsPath)}>
|
||||
<Icon icon="star-fall-minimalistic-2" /> Goals
|
||||
</SecondaryNavItem>
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={threadsPath}
|
||||
|
||||
27
src/app/components/ZapButton.svelte
Normal file
27
src/app/components/ZapButton.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import {deriveZapperForPubkey} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Zap from "@app/components/Zap.svelte"
|
||||
import InfoZapperError from "@app/components/InfoZapperError.svelte"
|
||||
import WalletConnect from "@app/components/WalletConnect.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {wallet} from "@app/state"
|
||||
|
||||
const {url, event, children, ...props} = $props()
|
||||
|
||||
const zapper = deriveZapperForPubkey(event.pubkey)
|
||||
|
||||
const onClick = () => {
|
||||
if (!$zapper?.allowsNostr) {
|
||||
pushModal(InfoZapperError, {url, pubkey: event.pubkey, eventId: event.id})
|
||||
} else if ($wallet) {
|
||||
pushModal(Zap, {url, pubkey: event.pubkey, eventId: event.id})
|
||||
} else {
|
||||
pushModal(WalletConnect)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button onclick={onClick} {...props}>
|
||||
{@render children?.()}
|
||||
</Button>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DIRECT_MESSAGE_FILE,
|
||||
MESSAGE,
|
||||
THREAD,
|
||||
ZAP_GOAL,
|
||||
EVENT_TIME,
|
||||
} from "@welshman/util"
|
||||
import {makeChatId, entityLink, decodeRelay, encodeRelay, userRoomsByUrl, ROOM} from "@app/state"
|
||||
@@ -37,6 +38,8 @@ export const makeRoomPath = (url: string, room: string) => `/spaces/${encodeRela
|
||||
|
||||
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
|
||||
|
||||
export const makeGoalPath = (url: string, eventId?: string) => makeSpacePath(url, "goals", eventId)
|
||||
|
||||
export const makeThreadPath = (url: string, eventId?: string) =>
|
||||
makeSpacePath(url, "threads", eventId)
|
||||
|
||||
@@ -87,6 +90,10 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
if (urls.length > 0) {
|
||||
const url = urls[0]
|
||||
|
||||
if (event.kind === ZAP_GOAL) {
|
||||
return makeGoalPath(url, event.id)
|
||||
}
|
||||
|
||||
if (event.kind === THREAD) {
|
||||
return makeThreadPath(url, event.id)
|
||||
}
|
||||
@@ -103,6 +110,10 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
const id = event.tags.find(nthEq(0, "E"))?.[1]
|
||||
|
||||
if (id && kind) {
|
||||
if (parseInt(kind) === ZAP_GOAL) {
|
||||
return makeGoalPath(url, id)
|
||||
}
|
||||
|
||||
if (parseInt(kind) === THREAD) {
|
||||
return makeThreadPath(url, id)
|
||||
}
|
||||
|
||||
6
src/assets/icons/Star Fall Minimalistic 2.svg
Normal file
6
src/assets/icons/Star Fall Minimalistic 2.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.8114 6.7267C12.8247 4.9089 13.3314 4 14.0889 4C14.8464 4 15.353 4.9089 16.3663 6.7267L16.6285 7.19699C16.9164 7.71355 17.0604 7.97183 17.2849 8.14225C17.5094 8.31266 17.789 8.37592 18.3482 8.50244L18.8572 8.61762C20.825 9.06284 21.8089 9.28545 22.0429 10.0382C22.277 10.7909 21.6063 11.5753 20.2648 13.1439L19.9177 13.5498C19.5365 13.9955 19.3459 14.2184 19.2602 14.4942C19.1744 14.7699 19.2032 15.0673 19.2609 15.662L19.3134 16.2035C19.5162 18.2965 19.6176 19.343 19.0047 19.8082C18.3919 20.2734 17.4707 19.8492 15.6283 19.0009L15.1517 18.7815C14.6281 18.5404 14.3664 18.4199 14.0889 18.4199C13.8114 18.4199 13.5496 18.5404 13.0261 18.7815L12.5494 19.0009C10.707 19.8492 9.78581 20.2734 9.17299 19.8082C8.56016 19.343 8.66157 18.2965 8.86438 16.2035L8.91685 15.662C8.97449 15.0673 9.0033 14.7699 8.91756 14.4942C8.83181 14.2184 8.64121 13.9955 8.26 13.5498L7.91295 13.1439C6.57147 11.5753 5.90073 10.7909 6.1348 10.0382C6.36888 9.28545 7.35275 9.06284 9.3205 8.61762L9.82958 8.50244C10.3887 8.37592 10.6683 8.31266 10.8928 8.14225C11.1173 7.97183 11.2613 7.71355 11.5492 7.19699L11.8114 6.7267Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<path d="M2.08887 16C3.20445 15.121 4.68639 14.7971 6.08887 15.1257" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M2.08887 10.5C3.08887 10 3.37862 10.0605 4.08887 10" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M2 5.60867L2.20816 5.48676C4.41383 4.19506 6.75032 3.84687 8.95304 4.48161L9.16092 4.54152" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -86,6 +86,7 @@
|
||||
import SquareShareLine from "@assets/icons/Square Share Line.svg?dataurl"
|
||||
import SortVertical from "@assets/icons/Sort Vertical.svg?dataurl"
|
||||
import Star from "@assets/icons/Star.svg?dataurl"
|
||||
import StarFallMinimalistic2 from "@assets/icons/Star Fall Minimalistic 2.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/Trash Bin 2.svg?dataurl"
|
||||
import UFO3 from "@assets/icons/UFO 3.svg?dataurl"
|
||||
import UserHeart from "@assets/icons/User Heart.svg?dataurl"
|
||||
@@ -190,6 +191,7 @@
|
||||
"square-share-line": SquareShareLine,
|
||||
"sort-vertical": SortVertical,
|
||||
star: Star,
|
||||
"star-fall-minimalistic-2": StarFallMinimalistic2,
|
||||
"user-heart": UserHeart,
|
||||
"user-circle": UserCircle,
|
||||
"user-rounded": UserRounded,
|
||||
|
||||
122
src/routes/spaces/[relay]/goals/+page.svelte
Normal file
122
src/routes/spaces/[relay]/goals/+page.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {sortBy, max, nthEq} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
ZAP_GOAL,
|
||||
REACTION,
|
||||
ZAP_RESPONSE,
|
||||
DELETE,
|
||||
COMMENT,
|
||||
getListTags,
|
||||
getPubkeyTagValues,
|
||||
} from "@welshman/util"
|
||||
import {userMutes} from "@welshman/app"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
|
||||
import GoalItem from "@app/components/GoalItem.svelte"
|
||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||
import {decodeRelay, getEventsForUrl} from "@app/state"
|
||||
import {setChecked} from "@app/notifications"
|
||||
import {makeFeed} from "@app/requests"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const url = decodeRelay($page.params.relay)
|
||||
const mutedPubkeys = getPubkeyTagValues(getListTags($userMutes))
|
||||
const goals: TrustedEvent[] = $state([])
|
||||
const comments: TrustedEvent[] = $state([])
|
||||
|
||||
let loading = $state(true)
|
||||
let element: HTMLElement | undefined = $state()
|
||||
|
||||
const createGoal = () => pushModal(GoalCreate, {url})
|
||||
|
||||
const events = $derived.by(() => {
|
||||
const scores = new Map<string, number>()
|
||||
|
||||
for (const comment of comments) {
|
||||
const id = comment.tags.find(nthEq(0, "E"))?.[1]
|
||||
|
||||
if (id) {
|
||||
scores.set(id, max([scores.get(id), comment.created_at]))
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(e => -max([scores.get(e.id), e.created_at]), goals)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const {cleanup} = makeFeed({
|
||||
element: element!,
|
||||
relays: [url],
|
||||
feedFilters: [{kinds: [ZAP_GOAL, COMMENT]}],
|
||||
subscriptionFilters: [
|
||||
{kinds: [ZAP_GOAL, REACTION, ZAP_RESPONSE, DELETE]},
|
||||
{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]},
|
||||
],
|
||||
initialEvents: getEventsForUrl(url, [{kinds: [ZAP_GOAL, COMMENT], limit: 10}]),
|
||||
onEvent: event => {
|
||||
if (event.kind === ZAP_GOAL && !mutedPubkeys.includes(event.pubkey)) {
|
||||
goals.push(event)
|
||||
}
|
||||
|
||||
if (event.kind === COMMENT) {
|
||||
comments.push(event)
|
||||
}
|
||||
},
|
||||
onExhausted: () => {
|
||||
loading = false
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanup()
|
||||
setChecked($page.url.pathname)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
{#snippet icon()}
|
||||
<div class="center">
|
||||
<Icon icon="notes-minimalistic" />
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<strong>Fundraising Goals</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<div class="row-2">
|
||||
<Button class="btn btn-primary btn-sm" onclick={createGoal}>
|
||||
<Icon icon="notes-minimalistic" />
|
||||
Create a Goal
|
||||
</Button>
|
||||
<MenuSpaceButton {url} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
|
||||
{#each events as event (event.id)}
|
||||
<div in:fly>
|
||||
<GoalItem {url} event={$state.snapshot(event)} />
|
||||
</div>
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Looking for goals...
|
||||
{:else if events.length === 0}
|
||||
No goals found.
|
||||
{:else}
|
||||
That's all!
|
||||
{/if}
|
||||
</Spinner>
|
||||
</p>
|
||||
</PageContent>
|
||||
123
src/routes/spaces/[relay]/goals/[id]/+page.svelte
Normal file
123
src/routes/spaces/[relay]/goals/[id]/+page.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {sortBy, sleep} from "@welshman/lib"
|
||||
import {COMMENT, getTagValue} from "@welshman/util"
|
||||
import {repository} from "@welshman/app"
|
||||
import {request} from "@welshman/net"
|
||||
import {deriveEvents} from "@welshman/store"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
|
||||
import GoalSummary from "@app/components/GoalSummary.svelte"
|
||||
import GoalActions from "@app/components/GoalActions.svelte"
|
||||
import CommentActions from "@app/components/CommentActions.svelte"
|
||||
import EventReply from "@app/components/EventReply.svelte"
|
||||
import {deriveEvent, decodeRelay} from "@app/state"
|
||||
import {setChecked} from "@app/notifications"
|
||||
|
||||
const {relay, id} = $page.params
|
||||
const url = decodeRelay(relay)
|
||||
const event = deriveEvent(id)
|
||||
const filters = [{kinds: [COMMENT], "#E": [id]}]
|
||||
const replies = deriveEvents(repository, {filters})
|
||||
const summary = getTagValue("summary", $event.tags)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const openReply = () => {
|
||||
showReply = true
|
||||
}
|
||||
|
||||
const closeReply = () => {
|
||||
showReply = false
|
||||
}
|
||||
|
||||
const expand = () => {
|
||||
showAll = true
|
||||
}
|
||||
|
||||
let showAll = $state(false)
|
||||
let showReply = $state(false)
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
request({relays: [url], filters, signal: controller.signal})
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
setChecked($page.url.pathname)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
{#snippet icon()}
|
||||
<div>
|
||||
<Button class="btn btn-neutral btn-sm flex-nowrap whitespace-nowrap" onclick={back}>
|
||||
<Icon icon="alt-arrow-left" />
|
||||
<span class="hidden sm:inline">Go back</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<h1 class="text-xl">{$event.content}</h1>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<div>
|
||||
<MenuSpaceButton {url} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col p-2 pt-4">
|
||||
{#if $event}
|
||||
<div class="flex flex-col gap-3">
|
||||
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
||||
<div class="col-3 ml-12">
|
||||
<Content showEntire event={{...$event, content: summary}} {url} />
|
||||
<GoalSummary event={$event} {url} />
|
||||
<GoalActions event={$event} {url} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{#if !showAll && $replies.length > 4}
|
||||
<div class="flex justify-center">
|
||||
<Button class="btn btn-link" onclick={expand}>
|
||||
<Icon icon="sort-vertical" />
|
||||
Show all {$replies.length} replies
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#each sortBy(e => e.created_at, $replies).slice(0, showAll ? undefined : 4) as reply (reply.id)}
|
||||
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
|
||||
<div class="col-3 ml-12">
|
||||
<Content showEntire event={reply} {url} />
|
||||
<CommentActions event={reply} {url} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{/each}
|
||||
</div>
|
||||
{#if showReply}
|
||||
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
|
||||
{:else}
|
||||
<div class="flex justify-end p-2">
|
||||
<Button class="btn btn-primary" onclick={openReply}>
|
||||
<Icon icon="reply" />
|
||||
Comment on this goal
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading funding goal...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load funding goal.</p>
|
||||
{/await}
|
||||
{/if}
|
||||
</PageContent>
|
||||
Reference in New Issue
Block a user