Lay the home network out as a masonry of cards that loads as you scroll
This commit is contained in:
parent
b50d35e5fa
commit
4f32f6a3df
3 changed files with 86 additions and 46 deletions
|
|
@ -7,8 +7,9 @@
|
||||||
import {Feeds} from "@welshman/app"
|
import {Feeds} from "@welshman/app"
|
||||||
import {Scope, feedFromFilter, makeIntersectionFeed, makeScopeFeed} from "@welshman/feeds"
|
import {Scope, feedFromFilter, makeIntersectionFeed, makeScopeFeed} from "@welshman/feeds"
|
||||||
import Planet from "@assets/icons/planet.svg?dataurl"
|
import Planet from "@assets/icons/planet.svg?dataurl"
|
||||||
|
import {createScroller} from "@lib/html"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Masonry from "@lib/components/Masonry.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import HomeSection from "@app/components/HomeSection.svelte"
|
import HomeSection from "@app/components/HomeSection.svelte"
|
||||||
import NoteItem from "@app/components/NoteItem.svelte"
|
import NoteItem from "@app/components/NoteItem.svelte"
|
||||||
|
|
@ -28,61 +29,83 @@
|
||||||
feed: makeIntersectionFeed(makeScopeFeed(Scope.Follows), feedFromFilter({kinds: [NOTE]})),
|
feed: makeIntersectionFeed(makeScopeFeed(Scope.Follows), feedFromFilter({kinds: [NOTE]})),
|
||||||
onEvent: (event: TrustedEvent) => {
|
onEvent: (event: TrustedEvent) => {
|
||||||
if (getReplyTags(event.tags).replies.length === 0) {
|
if (getReplyTags(event.tags).replies.length === 0) {
|
||||||
events = uniqBy(
|
buffer.push(event)
|
||||||
event => event.id,
|
|
||||||
sortBy(event => -event.created_at, [...events, event]),
|
|
||||||
)
|
|
||||||
context.add(event)
|
context.add(event)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let element: Element | undefined = $state()
|
||||||
let events = $state<TrustedEvent[]>([])
|
let events = $state<TrustedEvent[]>([])
|
||||||
let limit = $state(PAGE_SIZE)
|
let loading = $state(false)
|
||||||
let loading = $state(true)
|
let caughtUp = $state(false)
|
||||||
|
let buffer: TrustedEvent[] = []
|
||||||
|
|
||||||
const load = () => {
|
const isEmpty = $derived(caughtUp && events.length === 0)
|
||||||
loading = true
|
|
||||||
controller
|
|
||||||
.load(PAGE_SIZE * 4)
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
.finally(() => (loading = false))
|
|
||||||
}
|
|
||||||
|
|
||||||
const showMore = () => {
|
const load = async () => {
|
||||||
limit += PAGE_SIZE
|
if (!loading) {
|
||||||
|
loading = true
|
||||||
|
|
||||||
if (events.length < limit + PAGE_SIZE) {
|
try {
|
||||||
load()
|
await controller.load(PAGE_SIZE * 4)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
caughtUp = buffer.length === 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(load)
|
onMount(() => {
|
||||||
|
const scroller = createScroller({
|
||||||
|
element: element!,
|
||||||
|
delay: 300,
|
||||||
|
threshold: 3000,
|
||||||
|
onScroll: () => {
|
||||||
|
buffer = uniqBy(
|
||||||
|
e => e.id,
|
||||||
|
sortBy(e => -e.created_at, buffer),
|
||||||
|
)
|
||||||
|
|
||||||
|
events = uniqBy(e => e.id, [...events, ...buffer.splice(0, PAGE_SIZE)])
|
||||||
|
|
||||||
|
if (buffer.length < PAGE_SIZE * 4) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return scroller.stop
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<HomeSection title="Network" icon={Planet}>
|
<HomeSection title="Network" icon={Planet}>
|
||||||
{#if events.length === 0}
|
<div class="flex flex-col gap-3 px-4 pb-4" bind:this={element}>
|
||||||
{#if loading}
|
{#if isEmpty}
|
||||||
<div class="flex justify-center px-4 pb-8">
|
<div class="flex flex-col items-center gap-3 pb-4 text-center">
|
||||||
<Spinner {loading}>Looking for notes from people you follow…</Spinner>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col items-center gap-3 px-4 pb-8 text-center">
|
|
||||||
<p class="font-medium">Follow a few people to fill this out</p>
|
<p class="font-medium">Follow a few people to fill this out</p>
|
||||||
<p class="max-w-md text-sm opacity-75">
|
<p class="max-w-md text-sm opacity-75">
|
||||||
Notes from the people you follow collect here. Spaces are a good place to find some.
|
Notes from the people you follow collect here. Spaces are a good place to find some.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/spaces" class="button button-neutral button-sm">Browse spaces</Link>
|
<Link href="/spaces" class="button button-neutral button-sm">Browse spaces</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if events.length === 0}
|
||||||
|
<div class="flex justify-center pb-4">
|
||||||
|
<Spinner loading>Looking for notes from people you follow…</Spinner>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Masonry items={events} getKey={event => event.id} columnWidth={80} maxColumns={2} gap={3}>
|
||||||
|
{#snippet child(event)}
|
||||||
|
<NoteItem {event} {context} />
|
||||||
|
{/snippet}
|
||||||
|
</Masonry>
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-4">
|
||||||
|
<Spinner loading />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
</div>
|
||||||
<div class="flex flex-col divide-y divide-line border-t border-line">
|
|
||||||
{#each events.slice(0, limit) as event (event.id)}
|
|
||||||
<NoteItem {event} {context} class="cv px-4 py-3" />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center border-t border-line p-3">
|
|
||||||
<Button class="button button-neutral button-sm" onclick={showMore}>Show more</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</HomeSection>
|
</HomeSection>
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,40 @@
|
||||||
items: T[]
|
items: T[]
|
||||||
child: Snippet<[T]>
|
child: Snippet<[T]>
|
||||||
getKey: (item: T) => PropertyKey
|
getKey: (item: T) => PropertyKey
|
||||||
// Minimum column width, in tailwind spacing units (1 = 0.25rem). The browser
|
|
||||||
// fits as many columns as the container allows, so the layout stays
|
|
||||||
// responsive without media queries.
|
|
||||||
columnWidth?: number
|
columnWidth?: number
|
||||||
// Space between columns and between stacked items, in tailwind units.
|
maxColumns?: number
|
||||||
gap?: number
|
gap?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const {items, child, getKey, columnWidth = 72, gap = 3}: Props = $props()
|
// columnWidth and gap are tailwind spacing units, not pixels.
|
||||||
|
const {items, child, getKey, columnWidth = 72, maxColumns = Infinity, gap = 3}: Props = $props()
|
||||||
|
|
||||||
|
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
||||||
|
|
||||||
const rem = (units: number) => `${units * 0.25}rem`
|
const rem = (units: number) => `${units * 0.25}rem`
|
||||||
|
|
||||||
|
let width = $state(0)
|
||||||
|
|
||||||
|
const count = $derived(
|
||||||
|
Math.min(maxColumns, Math.max(1, Math.floor(width / (columnWidth * 0.25 * rootFontSize)))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Which column an item lands in depends only on its index, so appending to `items` leaves
|
||||||
|
// everything already on screen where it was. CSS multi-column balances the columns instead,
|
||||||
|
// which reshuffles the whole list every time an infinite scroll loads another page.
|
||||||
|
const columns = $derived(
|
||||||
|
Array.from({length: count}, (_, column) =>
|
||||||
|
items.filter((_, index) => index % count === column),
|
||||||
|
),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div style="column-width: {rem(columnWidth)}; column-gap: {rem(gap)};">
|
<div class="flex items-start" style="gap: {rem(gap)};" bind:clientWidth={width}>
|
||||||
{#each items as item (getKey(item))}
|
{#each columns as column, index (index)}
|
||||||
<div class="break-inside-avoid" style="margin-bottom: {rem(gap)};">
|
<div class="flex min-w-0 flex-1 flex-col" style="gap: {rem(gap)};">
|
||||||
{@render child(item)}
|
{#each column as item (getKey(item))}
|
||||||
|
{@render child(item)}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
</div>
|
</div>
|
||||||
</PageBar>
|
</PageBar>
|
||||||
<PageContent noPad class="flex flex-col bg-surface">
|
<PageContent noPad class="flex flex-col bg-surface">
|
||||||
<div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
|
<div class="flex min-w-0 flex-1 flex-col lg:flex-row lg:items-stretch">
|
||||||
<div class="flex min-w-0 flex-col lg:flex-[2] lg:border-r lg:border-line">
|
<div class="flex min-w-0 flex-col lg:flex-[2] lg:border-r lg:border-line">
|
||||||
<HomeInbox />
|
<HomeInbox />
|
||||||
<HomeActivity />
|
<HomeActivity />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue