Classifieds tags (#18) #65
@@ -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} />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +74,10 @@
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
|
hodlbod marked this conversation as resolved
Outdated
|
||||
]
|
||||
|
||||
for (const topic of topics) {
|
||||
tags.push(["t", topic])
|
||||
}
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
No need to normalize or use a ternary, just do No need to normalize or use a ternary, just do `topicSearch.searchValues(value)`
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
@@ -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)}>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user
Topic search should be derived, it's expensive to create a new Fuse instance on every keystroke