Classifieds tags (#18) #65

Merged
hodlbod merged 1 commits from feature/18-classified-topics into dev 2026-02-25 00:09:51 +00:00
8 changed files with 172 additions and 8 deletions
+11 -1
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue, getAddress} from "@welshman/util"
import {getTagValue, getTagValues, getAddress} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import {normalizeTopic} from '@lib/util'
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -27,6 +29,7 @@
const {url, event, showRoom, showActivity}: Props = $props()
const h = getTagValue("h", event.tags)
const topics = getTagValues("t", event.tags)
const path = makeClassifiedPath(url, getAddress(event))
const shouldProtect = canEnforceNip70(url)
@@ -45,6 +48,13 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<div class="flex min-w-0 flex-wrap gap-2">
{#each uniq(topics) as topic (topic)}
<button type="button" class="btn btn-xs rounded-full font-normal">
#{normalizeTopic(topic)}
</button>
{/each}
</div>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event}>
<ClassifiedStatus {event} />
+2 -1
View File
@@ -18,7 +18,8 @@
const {d, title, status} = fromPairs(event.tags)
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
const images = getTagValues("image", event.tags)
const initialValues = {d, title, status, content, price: Number(price), currency, images}
const topics = getTagValues("t", event.tags)
const initialValues = {d, title, status, content, price: Number(price), currency, images, topics}
</script>
<ClassifiedForm {url} {initialValues}>
+17 -1
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {randomId} from "@welshman/lib"
import {removeUndefined, randomId, uniq} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import {normalizeTopic} from "@lib/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
@@ -14,6 +15,7 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import ImagesInput from "@lib/components/ImagesInput.svelte"
import CurrencyInput from "@app/components/CurrencyInput.svelte"
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
@@ -32,6 +34,7 @@
currency?: string
images?: string[]
status?: string
topics?: string[]
}
}
4
@@ -71,6 +74,10 @@
...ed.storage.nostr.getEditorTags(),
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Topic search should be derived, it's expensive to create a new Fuse instance on every keystroke

Topic search should be derived, it's expensive to create a new Fuse instance on every keystroke
]
for (const topic of topics) {
tags.push(["t", topic])
}
hodlbod marked this conversation as resolved Outdated
Outdated
Review

No need to normalize or use a ternary, just do topicSearch.searchValues(value)

No need to normalize or use a ternary, just do `topicSearch.searchValues(value)`
if (await shouldProtect) {
tags.push(PROTECTED)
}
1
@@ -118,6 +125,7 @@
let price = $state(Number(initialValues?.price || 0))
let currency = $state(initialValues?.currency || "SAT")
let images = $state<(string | File)[]>(initialValues?.images || [])
let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic))))
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
1
@@ -150,6 +158,14 @@
</div>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Topics</p>
{/snippet}
{#snippet input()}
<TopicMultiSelect bind:value={topics} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Price*</p>
+4
View File
@@ -96,6 +96,10 @@
params={{
trigger: "manual",
interactive: true,
placement: "bottom",
getReferenceClientRect: () => wrapper!.getBoundingClientRect(),
onShow: (instance: Instance) => {
instance.popper.style.width = `${wrapper!.getBoundingClientRect().width + 8}px`
},
}} />
</button>
+8 -5
View File
@@ -62,7 +62,7 @@
}
}
let input: Element | undefined = $state()
let label: Element | undefined = $state()
let popover: Instance | undefined = $state()
let instance: any = $state()
@@ -92,7 +92,7 @@
</div>
{/each}
</div>
<label class="input input-bordered flex w-full items-center gap-2" bind:this={input}>
<label class="input input-bordered flex w-full items-center gap-2" bind:this={label}>
<Icon icon={Magnifier} />
<!-- svelte-ignore a11y_autofocus -->
<input
@@ -114,12 +114,15 @@
select: selectPubkey,
component: ProfileSuggestion,
class: "rounded-box",
style: `left: 4px; width: ${input?.clientWidth + 12}px`,
style: `left: 4px; width: ${label?.clientWidth + 12}px`,
}}
params={{
trigger: "manual",
interactive: true,
maxWidth: "none",
getReferenceClientRect: () => input!.getBoundingClientRect(),
placement: "bottom",
getReferenceClientRect: () => label!.getBoundingClientRect(),
onShow: (instance: Instance) => {
instance.popper.style.width = `${label!.getBoundingClientRect().width + 8}px`
},
}} />
</div>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {Writable} from "svelte/store"
import type {Instance} from "tippy.js"
import {remove, without, uniq} from "@welshman/lib"
import {createSearch, topics} from "@welshman/app"
import {normalizeTopic} from "@lib/util"
import Suggestions from "@lib/components/Suggestions.svelte"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import TopicSuggestion from "@app/components/TopicSuggestion.svelte"
interface Props {
value: string[]
term?: Writable<string>
}
let {value = $bindable(), term = writable("")}: Props = $props()
const topicSearch = $derived.by(() =>
createSearch(without(value, $topics), {
getValue: topic => topic.name,
fuseOptions: {
keys: ["name"],
threshold: 0.4,
},
}),
)
const addTopic = (text: string) => {
const topic = normalizeTopic(text)
if (topic) {
value = uniq([...value, topic])
}
term.set("")
popover?.hide()
}
const removeTopic = (topic: string) => {
value = remove(topic, value)
}
const onKeyDown = (e: KeyboardEvent) => {
if (instance?.onKeyDown(e)) {
e.preventDefault()
return
}
if (e.key === "Enter" && $term) {
e.preventDefault()
addTopic($term)
}
}
const onBlur = () => {
term.set("")
popover?.hide()
}
let label: Element | undefined = $state()
let popover: Instance | undefined = $state()
let instance: any = $state()
$effect(() => {
if ($term.trim()) {
popover?.show()
} else {
popover?.hide()
}
})
</script>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
{#each value as topic (topic)}
<div class="badge badge-neutral gap-1">
<Button class="flex items-center" onclick={() => removeTopic(topic)}>
<Icon icon={CloseCircle} size={4} class="-ml-1 mt-px" />
</Button>
<span>#{topic}</span>
</div>
{/each}
</div>
<label class="input input-bordered flex w-full items-center gap-2" bind:this={label}>
<Icon icon={Magnifier} />
<input
bind:value={$term}
class="grow"
type="text"
placeholder="Add topics..."
onkeydown={onKeyDown}
onblur={onBlur} />
</label>
<Tippy
bind:popover
bind:instance
component={Suggestions}
props={{
term,
search: topicSearch.searchValues,
select: addTopic,
component: TopicSuggestion,
allowCreate: true,
}}
params={{
trigger: "manual",
interactive: true,
placement: "bottom",
getReferenceClientRect: () => label!.getBoundingClientRect(),
onShow: (instance: Instance) => {
instance.popper.style.width = `${label!.getBoundingClientRect().width + 8}px`
},
}} />
</div>
@@ -0,0 +1,9 @@
<script lang="ts">
type Props = {
value: string
}
const {value}: Props = $props()
</script>
<span>#{value}</span>
+2
View File
@@ -28,3 +28,5 @@ export const buildUrl = (base: string | URL, ...pathname: string[]) => {
}
export const addPeriod = (s: string) => (s + ".").replace(/\.+$/, ".")
export const normalizeTopic = (topic: string) => topic.trim().replace(/^#+/, "").toLowerCase()