Move goal aggregation, the article form, space search ranking and detail-page loading fallbacks out of routes
This commit is contained in:
parent
e5a270aa5c
commit
a9ea5e34fe
20 changed files with 552 additions and 515 deletions
|
|
@ -331,7 +331,8 @@ with `makeFeed` and filters them with the `classifieds.ts` helpers. `[address]/+
|
||||||
one listing with `$events.one(address, [url]).$`.
|
one listing with `$events.one(address, [url]).$`.
|
||||||
|
|
||||||
Articles are composed on a full page (`spaces/[relay]/articles/create`, built by
|
Articles are composed on a full page (`spaces/[relay]/articles/create`, built by
|
||||||
`makeArticleCreatePath`) instead of in a modal.
|
`makeArticleCreatePath`) instead of in a modal. The page reads `?h=` and `?shareToChat` and
|
||||||
|
renders `ArticleForm`.
|
||||||
|
|
||||||
## Related skills
|
## Related skills
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores.
|
||||||
| `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL |
|
| `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL |
|
||||||
| `Chats` (`chats.ts`) | none | DM conversations by chat id (`index`, `one`), and a fuzzy `search` |
|
| `Chats` (`chats.ts`) | none | DM conversations by chat id (`index`, `one`), and a fuzzy `search` |
|
||||||
| `LatestEvents` (`repository.ts`) | none | the most recent event held from an author (`forPubkey`) |
|
| `LatestEvents` (`repository.ts`) | none | the most recent event held from an author (`forPubkey`) |
|
||||||
|
| `GoalProgresses` (`goals.ts`) | none | one memoized NIP-75 progress store per goal and relay, behind `deriveGoalProgress` |
|
||||||
| `Hosting` (`hosting.ts`) | none | the hosting backend's plans, the tenant's relays and billing, shared by every hosting view |
|
| `Hosting` (`hosting.ts`) | none | the hosting backend's plans, the tenant's relays and billing, shared by every hosting view |
|
||||||
|
|
||||||
Each is exposed with `usePlugin`. `Statuses` is the minimal shape:
|
Each is exposed with `usePlugin`. `Statuses` is the minimal shape:
|
||||||
|
|
|
||||||
|
|
@ -229,8 +229,8 @@ lives there:
|
||||||
`ModalSubtitle`, `ModalFooter`, `Confirm`
|
`ModalSubtitle`, `ModalFooter`, `Confirm`
|
||||||
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
|
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
|
||||||
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
|
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
|
||||||
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`, `Cv`,
|
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `LoadingFallback`, `Tooltip`,
|
||||||
`VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
`Tippy`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
||||||
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens
|
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens
|
||||||
|
|
||||||
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app
|
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app
|
||||||
|
|
@ -324,14 +324,13 @@ data every page needs lives in `src/app/sync.ts`.
|
||||||
lists before loading the author's outbox with `$network.loadLenient`.
|
lists before loading the author's outbox with `$network.loadLenient`.
|
||||||
|
|
||||||
**A detail page:** `$events.one(address, [url]).$` loads when nothing local matches. While it is
|
**A detail page:** `$events.one(address, [url]).$` loads when nothing local matches. While it is
|
||||||
empty, seven pages show a spinner that turns into a failure message:
|
empty, seven pages show `LoadingFallback`, a spinner that turns into a failure message after
|
||||||
|
five seconds:
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
{#await sleep(5000)}
|
<LoadingFallback
|
||||||
<Spinner loading>Loading listing...</Spinner>
|
loadingMessage="Loading listing..."
|
||||||
{:then}
|
failureMessage="Failed to load classified listing." />
|
||||||
<p>Failed to load classified listing.</p>
|
|
||||||
{/await}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Related events:** request them, abort on teardown, and read them back from the repository with
|
**Related events:** request them, abort on teardown, and read them back from the repository with
|
||||||
|
|
@ -444,7 +443,9 @@ Create/edit pairs come in three shapes:
|
||||||
- **The form owns the mutation and a draft.** `ClassifiedForm` persists fields with `DraftKey`
|
- **The form owns the mutation and a draft.** `ClassifiedForm` persists fields with `DraftKey`
|
||||||
and republishes the same `d` on edit. `ClassifiedCreate` passes a header, and `ClassifiedEdit`
|
and republishes the same `d` on edit. `ClassifiedCreate` passes a header, and `ClassifiedEdit`
|
||||||
turns a reader into `initialValues`. Its `Values` type stays local, so `ClassifiedEdit`
|
turns a reader into `initialValues`. Its `Values` type stays local, so `ClassifiedEdit`
|
||||||
restates the shape; export it instead.
|
restates the shape; export it instead, as `ArticleForm` does. `ArticleForm` is the same shape
|
||||||
|
on a full page: it renders the `SpaceBar` with its Publish button, takes the title as `header`,
|
||||||
|
and lands on the published article.
|
||||||
|
|
||||||
Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`.
|
Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`.
|
||||||
|
|
||||||
|
|
|
||||||
270
src/app/components/ArticleForm.svelte
Normal file
270
src/app/components/ArticleForm.svelte
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
<script lang="ts" module>
|
||||||
|
export type Values = {
|
||||||
|
d: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
image: string
|
||||||
|
topics: string[]
|
||||||
|
content: string | object
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
|
import {writable} from "svelte/store"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import {now, randomId} from "@welshman/lib"
|
||||||
|
import {getAddress} from "@welshman/util"
|
||||||
|
import {Article} from "@welshman/domain"
|
||||||
|
import {isMobile} from "@lib/html"
|
||||||
|
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||||
|
import DocumentText from "@assets/icons/document-text.svg?dataurl"
|
||||||
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
|
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
|
||||||
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
|
import {writer} from "@app/core"
|
||||||
|
import {DraftKey} from "@app/drafts"
|
||||||
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {publishToSpace} from "@app/rooms"
|
||||||
|
import {makeArticlePath} from "@app/routes"
|
||||||
|
import {compressFileForUpload, uploadFile} from "@app/uploads"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
h?: string
|
||||||
|
shareToChat?: boolean
|
||||||
|
header: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h, shareToChat = false, header}: Props = $props()
|
||||||
|
|
||||||
|
const draftKey = new DraftKey<Values>(`article:${url}:${h ?? ""}`)
|
||||||
|
const initialValues = draftKey.get()
|
||||||
|
|
||||||
|
const coverInputId = randomId()
|
||||||
|
|
||||||
|
const uploading = writable(false)
|
||||||
|
|
||||||
|
const back = () => goto(makeArticlePath(url))
|
||||||
|
|
||||||
|
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||||
|
|
||||||
|
const clearCover = () => {
|
||||||
|
image = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCoverChange = async (event: Event & {currentTarget: HTMLInputElement}) => {
|
||||||
|
const input = event.currentTarget
|
||||||
|
const file = input.files?.[0]
|
||||||
|
|
||||||
|
// Clear the input so picking the same file again after removing the cover still fires
|
||||||
|
input.value = ""
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
uploadingCover = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
|
||||||
|
|
||||||
|
if (result?.url) {
|
||||||
|
image = result.url
|
||||||
|
} else {
|
||||||
|
pushToast({theme: "error", message: error ?? "Failed to upload your cover image."})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// compressFileForUpload rejects outside uploadFile's own error handling
|
||||||
|
console.error("Error caught when uploading cover image:", e)
|
||||||
|
pushToast({theme: "error", message: "Failed to upload your cover image."})
|
||||||
|
} finally {
|
||||||
|
uploadingCover = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if ($uploading || uploadingCover || loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please provide a title for your article.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ed = await editor
|
||||||
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please write something for your article.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventWriter = writer(Article)
|
||||||
|
.setIdentifier(d)
|
||||||
|
.setContent(content)
|
||||||
|
.setTitle(title)
|
||||||
|
.setTopics(topics)
|
||||||
|
.setPublishedAt(now())
|
||||||
|
.addTags(...ed.storage.nostr.getEditorTags())
|
||||||
|
|
||||||
|
if (summary) {
|
||||||
|
eventWriter.setSummary(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
eventWriter.setImage(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {thunk, error} = await publishToSpace(eventWriter, {url, h, shareToChat})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return pushToast({theme: "error", message: error})
|
||||||
|
}
|
||||||
|
|
||||||
|
draftKey.clear()
|
||||||
|
goto(makeArticlePath(url, getAddress(thunk.options.event)))
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let loading = $state(false)
|
||||||
|
let uploadingCover = $state(false)
|
||||||
|
let titleInput = $state<HTMLInputElement>()
|
||||||
|
|
||||||
|
// The autofocus attribute is ignored while the button that navigated here still holds focus
|
||||||
|
onMount(() => {
|
||||||
|
if (!isMobile) {
|
||||||
|
titleInput?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const d = $state(initialValues?.d ?? randomId())
|
||||||
|
let title = $state(initialValues?.title ?? "")
|
||||||
|
let summary = $state(initialValues?.summary ?? "")
|
||||||
|
let image = $state(initialValues?.image ?? "")
|
||||||
|
let topics = $state(initialValues?.topics ?? [])
|
||||||
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
|
const onChange = (json: object) => {
|
||||||
|
content = json
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({
|
||||||
|
url,
|
||||||
|
submit,
|
||||||
|
uploading,
|
||||||
|
onChange,
|
||||||
|
placeholder: "Write your article...",
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
draftKey.set({d, title, summary, image, topics, content})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SpaceBar {back}>
|
||||||
|
{#snippet leading()}
|
||||||
|
<Icon icon={DocumentText} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet title()}
|
||||||
|
{@render header()}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet action()}
|
||||||
|
<Button
|
||||||
|
class="button button-primary button-sm"
|
||||||
|
onclick={submit}
|
||||||
|
disabled={$uploading || uploadingCover || loading}>
|
||||||
|
<Spinner {loading}>Publish</Spinner>
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</SpaceBar>
|
||||||
|
|
||||||
|
<!-- column flex: justify-center would put the top of a tall page out of scroll reach -->
|
||||||
|
<PageContent class="items-center">
|
||||||
|
<div class="flex w-full max-w-3xl flex-col gap-6">
|
||||||
|
<input
|
||||||
|
id={coverInputId}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
onchange={onCoverChange}
|
||||||
|
class="hidden" />
|
||||||
|
{#if image}
|
||||||
|
<div class="relative">
|
||||||
|
<img src={image} alt="Article cover" class="h-56 w-full rounded-2xl object-cover" />
|
||||||
|
<Button
|
||||||
|
data-tip="Remove cover image"
|
||||||
|
aria-label="Remove cover image"
|
||||||
|
class="tip tip-left button button-neutral button-sm button-circle absolute right-2 top-2"
|
||||||
|
onclick={clearCover}>
|
||||||
|
<Icon icon={CloseCircle} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<label
|
||||||
|
for={coverInputId}
|
||||||
|
class="flex h-28 cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed border-line text-content-muted transition-colors hover:text-content">
|
||||||
|
{#if uploadingCover}
|
||||||
|
<Spinner loading>Uploading cover image...</Spinner>
|
||||||
|
{:else}
|
||||||
|
<Icon icon={GallerySend} />
|
||||||
|
Add a cover image
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
bind:this={titleInput}
|
||||||
|
bind:value={title}
|
||||||
|
class="bg-transparent text-3xl font-bold outline-none placeholder:text-content-muted"
|
||||||
|
type="text"
|
||||||
|
placeholder="Title" />
|
||||||
|
<input
|
||||||
|
bind:value={summary}
|
||||||
|
class="placeholder:text-content-muted bg-transparent text-lg outline-none"
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a one line teaser for the article list" />
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="article-editor note-editor">
|
||||||
|
<EditorContent {editor} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
data-tip="Add an image"
|
||||||
|
class="tip tip-left absolute bottom-1 right-2"
|
||||||
|
onclick={selectFiles}
|
||||||
|
disabled={loading}>
|
||||||
|
{#if $uploading}
|
||||||
|
<Spinner size="xs" />
|
||||||
|
{:else}
|
||||||
|
<Icon icon={Paperclip} size={3} />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Topics</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<TopicMultiSelect bind:value={topics} />
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<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"
|
||||||
|
|
@ -9,7 +8,6 @@
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
import ContentActions from "@app/components/ContentActions.svelte"
|
import ContentActions from "@app/components/ContentActions.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"
|
||||||
|
|
@ -19,10 +17,9 @@
|
||||||
url: string
|
url: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
context: FeedContext
|
context: FeedContext
|
||||||
progress?: Readable<GoalProgress>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, event, context, progress}: Props = $props()
|
const {url, event, context}: Props = $props()
|
||||||
|
|
||||||
const goal = reader(ZapGoal)(event)
|
const goal = reader(ZapGoal)(event)
|
||||||
|
|
||||||
|
|
@ -49,7 +46,7 @@
|
||||||
maxLength={180} />
|
maxLength={180} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<GoalSummary {url} {event} {progress} />
|
<GoalSummary {url} {event} />
|
||||||
<div class="flex w-full min-w-0 flex-wrap items-center justify-between gap-2">
|
<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">
|
<span class="min-w-0 py-1 text-sm text-content-muted">
|
||||||
by <ProfileLink pubkey={event.pubkey} {url} />
|
by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import type {Readable} from "svelte/store"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
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"
|
||||||
|
|
@ -10,19 +9,16 @@
|
||||||
import GoalStatus from "@app/components/GoalStatus.svelte"
|
import GoalStatus from "@app/components/GoalStatus.svelte"
|
||||||
import {ENABLE_ZAPS} from "@app/env"
|
import {ENABLE_ZAPS} from "@app/env"
|
||||||
import {deriveGoalProgress} from "@app/goals"
|
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, progress: providedProgress, ...props}: Props = $props()
|
const {url, event, ...props}: Props = $props()
|
||||||
|
|
||||||
// The list page already has a store per goal; reuse it instead of resubscribing.
|
const progress = deriveGoalProgress(event, url)
|
||||||
const progress = providedProgress ?? deriveGoalProgress(event, url)
|
|
||||||
|
|
||||||
const backers = $derived($progress.backers.length)
|
const backers = $derived($progress.backers.length)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
import {onDestroy} from "svelte"
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {sleep} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import Reply from "@assets/icons/reply-2.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 Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
|
||||||
import CommentCompose from "@app/components/CommentCompose.svelte"
|
import CommentCompose from "@app/components/CommentCompose.svelte"
|
||||||
import CommentTree from "@app/components/CommentTree.svelte"
|
import CommentTree from "@app/components/CommentTree.svelte"
|
||||||
import NoteItem from "@app/components/NoteItem.svelte"
|
import NoteItem from "@app/components/NoteItem.svelte"
|
||||||
|
|
@ -69,13 +68,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-center py-20">
|
<LoadingFallback loadingMessage="Loading note..." failureMessage="Failed to load note." />
|
||||||
{#await sleep(5000)}
|
|
||||||
<Spinner loading>Loading note...</Spinner>
|
|
||||||
{:then}
|
|
||||||
<p>Failed to load note.</p>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
30
src/app/components/SpaceResult.svelte
Normal file
30
src/app/components/SpaceResult.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import RelaySummary from "@app/components/RelaySummary.svelte"
|
||||||
|
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
|
||||||
|
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||||
|
import {userSpaceUrls} from "@app/rooms"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {goToSpace} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
invite?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, invite}: Props = $props()
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
if ($userSpaceUrls.includes(url)) {
|
||||||
|
goToSpace(url)
|
||||||
|
} else if (invite) {
|
||||||
|
pushModal(SpaceInviteAccept, {invite})
|
||||||
|
} else {
|
||||||
|
pushModal(SpaceJoin, {url})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button class="card card-interactive" onclick={open}>
|
||||||
|
<RelaySummary {url} />
|
||||||
|
</Button>
|
||||||
139
src/app/goals.ts
139
src/app/goals.ts
|
|
@ -1,12 +1,13 @@
|
||||||
import {derived} from "svelte/store"
|
import {derived, readable} from "svelte/store"
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
import {now, removeUndefined} from "@welshman/lib"
|
import {now, removeUndefined, sortBy} from "@welshman/lib"
|
||||||
import {fromMsats} from "@welshman/util"
|
import {fromMsats} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {ZapGoal} from "@welshman/domain"
|
import {ZapGoal} from "@welshman/domain"
|
||||||
import type {Zap} from "@welshman/domain"
|
import type {Zap} from "@welshman/domain"
|
||||||
import {ZapGoals} from "@welshman/app"
|
import {Domain, ZapGoals} from "@welshman/app"
|
||||||
import {app, reader} from "@app/core"
|
import type {IApp} from "@welshman/app"
|
||||||
|
import {app} from "@app/core"
|
||||||
|
|
||||||
export type GoalProgress = {
|
export type GoalProgress = {
|
||||||
zaps: Zap[]
|
zaps: Zap[]
|
||||||
|
|
@ -19,22 +20,118 @@ export type GoalProgress = {
|
||||||
closedAt?: number
|
closedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Welshman tallies a goal in millisats, and comparing there avoids funding a rounded sat early.
|
export type GoalFilter = "all" | "live" | "funded" | "ended"
|
||||||
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 => ({
|
export type GoalSort = "active" | "new" | "progress" | "ending"
|
||||||
zaps: $progress.zaps,
|
|
||||||
raised: fromMsats($progress.amount),
|
export class GoalProgresses {
|
||||||
target: fromMsats($progress.target),
|
private progressByKey = new Map<string, Readable<GoalProgress>>()
|
||||||
percent: $progress.target > 0 ? Math.round(($progress.amount / $progress.target) * 100) : 0,
|
|
||||||
backers: $progress.contributors,
|
constructor(private readonly app: IApp) {}
|
||||||
isFunded: $progress.target > 0 && $progress.amount >= $progress.target,
|
|
||||||
isEnded: Boolean(closedAt && closedAt < now()),
|
forGoal = (event: TrustedEvent, url?: string) => {
|
||||||
closedAt,
|
const key = `${event.id}:${url ?? ""}`
|
||||||
}))
|
let progress = this.progressByKey.get(key)
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
progress = this.derive(event, url)
|
||||||
|
this.progressByKey.set(key, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
return progress
|
||||||
|
}
|
||||||
|
|
||||||
|
// Welshman tallies a goal in millisats, and comparing there avoids funding a rounded sat early.
|
||||||
|
private derive = (event: TrustedEvent, url?: string): Readable<GoalProgress> => {
|
||||||
|
const closedAt = this.app.use(Domain).reader(ZapGoal)(event).closedAt()
|
||||||
|
const progress = this.app.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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deriveGoalProgress = (event: TrustedEvent, url?: string) =>
|
||||||
|
app.get().use(GoalProgresses).forGoal(event, url)
|
||||||
|
|
||||||
|
// `derived` over no stores never fires, so an empty board needs a store of its own.
|
||||||
|
export const deriveGoalProgressById = (goals: TrustedEvent[], url: string) =>
|
||||||
|
goals.length > 0
|
||||||
|
? derived(
|
||||||
|
goals.map(event => deriveGoalProgress(event, url)),
|
||||||
|
progresses => new Map(progresses.map((progress, i) => [goals[i]!.id, progress])),
|
||||||
|
)
|
||||||
|
: readable(new Map<string, GoalProgress>())
|
||||||
|
|
||||||
|
export const getGoalTotals = (progressById: Map<string, GoalProgress>) => {
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filterGoals = (
|
||||||
|
filter: GoalFilter,
|
||||||
|
goals: TrustedEvent[],
|
||||||
|
progressById: Map<string, GoalProgress>,
|
||||||
|
) =>
|
||||||
|
goals.filter(event => {
|
||||||
|
const progress = progressById.get(event.id)
|
||||||
|
|
||||||
|
if (filter === "live") {
|
||||||
|
return Boolean(progress && !progress.isEnded && !progress.isFunded)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter === "funded") {
|
||||||
|
return Boolean(progress?.isFunded)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter === "ended") {
|
||||||
|
return Boolean(progress?.isEnded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export const sortGoals = (
|
||||||
|
sort: GoalSort,
|
||||||
|
goals: TrustedEvent[],
|
||||||
|
progressById: Map<string, GoalProgress>,
|
||||||
|
activeAt: Map<string, number>,
|
||||||
|
) => {
|
||||||
|
if (sort === "new") {
|
||||||
|
return sortBy(event => -event.created_at, goals)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort === "progress") {
|
||||||
|
return sortBy(event => -(progressById.get(event.id)?.percent ?? 0), goals)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort === "ending") {
|
||||||
|
return sortBy(event => {
|
||||||
|
const closedAt = progressById.get(event.id)?.closedAt
|
||||||
|
|
||||||
|
return closedAt && closedAt > now() ? closedAt : Infinity
|
||||||
|
}, goals)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(event => -(activeAt.get(event.id) ?? event.created_at), goals)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {derived, get} from "svelte/store"
|
import {derived, get} from "svelte/store"
|
||||||
import {formatTimestampAsDate, int, sortBy, uniq, MINUTE} from "@welshman/lib"
|
import {addToMapKey, dec, formatTimestampAsDate, int, sortBy, uniq, MINUTE} from "@welshman/lib"
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
ROOM_DELETE_EVENT,
|
ROOM_DELETE_EVENT,
|
||||||
|
|
@ -15,7 +15,8 @@ import {
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {EventContent, TrustedEvent} from "@welshman/util"
|
import type {EventContent, TrustedEvent} from "@welshman/util"
|
||||||
import {Comment, Message} from "@welshman/domain"
|
import {Comment, Message} from "@welshman/domain"
|
||||||
import type {BaseEventReader, EventWriter} from "@welshman/domain"
|
import type {BaseEventReader, EventWriter, Relay} from "@welshman/domain"
|
||||||
|
import {throttled} from "@welshman/store"
|
||||||
import {MembershipStatus, RoomLists, makeRoomKey, createSearch, publish} from "@welshman/app"
|
import {MembershipStatus, RoomLists, makeRoomKey, createSearch, publish} from "@welshman/app"
|
||||||
import type {Room, RoomMeta} from "@welshman/app"
|
import type {Room, RoomMeta} from "@welshman/app"
|
||||||
import {
|
import {
|
||||||
|
|
@ -266,6 +267,45 @@ export const deriveUserRoomMembershipStatus = (url: string, h: string) =>
|
||||||
([$status, $isAdmin]) => ($isAdmin ? MembershipStatus.Granted : $status),
|
([$status, $isAdmin]) => ($isAdmin ? MembershipStatus.Granted : $status),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// How many people list each space both scopes and ranks the search.
|
||||||
|
export const deriveSpaceSearch = () => {
|
||||||
|
const spacePubkeysByUrl = derived(roomLists.get().all.$, $roomLists => {
|
||||||
|
const result = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
for (const roomList of $roomLists) {
|
||||||
|
for (const url of roomList.urls()) {
|
||||||
|
addToMapKey(result, url, roomList.author())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return derived(
|
||||||
|
[throttled(1000, relays.get().all.$), spacePubkeysByUrl],
|
||||||
|
([$relays, $spacePubkeysByUrl]) =>
|
||||||
|
createSearch(
|
||||||
|
$relays.filter(r => $spacePubkeysByUrl.has(r.url)),
|
||||||
|
{
|
||||||
|
getValue: (relay: Relay) => relay.url,
|
||||||
|
sortFn: ({score, item}) => {
|
||||||
|
if (score && score > 0.1) {
|
||||||
|
return -score
|
||||||
|
}
|
||||||
|
|
||||||
|
const wotScore = $spacePubkeysByUrl.get(item.url)?.size || 0
|
||||||
|
|
||||||
|
return score ? dec(score) * wotScore : -wotScore
|
||||||
|
},
|
||||||
|
fuseOptions: {
|
||||||
|
keys: ["url", "name", {name: "description", weight: 0.3}],
|
||||||
|
shouldSort: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const deriveUserRoomSearch = () =>
|
export const deriveUserRoomSearch = () =>
|
||||||
derived(
|
derived(
|
||||||
[userSpaceUrls, userRoomList, rooms.get().byUrl.$],
|
[userSpaceUrls, userRoomList, rooms.get().byUrl.$],
|
||||||
|
|
|
||||||
21
src/lib/components/LoadingFallback.svelte
Normal file
21
src/lib/components/LoadingFallback.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import {sleep} from "@welshman/lib"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
loadingMessage: string
|
||||||
|
failureMessage: string
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {loadingMessage, failureMessage, ...props}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cx("flex justify-center py-20", props.class)}>
|
||||||
|
{#await sleep(5000)}
|
||||||
|
<Spinner loading>{loadingMessage}</Spinner>
|
||||||
|
{:then}
|
||||||
|
<p>{failureMessage}</p>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {derived as _derived} from "svelte/store"
|
import {sleep} from "@welshman/lib"
|
||||||
import {addToMapKey, dec, sleep} from "@welshman/lib"
|
|
||||||
import {ROOMS} from "@welshman/util"
|
import {ROOMS} from "@welshman/util"
|
||||||
import type {Relay} from "@welshman/domain"
|
import {Sync} from "@welshman/app"
|
||||||
import {throttled} from "@welshman/store"
|
|
||||||
import {Sync, createSearch} from "@welshman/app"
|
|
||||||
import {createScroller, isMobile} from "@lib/html"
|
import {createScroller, isMobile} from "@lib/html"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||||
|
|
@ -19,75 +16,26 @@
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ContentSearch from "@lib/components/ContentSearch.svelte"
|
import ContentSearch from "@lib/components/ContentSearch.svelte"
|
||||||
import RelaySummary from "@app/components/RelaySummary.svelte"
|
|
||||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||||
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
|
import SpaceResult from "@app/components/SpaceResult.svelte"
|
||||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
import {app, roomLists, user} from "@app/core"
|
||||||
import {app, relays, roomLists, user} from "@app/core"
|
import {deriveSpaceSearch, userSpaceUrls} from "@app/rooms"
|
||||||
import {userSpaceUrls} from "@app/rooms"
|
|
||||||
import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env"
|
import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env"
|
||||||
import {bootstrapPubkeys} from "@app/social"
|
import {bootstrapPubkeys} from "@app/social"
|
||||||
import {parseInviteLink} from "@app/access"
|
import {parseInviteLink} from "@app/access"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {goToSpace} from "@app/routes"
|
|
||||||
|
|
||||||
const addSpace = () => pushModal(SpaceAdd)
|
const addSpace = () => pushModal(SpaceAdd)
|
||||||
|
|
||||||
const userSpacesLoaded = $roomLists.load($user.pubkey)
|
const userSpacesLoaded = $roomLists.load($user.pubkey)
|
||||||
|
const spaceSearch = deriveSpaceSearch()
|
||||||
// How many people list each space, which both scopes and ranks search.
|
|
||||||
const spacePubkeysByUrl = _derived($roomLists.all.$, $roomLists => {
|
|
||||||
const result = new Map<string, Set<string>>()
|
|
||||||
|
|
||||||
for (const roomList of $roomLists) {
|
|
||||||
for (const url of roomList.urls()) {
|
|
||||||
addToMapKey(result, url, roomList.author())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const relaySearch = _derived(
|
|
||||||
[throttled(1000, $relays.all.$), spacePubkeysByUrl],
|
|
||||||
([$relays, $spacePubkeysByUrl]) => {
|
|
||||||
const options = $relays.filter(r => $spacePubkeysByUrl.has(r.url))
|
|
||||||
|
|
||||||
return createSearch(options, {
|
|
||||||
getValue: (relay: Relay) => relay.url,
|
|
||||||
sortFn: ({score, item}) => {
|
|
||||||
if (score && score > 0.1) {
|
|
||||||
return -score!
|
|
||||||
}
|
|
||||||
|
|
||||||
const wotScore = $spacePubkeysByUrl.get(item.url)?.size || 0
|
|
||||||
|
|
||||||
return score ? dec(score) * wotScore : -wotScore
|
|
||||||
},
|
|
||||||
fuseOptions: {
|
|
||||||
keys: ["url", "name", {name: "description", weight: 0.3}],
|
|
||||||
shouldSort: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const openSpace = (url: string, claim = "") => {
|
|
||||||
if ($userSpaceUrls.includes(url)) {
|
|
||||||
goToSpace(url)
|
|
||||||
} else if (claim) {
|
|
||||||
pushModal(SpaceInviteAccept, {invite: term})
|
|
||||||
} else {
|
|
||||||
pushModal(SpaceJoin, {url})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let term = $state("")
|
let term = $state("")
|
||||||
let limit = $state(20)
|
let limit = $state(20)
|
||||||
let element: Element
|
let element: Element
|
||||||
|
|
||||||
const inviteData = $derived(parseInviteLink(term))
|
const inviteData = $derived(parseInviteLink(term))
|
||||||
const searchResults = $derived($relaySearch.searchOptions(term))
|
const searchResults = $derived($spaceSearch.searchOptions(term))
|
||||||
const userSpaceSet = $derived(new Set($userSpaceUrls))
|
const userSpaceSet = $derived(new Set($userSpaceUrls))
|
||||||
const otherSpaces = $derived(
|
const otherSpaces = $derived(
|
||||||
searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url),
|
searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url),
|
||||||
|
|
@ -146,9 +94,7 @@
|
||||||
{#snippet content()}
|
{#snippet content()}
|
||||||
<div class="flex flex-col gap-2" bind:this={element}>
|
<div class="flex flex-col gap-2" bind:this={element}>
|
||||||
{#each PLATFORM_RELAYS as url (url)}
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
<Button class="card card-interactive" onclick={() => openSpace(url)}>
|
<SpaceResult {url} />
|
||||||
<RelaySummary {url} />
|
|
||||||
</Button>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#await userSpacesLoaded}
|
{#await userSpacesLoaded}
|
||||||
<div class="flex items-center justify-center py-20">
|
<div class="flex items-center justify-center py-20">
|
||||||
|
|
@ -159,20 +105,14 @@
|
||||||
{#if inviteData}
|
{#if inviteData}
|
||||||
<Divider>Search results</Divider>
|
<Divider>Search results</Divider>
|
||||||
{#key inviteData.url}
|
{#key inviteData.url}
|
||||||
<Button
|
<SpaceResult url={inviteData.url} invite={inviteData.claim ? term : undefined} />
|
||||||
class="card card-interactive"
|
|
||||||
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
|
|
||||||
<RelaySummary url={inviteData.url} />
|
|
||||||
</Button>
|
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
{#if otherSpaces.length > 0}
|
{#if otherSpaces.length > 0}
|
||||||
<Divider>Browse Spaces</Divider>
|
<Divider>Browse Spaces</Divider>
|
||||||
{/if}
|
{/if}
|
||||||
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
|
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
|
||||||
<Button class="card card-interactive" onclick={() => openSpace(relay.url)}>
|
<SpaceResult url={relay.url} />
|
||||||
<RelaySummary url={relay.url} />
|
|
||||||
</Button>
|
|
||||||
{/each}
|
{/each}
|
||||||
<div class="flex justify-center py-20">
|
<div class="flex justify-center py-20">
|
||||||
{#await sleep(5000)}
|
{#await sleep(5000)}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<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 {formatTimestamp, sleep, uniq} from "@welshman/lib"
|
import {formatTimestamp, uniq} from "@welshman/lib"
|
||||||
import {Article} from "@welshman/domain"
|
import {Article} from "@welshman/domain"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
|
||||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import ContentMarkdown from "@app/components/ContentMarkdown.svelte"
|
import ContentMarkdown from "@app/components/ContentMarkdown.svelte"
|
||||||
|
|
@ -134,12 +134,9 @@
|
||||||
<ArticleAuthorSidebar {url} event={$event} />
|
<ArticleAuthorSidebar {url} event={$event} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex grow justify-center py-20">
|
<LoadingFallback
|
||||||
{#await sleep(5000)}
|
class="grow"
|
||||||
<Spinner loading>Loading article...</Spinner>
|
loadingMessage="Loading article..."
|
||||||
{:then}
|
failureMessage="Failed to load article." />
|
||||||
<p>Failed to load article.</p>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
|
||||||
|
|
@ -1,267 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import {writable} from "svelte/store"
|
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {goto} from "$app/navigation"
|
import ArticleForm from "@app/components/ArticleForm.svelte"
|
||||||
import {now, randomId} from "@welshman/lib"
|
|
||||||
import {getAddress} from "@welshman/util"
|
|
||||||
import {Article} from "@welshman/domain"
|
|
||||||
import {isMobile} from "@lib/html"
|
|
||||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
|
||||||
import DocumentText from "@assets/icons/document-text.svg?dataurl"
|
|
||||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Field from "@lib/components/Field.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
|
||||||
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
|
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
|
||||||
import {writer} from "@app/core"
|
|
||||||
import {DraftKey} from "@app/drafts"
|
|
||||||
import {makeEditor} from "@app/editor"
|
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
import {publishToSpace} from "@app/rooms"
|
|
||||||
import {makeArticlePath} from "@app/routes"
|
|
||||||
import {compressFileForUpload, uploadFile} from "@app/uploads"
|
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
import type {PageProps} from "./$types"
|
import type {PageProps} from "./$types"
|
||||||
|
|
||||||
type Values = {
|
|
||||||
d?: string
|
|
||||||
content?: string | object
|
|
||||||
title?: string
|
|
||||||
summary?: string
|
|
||||||
image?: string
|
|
||||||
topics?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const {params}: PageProps = $props()
|
const {params}: PageProps = $props()
|
||||||
|
|
||||||
const url = decodeRelay(params.relay)
|
const url = decodeRelay(params.relay)
|
||||||
|
|
||||||
const h = $page.url.searchParams.get("h") ?? undefined
|
const h = $page.url.searchParams.get("h") ?? undefined
|
||||||
const shareToChat = $page.url.searchParams.has("shareToChat")
|
const shareToChat = $page.url.searchParams.has("shareToChat")
|
||||||
|
|
||||||
const draftKey = new DraftKey<Values>(`article:${url}:${h ?? ""}`)
|
|
||||||
const initialValues = draftKey.get()
|
|
||||||
const coverInputId = randomId()
|
|
||||||
|
|
||||||
const uploading = writable(false)
|
|
||||||
|
|
||||||
const back = () => goto(makeArticlePath(url))
|
|
||||||
|
|
||||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
|
||||||
|
|
||||||
const clearCover = () => {
|
|
||||||
image = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCoverChange = async (event: Event & {currentTarget: HTMLInputElement}) => {
|
|
||||||
const input = event.currentTarget
|
|
||||||
const file = input.files?.[0]
|
|
||||||
|
|
||||||
// Clear the input so picking the same file again after removing the cover still fires
|
|
||||||
input.value = ""
|
|
||||||
|
|
||||||
if (file) {
|
|
||||||
uploadingCover = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
|
|
||||||
|
|
||||||
if (result?.url) {
|
|
||||||
image = result.url
|
|
||||||
} else {
|
|
||||||
pushToast({theme: "error", message: error ?? "Failed to upload your cover image."})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// compressFileForUpload rejects outside uploadFile's own error handling
|
|
||||||
console.error("Error caught when uploading cover image:", e)
|
|
||||||
pushToast({theme: "error", message: "Failed to upload your cover image."})
|
|
||||||
} finally {
|
|
||||||
uploadingCover = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if ($uploading || uploadingCover || loading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "Please provide a title for your article.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const ed = await editor
|
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "Please write something for your article.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const eventWriter = writer(Article)
|
|
||||||
.setIdentifier(d)
|
|
||||||
.setContent(content)
|
|
||||||
.setTitle(title)
|
|
||||||
.setTopics(topics)
|
|
||||||
.setPublishedAt(now())
|
|
||||||
.addTags(...ed.storage.nostr.getEditorTags())
|
|
||||||
|
|
||||||
if (summary) {
|
|
||||||
eventWriter.setSummary(summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (image) {
|
|
||||||
eventWriter.setImage(image)
|
|
||||||
}
|
|
||||||
|
|
||||||
const {thunk, error} = await publishToSpace(eventWriter, {url, h, shareToChat})
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return pushToast({theme: "error", message: error})
|
|
||||||
}
|
|
||||||
|
|
||||||
draftKey.clear()
|
|
||||||
goto(makeArticlePath(url, getAddress(thunk.options.event)))
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let loading = $state(false)
|
|
||||||
let uploadingCover = $state(false)
|
|
||||||
let titleInput = $state<HTMLInputElement>()
|
|
||||||
|
|
||||||
// The autofocus attribute is ignored while the button that navigated here still holds focus
|
|
||||||
onMount(() => {
|
|
||||||
if (!isMobile) {
|
|
||||||
titleInput?.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const d = $state(initialValues?.d ?? randomId())
|
|
||||||
let title = $state(initialValues?.title ?? "")
|
|
||||||
let summary = $state(initialValues?.summary ?? "")
|
|
||||||
let image = $state(initialValues?.image ?? "")
|
|
||||||
let topics = $state(initialValues?.topics ?? [])
|
|
||||||
let content = $state(initialValues?.content ?? "")
|
|
||||||
|
|
||||||
const onChange = (json: object) => {
|
|
||||||
content = json
|
|
||||||
}
|
|
||||||
|
|
||||||
const editor = makeEditor({
|
|
||||||
url,
|
|
||||||
submit,
|
|
||||||
uploading,
|
|
||||||
onChange,
|
|
||||||
placeholder: "Write your article...",
|
|
||||||
content,
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
draftKey.update({d, title, summary, image, topics, content})
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SpaceBar {back}>
|
<ArticleForm {url} {h} {shareToChat}>
|
||||||
{#snippet leading()}
|
{#snippet header()}
|
||||||
<Icon icon={DocumentText} />
|
|
||||||
{/snippet}
|
|
||||||
{#snippet title()}
|
|
||||||
<strong>Write an Article</strong>
|
<strong>Write an Article</strong>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
</ArticleForm>
|
||||||
<Button
|
|
||||||
class="button button-primary button-sm"
|
|
||||||
onclick={submit}
|
|
||||||
disabled={$uploading || uploadingCover || loading}>
|
|
||||||
<Spinner {loading}>Publish</Spinner>
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
|
||||||
</SpaceBar>
|
|
||||||
|
|
||||||
<!-- column flex: justify-center would put the top of a tall page out of scroll reach -->
|
|
||||||
<PageContent class="items-center">
|
|
||||||
<div class="flex w-full max-w-3xl flex-col gap-6">
|
|
||||||
<input
|
|
||||||
id={coverInputId}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
||||||
onchange={onCoverChange}
|
|
||||||
class="hidden" />
|
|
||||||
{#if image}
|
|
||||||
<div class="relative">
|
|
||||||
<img src={image} alt="Article cover" class="h-56 w-full rounded-2xl object-cover" />
|
|
||||||
<Button
|
|
||||||
data-tip="Remove cover image"
|
|
||||||
aria-label="Remove cover image"
|
|
||||||
class="tip tip-left button button-neutral button-sm button-circle absolute right-2 top-2"
|
|
||||||
onclick={clearCover}>
|
|
||||||
<Icon icon={CloseCircle} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<label
|
|
||||||
for={coverInputId}
|
|
||||||
class="flex h-28 cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed border-line text-content-muted transition-colors hover:text-content">
|
|
||||||
{#if uploadingCover}
|
|
||||||
<Spinner loading>Uploading cover image...</Spinner>
|
|
||||||
{:else}
|
|
||||||
<Icon icon={GallerySend} />
|
|
||||||
Add a cover image
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
bind:this={titleInput}
|
|
||||||
bind:value={title}
|
|
||||||
class="bg-transparent text-3xl font-bold outline-none placeholder:text-content-muted"
|
|
||||||
type="text"
|
|
||||||
placeholder="Title" />
|
|
||||||
<input
|
|
||||||
bind:value={summary}
|
|
||||||
class="placeholder:text-content-muted bg-transparent text-lg outline-none"
|
|
||||||
type="text"
|
|
||||||
placeholder="Add a one line teaser for the article list" />
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="article-editor note-editor">
|
|
||||||
<EditorContent {editor} />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
data-tip="Add an image"
|
|
||||||
class="tip tip-left absolute bottom-1 right-2"
|
|
||||||
onclick={selectFiles}
|
|
||||||
disabled={loading}>
|
|
||||||
{#if $uploading}
|
|
||||||
<Spinner size="xs" />
|
|
||||||
{:else}
|
|
||||||
<Icon icon={Paperclip} size={3} />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Field>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Topics</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<TopicMultiSelect bind:value={topics} />
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</PageContent>
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
import {onDestroy} from "svelte"
|
||||||
import {derived, readable} from "svelte/store"
|
import {derived, readable} from "svelte/store"
|
||||||
import {sleep} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getCommentFiltersForRoot} from "@welshman/util"
|
import {getCommentFiltersForRoot} from "@welshman/util"
|
||||||
import {TimeEvent} from "@welshman/domain"
|
import {TimeEvent} from "@welshman/domain"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
|
||||||
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
|
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
|
||||||
import type {SegmentedOption} from "@lib/components/SegmentedButtons.svelte"
|
import type {SegmentedOption} from "@lib/components/SegmentedButtons.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
|
|
@ -127,12 +126,6 @@
|
||||||
<CalendarEventPeople {url} event={$event} rsvps={$rsvps} />
|
<CalendarEventPeople {url} event={$event} rsvps={$rsvps} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-center py-20">
|
<LoadingFallback loadingMessage="Loading event..." failureMessage="Failed to load event." />
|
||||||
{#await sleep(5000)}
|
|
||||||
<Spinner loading>Loading event...</Spinner>
|
|
||||||
{:then}
|
|
||||||
<p>Failed to load event.</p>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<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, uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {Classified} from "@welshman/domain"
|
import {Classified} from "@welshman/domain"
|
||||||
import {normalizeTopic} from "@lib/util"
|
import {normalizeTopic} from "@lib/util"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
|
|
@ -72,12 +72,8 @@
|
||||||
</NoteCard>
|
</NoteCard>
|
||||||
<EventComments event={$event} {url} {context} />
|
<EventComments event={$event} {url} {context} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-center py-20">
|
<LoadingFallback
|
||||||
{#await sleep(5000)}
|
loadingMessage="Loading listing..."
|
||||||
<Spinner loading>Loading listing...</Spinner>
|
failureMessage="Failed to load classified listing." />
|
||||||
{:then}
|
|
||||||
<p>Failed to load classified listing.</p>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy, onMount} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
import {derived, readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
import {equals, now, pluck, sortBy, spec} from "@welshman/lib"
|
import {equals, pluck, 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} from "@welshman/util"
|
import {ZAP_GOAL} from "@welshman/util"
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
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 SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
|
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
|
||||||
|
import type {SegmentedOption} from "@lib/components/SegmentedButtons.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 SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
|
|
@ -23,8 +24,8 @@
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
import {makeCommentFilter, partitionByActivity} from "@app/content"
|
import {makeCommentFilter, partitionByActivity} 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 {deriveGoalProgressById, filterGoals, getGoalTotals, sortGoals} from "@app/goals"
|
||||||
import type {GoalProgress} from "@app/goals"
|
import type {GoalFilter, GoalSort} from "@app/goals"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import type {PageProps} from "./$types"
|
import type {PageProps} from "./$types"
|
||||||
|
|
||||||
|
|
@ -35,7 +36,7 @@
|
||||||
|
|
||||||
onDestroy(context.cleanup)
|
onDestroy(context.cleanup)
|
||||||
|
|
||||||
const tabs = [
|
const tabs: SegmentedOption<GoalFilter>[] = [
|
||||||
{value: "all", label: "All"},
|
{value: "all", label: "All"},
|
||||||
{value: "live", label: "Live"},
|
{value: "live", label: "Live"},
|
||||||
{value: "funded", label: "Funded"},
|
{value: "funded", label: "Funded"},
|
||||||
|
|
@ -51,93 +52,26 @@
|
||||||
|
|
||||||
const createGoal = () => pushModal(GoalCreate, {url})
|
const createGoal = () => pushModal(GoalCreate, {url})
|
||||||
|
|
||||||
let tab = $state("all")
|
let tab: GoalFilter = $state("all")
|
||||||
let sort = $state("active")
|
let sort: GoalSort = $state("active")
|
||||||
|
|
||||||
// progressById resubscribes every goal's zap receipts, so these change only with the goal set.
|
// progressById resubscribes every goal's zap receipts, so goals change only with the goal set.
|
||||||
let goals: TrustedEvent[] = $state([])
|
let goals: TrustedEvent[] = $state([])
|
||||||
let progressStoresById: Map<string, Readable<GoalProgress>> = $state(new Map())
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const next = $events.filter(spec({kind: ZAP_GOAL}))
|
const next = $events.filter(spec({kind: ZAP_GOAL}))
|
||||||
|
|
||||||
if (!equals(pluck("id", next), pluck("id", goals))) {
|
if (!equals(pluck("id", next), pluck("id", goals))) {
|
||||||
goals = next
|
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(deriveGoalProgressById(goals, url))
|
||||||
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>())
|
|
||||||
})
|
|
||||||
|
|
||||||
const {activeAt} = $derived(partitionByActivity(ZAP_GOAL, $events))
|
const {activeAt} = $derived(partitionByActivity(ZAP_GOAL, $events))
|
||||||
|
const totals = $derived(getGoalTotals($progressById))
|
||||||
const totals = $derived.by(() => {
|
const items = $derived(
|
||||||
const backers = new Set<string>()
|
sortGoals(sort, filterGoals(tab, goals, $progressById), $progressById, activeAt),
|
||||||
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(() => {
|
||||||
const feed = makeFeed({
|
const feed = makeFeed({
|
||||||
|
|
@ -207,11 +141,7 @@
|
||||||
<div class="columns-1 gap-2 sm:gap-4 lg:columns-2 2xl:columns-3">
|
<div class="columns-1 gap-2 sm:gap-4 lg:columns-2 2xl:columns-3">
|
||||||
{#each items as event (event.id)}
|
{#each items as event (event.id)}
|
||||||
<div in:fly class="mb-2 min-w-0 break-inside-avoid sm:mb-4">
|
<div in:fly class="mb-2 min-w-0 break-inside-avoid sm:mb-4">
|
||||||
<GoalItem
|
<GoalItem {url} {context} event={$state.snapshot(event)} />
|
||||||
{url}
|
|
||||||
{context}
|
|
||||||
event={$state.snapshot(event)}
|
|
||||||
progress={progressStoresById.get(event.id)} />
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<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 {DAY, formatTimestampRelative, now, sleep} from "@welshman/lib"
|
import {DAY, formatTimestampRelative, now} from "@welshman/lib"
|
||||||
import {ZapGoal} from "@welshman/domain"
|
import {ZapGoal} from "@welshman/domain"
|
||||||
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 PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
|
|
@ -116,12 +116,8 @@
|
||||||
<GoalSupporters {url} event={$event} progress={$progress} />
|
<GoalSupporters {url} event={$event} progress={$progress} />
|
||||||
<EventComments event={$event} {url} {context} />
|
<EventComments event={$event} {url} {context} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-center py-20">
|
<LoadingFallback
|
||||||
{#await sleep(5000)}
|
loadingMessage="Loading funding goal..."
|
||||||
<Spinner loading>Loading funding goal...</Spinner>
|
failureMessage="Failed to load funding goal." />
|
||||||
{:then}
|
|
||||||
<p>Failed to load funding goal.</p>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy, onMount} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
import {sleep} from "@welshman/lib"
|
|
||||||
import {POLL, POLL_RESPONSE} from "@welshman/util"
|
import {POLL, POLL_RESPONSE} from "@welshman/util"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
|
|
@ -59,12 +58,6 @@
|
||||||
</NoteCard>
|
</NoteCard>
|
||||||
<EventComments event={$event} {url} {context} />
|
<EventComments event={$event} {url} {context} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-center py-20">
|
<LoadingFallback loadingMessage="Loading poll..." failureMessage="Failed to load poll." />
|
||||||
{#await sleep(5000)}
|
|
||||||
<Spinner loading>Loading poll...</Spinner>
|
|
||||||
{:then}
|
|
||||||
<p>Failed to load poll.</p>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
import {onDestroy} from "svelte"
|
||||||
import {page} from "$app/state"
|
import {page} from "$app/state"
|
||||||
import {sleep, spec} from "@welshman/lib"
|
import {spec} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getCommentFiltersForRoot} from "@welshman/util"
|
import {getCommentFiltersForRoot} from "@welshman/util"
|
||||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import {fade, fly} from "@lib/transition"
|
import {fade, fly} from "@lib/transition"
|
||||||
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 LoadingFallback from "@lib/components/LoadingFallback.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ThreadPost from "@app/components/ThreadPost.svelte"
|
import ThreadPost from "@app/components/ThreadPost.svelte"
|
||||||
import ThreadSummaryBar from "@app/components/ThreadSummaryBar.svelte"
|
import ThreadSummaryBar from "@app/components/ThreadSummaryBar.svelte"
|
||||||
|
|
@ -216,13 +216,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-center py-20">
|
<LoadingFallback loadingMessage="Loading thread..." failureMessage="Failed to load thread." />
|
||||||
{#await sleep(5000)}
|
|
||||||
<Spinner loading>Loading thread...</Spinner>
|
|
||||||
{:then}
|
|
||||||
<p>Failed to load thread.</p>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
{#if $event && scrolledPastRoot}
|
{#if $event && scrolledPastRoot}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue