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]).$`.
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| `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`) |
|
||||
| `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 |
|
||||
|
||||
Each is exposed with `usePlugin`. `Statuses` is the minimal shape:
|
||||
|
|
|
|||
|
|
@ -229,8 +229,8 @@ lives there:
|
|||
`ModalSubtitle`, `ModalFooter`, `Confirm`
|
||||
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
|
||||
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
|
||||
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`, `Cv`,
|
||||
`VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
||||
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `LoadingFallback`, `Tooltip`,
|
||||
`Tippy`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
||||
- 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
|
||||
|
|
@ -324,14 +324,13 @@ data every page needs lives in `src/app/sync.ts`.
|
|||
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
|
||||
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
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading listing...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load classified listing.</p>
|
||||
{/await}
|
||||
<LoadingFallback
|
||||
loadingMessage="Loading listing..."
|
||||
failureMessage="Failed to load classified listing." />
|
||||
```
|
||||
|
||||
**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`
|
||||
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`
|
||||
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`.
|
||||
|
||||
|
|
|
|||
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">
|
||||
import type {Readable} from "svelte/store"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
|
|
@ -9,7 +8,6 @@
|
|||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ContentActions from "@app/components/ContentActions.svelte"
|
||||
import type {FeedContext} from "@app/feeds"
|
||||
import type {GoalProgress} from "@app/goals"
|
||||
import GoalSummary from "@app/components/GoalSummary.svelte"
|
||||
import RoomLink from "@app/components/RoomLink.svelte"
|
||||
import UnreadDot from "@app/components/UnreadDot.svelte"
|
||||
|
|
@ -19,10 +17,9 @@
|
|||
url: string
|
||||
event: TrustedEvent
|
||||
context: FeedContext
|
||||
progress?: Readable<GoalProgress>
|
||||
}
|
||||
|
||||
const {url, event, context, progress}: Props = $props()
|
||||
const {url, event, context}: Props = $props()
|
||||
|
||||
const goal = reader(ZapGoal)(event)
|
||||
|
||||
|
|
@ -49,7 +46,7 @@
|
|||
maxLength={180} />
|
||||
</div>
|
||||
{/if}
|
||||
<GoalSummary {url} {event} {progress} />
|
||||
<GoalSummary {url} {event} />
|
||||
<div class="flex w-full min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span class="min-w-0 py-1 text-sm text-content-muted">
|
||||
by <ProfileLink pubkey={event.pubkey} {url} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import type {Readable} from "svelte/store"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -10,19 +9,16 @@
|
|||
import GoalStatus from "@app/components/GoalStatus.svelte"
|
||||
import {ENABLE_ZAPS} from "@app/env"
|
||||
import {deriveGoalProgress} from "@app/goals"
|
||||
import type {GoalProgress} from "@app/goals"
|
||||
|
||||
type Props = {
|
||||
url?: string
|
||||
event: TrustedEvent
|
||||
progress?: Readable<GoalProgress>
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {url, event, 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 = providedProgress ?? deriveGoalProgress(event, url)
|
||||
const progress = deriveGoalProgress(event, url)
|
||||
|
||||
const backers = $derived($progress.backers.length)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy} from "svelte"
|
||||
import {readable} from "svelte/store"
|
||||
import {sleep} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Modal from "@lib/components/Modal.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 CommentTree from "@app/components/CommentTree.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
|
|
@ -69,13 +68,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading note...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load note.</p>
|
||||
{/await}
|
||||
</div>
|
||||
<LoadingFallback loadingMessage="Loading note..." failureMessage="Failed to load note." />
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</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 {now, removeUndefined} from "@welshman/lib"
|
||||
import {now, removeUndefined, sortBy} from "@welshman/lib"
|
||||
import {fromMsats} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import type {Zap} from "@welshman/domain"
|
||||
import {ZapGoals} from "@welshman/app"
|
||||
import {app, reader} from "@app/core"
|
||||
import {Domain, ZapGoals} from "@welshman/app"
|
||||
import type {IApp} from "@welshman/app"
|
||||
import {app} from "@app/core"
|
||||
|
||||
export type GoalProgress = {
|
||||
zaps: Zap[]
|
||||
|
|
@ -19,22 +20,118 @@ export type GoalProgress = {
|
|||
closedAt?: number
|
||||
}
|
||||
|
||||
// Welshman tallies a goal in millisats, and comparing there avoids funding a rounded sat early.
|
||||
export const deriveGoalProgress = (event: TrustedEvent, url?: string): Readable<GoalProgress> => {
|
||||
const closedAt = reader(ZapGoal)(event).closedAt()
|
||||
const progress = app
|
||||
.get()
|
||||
.use(ZapGoals)
|
||||
.progress(event, removeUndefined([url])).$
|
||||
export type GoalFilter = "all" | "live" | "funded" | "ended"
|
||||
|
||||
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 type GoalSort = "active" | "new" | "progress" | "ending"
|
||||
|
||||
export class GoalProgresses {
|
||||
private progressByKey = new Map<string, Readable<GoalProgress>>()
|
||||
|
||||
constructor(private readonly app: IApp) {}
|
||||
|
||||
forGoal = (event: TrustedEvent, url?: string) => {
|
||||
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 {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 {
|
||||
ROOM_DELETE_EVENT,
|
||||
|
|
@ -15,7 +15,8 @@ import {
|
|||
} from "@welshman/util"
|
||||
import type {EventContent, TrustedEvent} from "@welshman/util"
|
||||
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 type {Room, RoomMeta} from "@welshman/app"
|
||||
import {
|
||||
|
|
@ -266,6 +267,45 @@ export const deriveUserRoomMembershipStatus = (url: string, h: string) =>
|
|||
([$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 = () =>
|
||||
derived(
|
||||
[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">
|
||||
import {onMount} from "svelte"
|
||||
import {derived as _derived} from "svelte/store"
|
||||
import {addToMapKey, dec, sleep} from "@welshman/lib"
|
||||
import {sleep} from "@welshman/lib"
|
||||
import {ROOMS} from "@welshman/util"
|
||||
import type {Relay} from "@welshman/domain"
|
||||
import {throttled} from "@welshman/store"
|
||||
import {Sync, createSearch} from "@welshman/app"
|
||||
import {Sync} from "@welshman/app"
|
||||
import {createScroller, isMobile} from "@lib/html"
|
||||
import {fly} from "@lib/transition"
|
||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||
|
|
@ -19,75 +16,26 @@
|
|||
import Divider from "@lib/components/Divider.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ContentSearch from "@lib/components/ContentSearch.svelte"
|
||||
import RelaySummary from "@app/components/RelaySummary.svelte"
|
||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
|
||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||
import {app, relays, roomLists, user} from "@app/core"
|
||||
import {userSpaceUrls} from "@app/rooms"
|
||||
import SpaceResult from "@app/components/SpaceResult.svelte"
|
||||
import {app, roomLists, user} from "@app/core"
|
||||
import {deriveSpaceSearch, userSpaceUrls} from "@app/rooms"
|
||||
import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env"
|
||||
import {bootstrapPubkeys} from "@app/social"
|
||||
import {parseInviteLink} from "@app/access"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {goToSpace} from "@app/routes"
|
||||
|
||||
const addSpace = () => pushModal(SpaceAdd)
|
||||
|
||||
const userSpacesLoaded = $roomLists.load($user.pubkey)
|
||||
|
||||
// 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})
|
||||
}
|
||||
}
|
||||
const spaceSearch = deriveSpaceSearch()
|
||||
|
||||
let term = $state("")
|
||||
let limit = $state(20)
|
||||
let element: Element
|
||||
|
||||
const inviteData = $derived(parseInviteLink(term))
|
||||
const searchResults = $derived($relaySearch.searchOptions(term))
|
||||
const searchResults = $derived($spaceSearch.searchOptions(term))
|
||||
const userSpaceSet = $derived(new Set($userSpaceUrls))
|
||||
const otherSpaces = $derived(
|
||||
searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url),
|
||||
|
|
@ -146,9 +94,7 @@
|
|||
{#snippet content()}
|
||||
<div class="flex flex-col gap-2" bind:this={element}>
|
||||
{#each PLATFORM_RELAYS as url (url)}
|
||||
<Button class="card card-interactive" onclick={() => openSpace(url)}>
|
||||
<RelaySummary {url} />
|
||||
</Button>
|
||||
<SpaceResult {url} />
|
||||
{:else}
|
||||
{#await userSpacesLoaded}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
|
|
@ -159,20 +105,14 @@
|
|||
{#if inviteData}
|
||||
<Divider>Search results</Divider>
|
||||
{#key inviteData.url}
|
||||
<Button
|
||||
class="card card-interactive"
|
||||
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
|
||||
<RelaySummary url={inviteData.url} />
|
||||
</Button>
|
||||
<SpaceResult url={inviteData.url} invite={inviteData.claim ? term : undefined} />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if otherSpaces.length > 0}
|
||||
<Divider>Browse Spaces</Divider>
|
||||
{/if}
|
||||
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
|
||||
<Button class="card card-interactive" onclick={() => openSpace(relay.url)}>
|
||||
<RelaySummary url={relay.url} />
|
||||
</Button>
|
||||
<SpaceResult url={relay.url} />
|
||||
{/each}
|
||||
<div class="flex justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {formatTimestamp, sleep, uniq} from "@welshman/lib"
|
||||
import {formatTimestamp, uniq} from "@welshman/lib"
|
||||
import {Article} from "@welshman/domain"
|
||||
import Link from "@lib/components/Link.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 SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import ContentMarkdown from "@app/components/ContentMarkdown.svelte"
|
||||
|
|
@ -134,12 +134,9 @@
|
|||
<ArticleAuthorSidebar {url} event={$event} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex grow justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading article...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load article.</p>
|
||||
{/await}
|
||||
</div>
|
||||
<LoadingFallback
|
||||
class="grow"
|
||||
loadingMessage="Loading article..."
|
||||
failureMessage="Failed to load article." />
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
|
|
|||
|
|
@ -1,267 +1,18 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {page} from "$app/stores"
|
||||
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 ArticleForm from "@app/components/ArticleForm.svelte"
|
||||
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"
|
||||
|
||||
type Values = {
|
||||
d?: string
|
||||
content?: string | object
|
||||
title?: string
|
||||
summary?: string
|
||||
image?: string
|
||||
topics?: string[]
|
||||
}
|
||||
|
||||
const {params}: PageProps = $props()
|
||||
|
||||
const url = decodeRelay(params.relay)
|
||||
|
||||
const h = $page.url.searchParams.get("h") ?? undefined
|
||||
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>
|
||||
|
||||
<SpaceBar {back}>
|
||||
{#snippet leading()}
|
||||
<Icon icon={DocumentText} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<ArticleForm {url} {h} {shareToChat}>
|
||||
{#snippet header()}
|
||||
<strong>Write an Article</strong>
|
||||
{/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>
|
||||
</ArticleForm>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy} from "svelte"
|
||||
import {derived, readable} from "svelte/store"
|
||||
import {sleep} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getCommentFiltersForRoot} from "@welshman/util"
|
||||
import {TimeEvent} from "@welshman/domain"
|
||||
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 type {SegmentedOption} from "@lib/components/SegmentedButtons.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
|
|
@ -127,12 +126,6 @@
|
|||
<CalendarEventPeople {url} event={$event} rsvps={$rsvps} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading event...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load event.</p>
|
||||
{/await}
|
||||
</div>
|
||||
<LoadingFallback loadingMessage="Loading event..." failureMessage="Failed to load event." />
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {sleep, uniq} from "@welshman/lib"
|
||||
import {uniq} from "@welshman/lib"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import Link from "@lib/components/Link.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 NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
|
|
@ -72,12 +72,8 @@
|
|||
</NoteCard>
|
||||
<EventComments event={$event} {url} {context} />
|
||||
{:else}
|
||||
<div class="flex justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading listing...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load classified listing.</p>
|
||||
{/await}
|
||||
</div>
|
||||
<LoadingFallback
|
||||
loadingMessage="Loading listing..."
|
||||
failureMessage="Failed to load classified listing." />
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
import {derived, readable} from "svelte/store"
|
||||
import {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 {TrustedEvent} from "@welshman/util"
|
||||
import {ZAP_GOAL} from "@welshman/util"
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
|
||||
import type {SegmentedOption} from "@lib/components/SegmentedButtons.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
|
|
@ -23,8 +24,8 @@
|
|||
import {decodeRelay} from "@app/relays"
|
||||
import {makeCommentFilter, partitionByActivity} from "@app/content"
|
||||
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
||||
import {deriveGoalProgress} from "@app/goals"
|
||||
import type {GoalProgress} from "@app/goals"
|
||||
import {deriveGoalProgressById, filterGoals, getGoalTotals, sortGoals} from "@app/goals"
|
||||
import type {GoalFilter, GoalSort} from "@app/goals"
|
||||
import {pushModal} from "@app/modal"
|
||||
import type {PageProps} from "./$types"
|
||||
|
||||
|
|
@ -35,7 +36,7 @@
|
|||
|
||||
onDestroy(context.cleanup)
|
||||
|
||||
const tabs = [
|
||||
const tabs: SegmentedOption<GoalFilter>[] = [
|
||||
{value: "all", label: "All"},
|
||||
{value: "live", label: "Live"},
|
||||
{value: "funded", label: "Funded"},
|
||||
|
|
@ -51,93 +52,26 @@
|
|||
|
||||
const createGoal = () => pushModal(GoalCreate, {url})
|
||||
|
||||
let tab = $state("all")
|
||||
let sort = $state("active")
|
||||
let tab: GoalFilter = $state("all")
|
||||
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 progressStoresById: Map<string, Readable<GoalProgress>> = $state(new Map())
|
||||
|
||||
$effect(() => {
|
||||
const next = $events.filter(spec({kind: ZAP_GOAL}))
|
||||
|
||||
if (!equals(pluck("id", next), pluck("id", goals))) {
|
||||
goals = next
|
||||
progressStoresById = new Map(
|
||||
next.map(event => [
|
||||
event.id,
|
||||
progressStoresById.get(event.id) ?? deriveGoalProgress(event, url),
|
||||
]),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// `derived` over no stores never fires, so an empty board needs a store of its own.
|
||||
const progressById = $derived.by(() => {
|
||||
const ids = Array.from(progressStoresById.keys())
|
||||
const stores = Array.from(progressStoresById.values())
|
||||
|
||||
return stores.length > 0
|
||||
? derived(stores, progresses => new Map(progresses.map((progress, i) => [ids[i]!, progress])))
|
||||
: readable(new Map<string, GoalProgress>())
|
||||
})
|
||||
|
||||
const progressById = $derived(deriveGoalProgressById(goals, url))
|
||||
const {activeAt} = $derived(partitionByActivity(ZAP_GOAL, $events))
|
||||
|
||||
const totals = $derived.by(() => {
|
||||
const backers = new Set<string>()
|
||||
let raised = 0
|
||||
let funded = 0
|
||||
|
||||
for (const progress of $progressById.values()) {
|
||||
raised += progress.raised
|
||||
funded += progress.isFunded ? 1 : 0
|
||||
|
||||
for (const backer of progress.backers) {
|
||||
backers.add(backer)
|
||||
}
|
||||
}
|
||||
|
||||
return {raised, funded, backers: backers.size}
|
||||
})
|
||||
|
||||
const items = $derived.by(() => {
|
||||
const matches = goals.filter(event => {
|
||||
const progress = $progressById.get(event.id)
|
||||
|
||||
if (tab === "live") {
|
||||
return Boolean(progress && !progress.isEnded && !progress.isFunded)
|
||||
}
|
||||
|
||||
if (tab === "funded") {
|
||||
return Boolean(progress?.isFunded)
|
||||
}
|
||||
|
||||
if (tab === "ended") {
|
||||
return Boolean(progress?.isEnded)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (sort === "new") {
|
||||
return sortBy(event => -event.created_at, matches)
|
||||
}
|
||||
|
||||
if (sort === "progress") {
|
||||
return sortBy(event => -($progressById.get(event.id)?.percent ?? 0), matches)
|
||||
}
|
||||
|
||||
if (sort === "ending") {
|
||||
return sortBy(event => {
|
||||
const closedAt = $progressById.get(event.id)?.closedAt
|
||||
|
||||
return closedAt && closedAt > now() ? closedAt : Infinity
|
||||
}, matches)
|
||||
}
|
||||
|
||||
return sortBy(event => -(activeAt.get(event.id) ?? event.created_at), matches)
|
||||
})
|
||||
const totals = $derived(getGoalTotals($progressById))
|
||||
const items = $derived(
|
||||
sortGoals(sort, filterGoals(tab, goals, $progressById), $progressById, activeAt),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
const feed = makeFeed({
|
||||
|
|
@ -207,11 +141,7 @@
|
|||
<div class="columns-1 gap-2 sm:gap-4 lg:columns-2 2xl:columns-3">
|
||||
{#each items as event (event.id)}
|
||||
<div in:fly class="mb-2 min-w-0 break-inside-avoid sm:mb-4">
|
||||
<GoalItem
|
||||
{url}
|
||||
{context}
|
||||
event={$state.snapshot(event)}
|
||||
progress={progressStoresById.get(event.id)} />
|
||||
<GoalItem {url} {context} event={$state.snapshot(event)} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy} from "svelte"
|
||||
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 Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import LoadingFallback from "@lib/components/LoadingFallback.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
|
|
@ -116,12 +116,8 @@
|
|||
<GoalSupporters {url} event={$event} progress={$progress} />
|
||||
<EventComments event={$event} {url} {context} />
|
||||
{:else}
|
||||
<div class="flex justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading funding goal...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load funding goal.</p>
|
||||
{/await}
|
||||
</div>
|
||||
<LoadingFallback
|
||||
loadingMessage="Loading funding goal..."
|
||||
failureMessage="Failed to load funding goal." />
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
import {sleep} from "@welshman/lib"
|
||||
import {POLL, POLL_RESPONSE} from "@welshman/util"
|
||||
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 NoteCard from "@app/components/NoteCard.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
|
|
@ -59,12 +58,6 @@
|
|||
</NoteCard>
|
||||
<EventComments event={$event} {url} {context} />
|
||||
{:else}
|
||||
<div class="flex justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading poll...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load poll.</p>
|
||||
{/await}
|
||||
</div>
|
||||
<LoadingFallback loadingMessage="Loading poll..." failureMessage="Failed to load poll." />
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy} from "svelte"
|
||||
import {page} from "$app/state"
|
||||
import {sleep, spec} from "@welshman/lib"
|
||||
import {spec} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getCommentFiltersForRoot} from "@welshman/util"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
import {fade, fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.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 ThreadPost from "@app/components/ThreadPost.svelte"
|
||||
import ThreadSummaryBar from "@app/components/ThreadSummaryBar.svelte"
|
||||
|
|
@ -216,13 +216,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading thread...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load thread.</p>
|
||||
{/await}
|
||||
</div>
|
||||
<LoadingFallback loadingMessage="Loading thread..." failureMessage="Failed to load thread." />
|
||||
{/if}
|
||||
</PageContent>
|
||||
{#if $event && scrolledPastRoot}
|
||||
|
|
|
|||
Loading…
Reference in a new issue