Redesign classifieds as a browsable marketplace (#383)
This commit is contained in:
parent
56ee9b4c44
commit
e6ce3e5ec8
12 changed files with 375 additions and 159 deletions
|
|
@ -685,7 +685,8 @@ test("US-051 post, edit, and close out a classified listing", async ({seed, as})
|
|||
const listing = page.getByRole("link").filter({hasText: "Vintage Road Bike"})
|
||||
|
||||
await expect(listing).toBeVisible()
|
||||
await expect(listing).toContainText("1200")
|
||||
// The card groups thousands, and which separator depends on the browser locale.
|
||||
await expect(listing).toContainText(/1[,.\s]?200/)
|
||||
await expect(listing).toContainText("#bicycles")
|
||||
await expect(listing.locator('img[src^="https://space.test/"]')).toBeVisible()
|
||||
|
||||
|
|
|
|||
74
src/app/classifieds.ts
Normal file
74
src/app/classifieds.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import {max, partition, pushToMapKey, sortBy, spec} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {CLASSIFIED, tagSpec, tagValue} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import {reader} from "@app/core"
|
||||
|
||||
export const CLASSIFIED_STATUS_TABS = [
|
||||
{value: "all", label: "All"},
|
||||
{value: "active", label: "Active"},
|
||||
{value: "sold", label: "Sold"},
|
||||
]
|
||||
|
||||
export const SUGGESTED_TOPICS = ["for sale", "wanted", "job", "service", "free"]
|
||||
|
||||
// Splits a feed into listings and their comments, scoring each listing by last activity.
|
||||
export const partitionListings = (events: TrustedEvent[]) => {
|
||||
const scores = new Map<string, number[]>()
|
||||
const [listings, comments] = partition(spec({kind: CLASSIFIED}), events)
|
||||
|
||||
for (const comment of comments) {
|
||||
const id = tagValue(tagSpec("E"), comment.tags)
|
||||
|
||||
if (id) {
|
||||
pushToMapKey(scores, id, comment.created_at)
|
||||
}
|
||||
}
|
||||
|
||||
const activeAt = new Map<string, number>()
|
||||
|
||||
for (const listing of listings) {
|
||||
activeAt.set(listing.id, max([...(scores.get(listing.id) || []), listing.created_at]))
|
||||
}
|
||||
|
||||
return {listings, activeAt}
|
||||
}
|
||||
|
||||
export const deriveTopicCounts = (events: TrustedEvent[]) => {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
for (const event of events) {
|
||||
for (const topic of reader(Classified)(event).topics() ?? []) {
|
||||
const normalized = normalizeTopic(topic)
|
||||
|
||||
counts.set(normalized, (counts.get(normalized) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(([, count]) => -count, Array.from(counts.entries()))
|
||||
}
|
||||
|
||||
export const getStatus = (event: TrustedEvent) => reader(Classified)(event).status() ?? "active"
|
||||
|
||||
export const matchesTopic = (event: TrustedEvent, topic: string) =>
|
||||
!topic ||
|
||||
Boolean(
|
||||
reader(Classified)(event)
|
||||
.topics()
|
||||
?.some(t => normalizeTopic(t) === topic),
|
||||
)
|
||||
|
||||
export const matchesQuery = (event: TrustedEvent, query: string) => {
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
if (!q) return true
|
||||
|
||||
const classified = reader(Classified)(event)
|
||||
const haystack = [classified.title(), classified.summary(), event.content]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(q)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import {uniq} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {goto} from "$app/navigation"
|
||||
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
|
|
@ -17,7 +18,7 @@
|
|||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
|
||||
import {reader, user} from "@app/core"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {makeClassifiedPath, makeSpacePath} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -26,9 +27,20 @@
|
|||
showRoom?: boolean
|
||||
showActivity?: boolean
|
||||
context: FeedContext
|
||||
// The grid card lays these out itself, so it opts out of both.
|
||||
showTopics?: boolean
|
||||
showStatus?: boolean
|
||||
}
|
||||
|
||||
const {url, event, showRoom, showActivity, context}: Props = $props()
|
||||
const {
|
||||
url,
|
||||
event,
|
||||
showRoom,
|
||||
showActivity,
|
||||
context,
|
||||
showTopics = true,
|
||||
showStatus = true,
|
||||
}: Props = $props()
|
||||
|
||||
// Editing a listing hands this a new version of the event, so every value read off it has to be
|
||||
// recomputed rather than captured when the component was created.
|
||||
|
|
@ -38,27 +50,43 @@
|
|||
|
||||
const editClassified = () => pushModal(ClassifiedEdit, {url, event})
|
||||
|
||||
const filterByTopic = (topic: string) => (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
goto(`${makeClassifiedPath(url)}?topic=${encodeURIComponent(normalizeTopic(topic))}`)
|
||||
}
|
||||
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||
</script>
|
||||
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
<div class="flex grow flex-wrap items-center justify-end gap-2">
|
||||
{#if (h && showRoom) || (showTopics && uniq(topics).length > 0)}
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
{#if showTopics}
|
||||
{#each uniq(topics) as topic (topic)}
|
||||
<button
|
||||
type="button"
|
||||
class="badge badge-neutral badge-sm cursor-pointer font-normal"
|
||||
onclick={filterByTopic(topic)}>
|
||||
#{normalizeTopic(topic)}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<span class="h-6 w-px shrink-0 bg-line"></span>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-wrap gap-2">
|
||||
{#each uniq(topics) as topic (topic)}
|
||||
<button type="button" class="button button-xs rounded-full font-normal">
|
||||
#{normalizeTopic(topic)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<ThunkStatusOrDeleted {event} {context}>
|
||||
{#snippet status()}
|
||||
<ClassifiedStatus {event} />
|
||||
{#if showStatus}
|
||||
<ClassifiedStatus {event} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
<ReactionSummary
|
||||
{url}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {removeUndefined, randomId, uniq} from "@welshman/lib"
|
||||
import cx from "classnames"
|
||||
import {removeUndefined, randomId, uniq, toggle} from "@welshman/lib"
|
||||
import {relay} from "@welshman/util"
|
||||
import {publish} from "@welshman/app"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import {SUGGESTED_TOPICS} from "@app/classifieds"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
|
|
@ -148,6 +150,10 @@
|
|||
content = json
|
||||
}
|
||||
|
||||
const toggleTopic = (topic: string) => () => {
|
||||
topics = toggle(topic, topics)
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, submit, onChange, content})
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -187,10 +193,25 @@
|
|||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Topics</p>
|
||||
<p>Category</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<TopicMultiSelect bind:value={topics} />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each SUGGESTED_TOPICS as topic (topic)}
|
||||
<button
|
||||
type="button"
|
||||
class={cx(
|
||||
"button button-xs rounded-full",
|
||||
topics.includes(topic) ? "button-primary" : "button-neutral",
|
||||
)}
|
||||
onclick={toggleTopic(topic)}>
|
||||
#{topic}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<TopicMultiSelect bind:value={topics} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
|
|
@ -198,7 +219,7 @@
|
|||
<p>Price*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="join grid grid-cols-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<input bind:value={price} class="grow w-32" type="number" />
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
<script lang="ts">
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import cx from "classnames"
|
||||
import {formatTimestampRelative, uniq} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getAddress} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import Gallery from "@assets/icons/gallery.svg?dataurl"
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
|
||||
import type {FeedContext} from "@app/feeds"
|
||||
import RoomLink from "@app/components/RoomLink.svelte"
|
||||
import UnreadDot from "@app/components/UnreadDot.svelte"
|
||||
import {makeClassifiedPath} from "@app/routes"
|
||||
|
||||
|
|
@ -27,42 +29,76 @@
|
|||
const path = $derived(makeClassifiedPath(url, getAddress(event)))
|
||||
const classified = $derived(reader(Classified)(event))
|
||||
const title = $derived(classified.title())
|
||||
const h = $derived(classified.room())
|
||||
const images = $derived(new Set(classified.images()))
|
||||
const summary = $derived(classified.summary() || event.content)
|
||||
const images = $derived(uniq(classified.images()))
|
||||
const cover = $derived(images[0])
|
||||
const extraImages = $derived(images.slice(1))
|
||||
const price = $derived(classified.price())
|
||||
const topics = $derived(uniq(classified.topics() ?? []).map(normalizeTopic))
|
||||
const isSold = $derived(classified.status() === "sold")
|
||||
// Zero is the form's default, not a claim that the listing is free.
|
||||
const hasPrice = $derived(Boolean(price && price.amount > 0))
|
||||
</script>
|
||||
|
||||
<Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
|
||||
<UnreadDot {path} class="absolute right-3 top-3" />
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<p class="text-xl">
|
||||
{title} —
|
||||
<CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount ?? 0}
|
||||
</p>
|
||||
<p class="text-sm opacity-75">
|
||||
{formatTimestamp(event.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 h-0 text-xs opacity-75">
|
||||
{formatTimestamp(event.created_at)}
|
||||
</p>
|
||||
{/if}
|
||||
<Content {event} {url} expandMode="inline" />
|
||||
<div class="grid grid-cols-3 sm:grid-cols-5">
|
||||
{#each images as image (image)}
|
||||
<ContentLinkBlock {event} value={{url: image}} />
|
||||
{/each}
|
||||
<Cv
|
||||
tag={Link}
|
||||
class="group card card-interactive card-media flex h-full w-full flex-col"
|
||||
href={makeClassifiedPath(url, getAddress(event))}>
|
||||
<div class="relative aspect-[4/3] w-full overflow-hidden bg-surface-more">
|
||||
<UnreadDot {path} class="absolute right-3 top-3" />
|
||||
{#if cover}
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
class={cx(
|
||||
"h-full w-full object-cover transition-transform duration-300 group-hover:scale-105",
|
||||
isSold && "opacity-40 grayscale",
|
||||
)} />
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<Icon icon={CaseMinimalistic} size={10} class="opacity-20" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if isSold}
|
||||
<span class="badge badge-neutral badge-sm absolute top-3 left-3">Sold</span>
|
||||
{/if}
|
||||
{#if extraImages.length > 0}
|
||||
<span
|
||||
class="absolute right-3 bottom-3 flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-xs font-bold text-white">
|
||||
<Icon icon={Gallery} size={3} />
|
||||
{images.length}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||
Posted by
|
||||
<ProfileLink pubkey={event.pubkey} {url} />
|
||||
{#if h}
|
||||
in <RoomLink {url} {h} />
|
||||
{/if}
|
||||
<div class="flex min-w-0 grow flex-col gap-2 p-4">
|
||||
<p class="line-clamp-2 wrap-break-word font-bold leading-snug">{title}</p>
|
||||
{#if hasPrice}
|
||||
<p class="text-xl leading-none font-bold text-primary">
|
||||
<CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount.toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
{#if summary}
|
||||
<p class="line-clamp-2 wrap-break-word text-sm text-content-muted">{summary}</p>
|
||||
{/if}
|
||||
{#if topics.length > 0}
|
||||
<div class="flex min-w-0 flex-wrap gap-1">
|
||||
{#each topics.slice(0, 2) as topic (topic)}
|
||||
<span class="badge badge-sm rounded-full font-normal">#{topic}</span>
|
||||
{/each}
|
||||
{#if topics.length > 2}
|
||||
<span class="badge badge-sm rounded-full font-normal">+{topics.length - 2}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-2 border-t border-solid border-line px-4 py-3">
|
||||
<span class="flex min-w-0 flex-col text-xs text-content-muted">
|
||||
<span class="truncate">
|
||||
<ProfileLink pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
<span class="truncate">{formatTimestampRelative(event.created_at)}</span>
|
||||
</span>
|
||||
<ClassifiedActions showActivity {url} {event} {context} />
|
||||
<ClassifiedActions {url} {event} {context} showTopics={false} showStatus={false} />
|
||||
</div>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
</script>
|
||||
|
||||
{#if status}
|
||||
<Badge variant={status !== "active" ? "primary" : "neutral"} class="rounded-full">
|
||||
<Badge variant={status !== "active" ? "primary" : "neutral"} class="badge-sm rounded-full">
|
||||
{ucFirst(status)}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
{#each value as topic (topic)}
|
||||
<div class="flex-inline badge badge-neutral mr-1 gap-1">
|
||||
<Button class="flex items-center" onclick={() => removeTopic(topic)}>
|
||||
<Icon icon={CloseCircle} size={4} class="-ml-1 mt-px" />
|
||||
<Icon icon={CloseCircle} size={4} class="mt-px" />
|
||||
</Button>
|
||||
<span>#{topic}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {currencyOptions} from "@lib/currency"
|
||||
import {getCurrencyOption} from "@lib/currency"
|
||||
|
||||
export let code
|
||||
</script>
|
||||
|
|
@ -7,5 +7,5 @@
|
|||
{#if code.match(/^sats?$/i)}
|
||||
<span style="font-family: Satoshis; font-size: 1.2em;">!</span>
|
||||
{:else}
|
||||
{currencyOptions.find(c => c.code)?.symbol || code}
|
||||
{getCurrencyOption(code).symbol}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -103,9 +103,9 @@
|
|||
alt="Upload preview"
|
||||
class="h-full w-full object-cover rounded-2xl" />
|
||||
<Button
|
||||
class="button button-neutral bg-surface absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full"
|
||||
class="button button-neutral button-xs button-circle bg-surface absolute right-1 top-1"
|
||||
onclick={() => removeItem(index)}>
|
||||
<Icon icon={CloseCircle} size={6} />
|
||||
<Icon icon={CloseCircle} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@
|
|||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* content reaches the card's edges; padding moves to its inner sections */
|
||||
&.card-media {
|
||||
@apply overflow-hidden p-0;
|
||||
}
|
||||
|
||||
&.card-primary {
|
||||
--accent: var(--primary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,18 @@
|
|||
import {onDestroy, onMount} from "svelte"
|
||||
import {readable} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
import cx from "classnames"
|
||||
import {goto} from "$app/navigation"
|
||||
import {page} from "$app/stores"
|
||||
import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib"
|
||||
import {sortBy} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {CLASSIFIED, tagSpec, tagValue} from "@welshman/util"
|
||||
import {CLASSIFIED} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {fly} from "@lib/transition"
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import Add from "@assets/icons/add.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
|
|
@ -20,6 +24,16 @@
|
|||
import {decodeRelay} from "@app/relays"
|
||||
import {makeCommentFilter} from "@app/content"
|
||||
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
||||
import {makeClassifiedPath} from "@app/routes"
|
||||
import {
|
||||
CLASSIFIED_STATUS_TABS,
|
||||
partitionListings,
|
||||
deriveTopicCounts,
|
||||
getStatus,
|
||||
matchesTopic,
|
||||
matchesQuery,
|
||||
} from "@app/classifieds"
|
||||
import {reader} from "@app/core"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
|
|
@ -27,28 +41,65 @@
|
|||
|
||||
onDestroy(context.cleanup)
|
||||
|
||||
const createClassified = () => pushModal(ClassifiedCreate, {url})
|
||||
|
||||
const setTopic = (value?: string) => () => {
|
||||
const path = makeClassifiedPath(url) + (value ? `?topic=${encodeURIComponent(value)}` : "")
|
||||
|
||||
goto(path, {replaceState: true, noScroll: true})
|
||||
}
|
||||
|
||||
const setTab = (value: string) => () => {
|
||||
tab = value
|
||||
}
|
||||
|
||||
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
|
||||
|
||||
const loading = $derived(isFeedLoading($older))
|
||||
const exhausted = $derived($older?.status === "exhausted")
|
||||
|
||||
let tab = $state("all")
|
||||
let sort = $state("active")
|
||||
let query = $state("")
|
||||
let element: HTMLElement | undefined = $state()
|
||||
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
||||
|
||||
const createClassified = () => pushModal(ClassifiedCreate, {url})
|
||||
const topic = $derived($page.url.searchParams.get("topic") ?? "")
|
||||
|
||||
const items = $derived.by(() => {
|
||||
const scores = new Map<string, number[]>()
|
||||
const [goals, comments] = partition(spec({kind: CLASSIFIED}), $events)
|
||||
const {listings, activeAt} = $derived(partitionListings($events))
|
||||
const topicCounts = $derived(deriveTopicCounts(listings))
|
||||
|
||||
for (const comment of comments) {
|
||||
const id = tagValue(tagSpec("E"), comment.tags)
|
||||
// Narrowed by everything except the status tab, so its counts match what it shows.
|
||||
const scoped = $derived(
|
||||
listings.filter(event => matchesQuery(event, query) && matchesTopic(event, topic)),
|
||||
)
|
||||
|
||||
if (id) {
|
||||
pushToMapKey(scores, id, comment.created_at)
|
||||
}
|
||||
const counts = $derived.by(() => {
|
||||
const result: Record<string, number> = {all: scoped.length, active: 0, sold: 0}
|
||||
|
||||
for (const event of scoped) {
|
||||
const status = getStatus(event)
|
||||
|
||||
result[status] = (result[status] ?? 0) + 1
|
||||
}
|
||||
|
||||
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), goals)
|
||||
return result
|
||||
})
|
||||
|
||||
const items = $derived.by(() => {
|
||||
const matches = scoped.filter(event => tab === "all" || getStatus(event) === tab)
|
||||
|
||||
if (sort === "new") return sortBy(event => -event.created_at, matches)
|
||||
|
||||
if (sort === "price-asc") {
|
||||
return sortBy(event => reader(Classified)(event).price()?.amount ?? Infinity, matches)
|
||||
}
|
||||
|
||||
if (sort === "price-desc") {
|
||||
return sortBy(event => -(reader(Classified)(event).price()?.amount ?? -1), matches)
|
||||
}
|
||||
|
||||
return sortBy(event => -(activeAt.get(event.id) ?? event.created_at), matches)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -86,21 +137,82 @@
|
|||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
||||
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||
{#each items as event (event.id)}
|
||||
<div in:fly>
|
||||
<ClassifiedItem {url} {context} event={$state.snapshot(event)} />
|
||||
<PageContent bind:element class="flex flex-col gap-3 p-2 sm:gap-4 sm:p-4">
|
||||
{#if listings.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="input flex w-auto min-w-48 grow items-center gap-2">
|
||||
<Icon icon={Magnifier} class="shrink-0 text-content-muted" />
|
||||
<input bind:value={query} class="grow" type="text" placeholder="Search listings..." />
|
||||
</label>
|
||||
<select class="select input w-full sm:w-auto" bind:value={sort}>
|
||||
<option value="active">Recently active</option>
|
||||
<option value="new">Newest</option>
|
||||
<option value="price-asc">Price: low to high</option>
|
||||
<option value="price-desc">Price: high to low</option>
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Looking for listings...
|
||||
{:else if exhausted && items.length === 0}
|
||||
No classified listings found.
|
||||
{:else if exhausted}
|
||||
That's all!
|
||||
<!-- Scrolls on a phone, where wrapping this many chips would bury the listings. -->
|
||||
<div
|
||||
class="flex shrink-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible sm:pb-0">
|
||||
{#each CLASSIFIED_STATUS_TABS as { value, label } (value)}
|
||||
<Button
|
||||
aria-pressed={tab === value}
|
||||
class={cx(
|
||||
"button button-sm shrink-0 rounded-full",
|
||||
tab === value ? "button-primary" : "button-neutral",
|
||||
)}
|
||||
onclick={setTab(value)}>
|
||||
{label}
|
||||
<span class="opacity-75">{counts[value] ?? 0}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
{#if topicCounts.length > 0}
|
||||
<span class="mx-1 h-6 w-px shrink-0 bg-line"></span>
|
||||
{#each topicCounts as [value, count] (value)}
|
||||
<Button
|
||||
aria-pressed={topic === value}
|
||||
class={cx(
|
||||
"button button-sm shrink-0 rounded-full font-normal",
|
||||
topic === value ? "button-primary" : "button-neutral",
|
||||
)}
|
||||
onclick={setTopic(topic === value ? undefined : value)}>
|
||||
#{value}
|
||||
<span class="opacity-75">{count}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
{/if}
|
||||
</Spinner>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{#each items as event (event.id)}
|
||||
<div in:fly class="h-full min-w-0">
|
||||
<ClassifiedItem {url} {context} event={$state.snapshot(event)} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if exhausted && listings.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 card w-full py-16 text-center">
|
||||
<Icon icon={CaseMinimalistic} size={10} class="text-primary" />
|
||||
<h2 class="text-xl font-bold">No listings yet</h2>
|
||||
<p class="text-content-muted">
|
||||
Classifieds let this space buy, sell, and find things together.
|
||||
</p>
|
||||
<Button class="button button-primary" onclick={createClassified}>
|
||||
<Icon icon={Add} />
|
||||
Post the first listing
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Looking for listings...
|
||||
{:else if exhausted && items.length === 0}
|
||||
No listings match your filters.
|
||||
{:else if exhausted}
|
||||
That's all!
|
||||
{/if}
|
||||
</Spinner>
|
||||
</p>
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
|
|
|||
|
|
@ -4,23 +4,16 @@
|
|||
import {page} from "$app/stores"
|
||||
import {sleep} from "@welshman/lib"
|
||||
import type {MakeNonOptional} from "@welshman/lib"
|
||||
import {getCommentFiltersForRoot} from "@welshman/util"
|
||||
import {deriveEventsAsc} from "@welshman/store"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
|
||||
import CommentActions from "@app/components/CommentActions.svelte"
|
||||
import EventReply from "@app/components/EventReply.svelte"
|
||||
import {network, reader} from "@app/core"
|
||||
import {deriveEvent, deriveEventsById} from "@app/repository"
|
||||
import EventComments from "@app/components/EventComments.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import {deriveEvent} from "@app/repository"
|
||||
import {makeFeedContext} from "@app/feeds"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
|
||||
|
|
@ -29,37 +22,10 @@
|
|||
const context = makeFeedContext({relays: [url]})
|
||||
const event = deriveEvent(address, [url])
|
||||
const classified = derived(event, $event => ($event ? reader(Classified)($event) : undefined))
|
||||
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
|
||||
const replies = $derived(deriveEventsAsc(deriveEventsById(filters)))
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const openReply = () => {
|
||||
showReply = true
|
||||
}
|
||||
|
||||
const closeReply = () => {
|
||||
showReply = false
|
||||
}
|
||||
|
||||
const expand = () => {
|
||||
showAll = true
|
||||
}
|
||||
|
||||
onDestroy(context.cleanup)
|
||||
|
||||
let showAll = $state(false)
|
||||
let showReply = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (filters.length > 0) {
|
||||
const controller = new AbortController()
|
||||
|
||||
$network.request({relays: [url], filters, signal: controller.signal})
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<SpaceBar {back}>
|
||||
|
|
@ -70,40 +36,13 @@
|
|||
|
||||
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||
{#if $event}
|
||||
<div class="flex flex-col gap-3">
|
||||
<NoteCard event={$event} {url} class="card z-feature w-full">
|
||||
<div class="flex flex-col gap-3 ml-12">
|
||||
<NoteContent showEntire event={$event} {url} />
|
||||
<ClassifiedActions showRoom event={$event} {url} {context} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{#if !showAll && $replies.length > 4}
|
||||
<div class="flex justify-center">
|
||||
<Button class="button button-link" onclick={expand}>
|
||||
<Icon icon={SortVertical} />
|
||||
Show all {$replies.length} replies
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)}
|
||||
<NoteCard event={reply} {url} class="card z-feature w-full">
|
||||
<div class="flex flex-col gap-3 ml-12">
|
||||
<NoteContent showEntire event={reply} {url} />
|
||||
<CommentActions event={reply} {url} {context} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{/each}
|
||||
</div>
|
||||
{#if showReply}
|
||||
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
|
||||
{:else}
|
||||
<div class="flex justify-end">
|
||||
<Button class="button button-primary" onclick={openReply}>
|
||||
<Icon icon={Reply} />
|
||||
Reply to listing
|
||||
</Button>
|
||||
<NoteCard event={$event} {url} class="card z-feature w-full">
|
||||
<div class="flex flex-col gap-3 ml-12">
|
||||
<NoteContent showEntire event={$event} {url} />
|
||||
<ClassifiedActions showRoom event={$event} {url} {context} />
|
||||
</div>
|
||||
{/if}
|
||||
</NoteCard>
|
||||
<EventComments event={$event} {url} {context} />
|
||||
{:else}
|
||||
<div class="flex justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue