chore: refine NIP-88 polls code structure and reactivity #153
+1
-1
@@ -13,7 +13,7 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check src && eslint src",
|
||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||
"format": "git diff HEAD --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||
"format:all": "prettier --write src",
|
||||
"prepare": "husky"
|
||||
},
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {onMount} from "svelte"
|
||||
import {request} from "@welshman/net"
|
||||
import {PollResponse} from "nostr-tools/kinds"
|
||||
import PollVotes from "@app/components/PollVotes.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
onMount(() => {
|
||||
if (!props.url) {
|
||||
return
|
||||
}
|
||||
|
||||
request({
|
||||
relays: [props.url],
|
||||
filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
|
||||
})
|
||||
})
|
||||
|
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
||||
import {now, randomId, removeUndefined} from "@welshman/lib"
|
||||
import {makeEvent} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
import {Poll} from "nostr-tools/kinds"
|
||||
@@ -52,45 +52,99 @@
|
||||
options = options.map(option => (option.id === id ? {...option, value} : option))
|
||||
}
|
||||
|
||||
const reorderOptions = (targetId: string) => {
|
||||
if (!draggedOptionId) {
|
||||
const swapOptions = (sourceId: string, targetId: string) => {
|
||||
if (sourceId === targetId) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
|
||||
const sourceIndex = options.findIndex(option => option.id === sourceId)
|
||||
const targetIndex = options.findIndex(option => option.id === targetId)
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
|
||||
const reordered = [...options]
|
||||
const sourceOption = reordered[sourceIndex]
|
||||
reordered[sourceIndex] = reordered[targetIndex]
|
||||
reordered[targetIndex] = sourceOption
|
||||
options = reordered
|
||||
|
hodlbod
commented
I think insertAt/removeAt is actually much cleaner I think insertAt/removeAt is actually much cleaner
|
||||
}
|
||||
|
||||
const onDragStart = (e: DragEvent, id: string) => {
|
||||
draggedOptionId = id
|
||||
const getOptionId = (target: EventTarget | null) => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return target.dataset.optionId
|
||||
}
|
||||
|
||||
const getClosestOptionId = (target: EventTarget | null) => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return target.closest<HTMLElement>("[data-option-id]")?.dataset.optionId
|
||||
}
|
||||
|
||||
const handleOptionDragStart = (e: DragEvent) => {
|
||||
const optionId = getOptionId(e.currentTarget)
|
||||
|
||||
if (!optionId) {
|
||||
return
|
||||
}
|
||||
|
||||
draggedOptionId = optionId
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", id)
|
||||
e.dataTransfer.setData("text/plain", optionId)
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent, targetId: string) => {
|
||||
const handleOptionDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
reorderOptions(targetId)
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent, targetId: string) => {
|
||||
const handleOptionDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
reorderOptions(targetId)
|
||||
|
||||
const targetId = getOptionId(e.currentTarget)
|
||||
|
||||
if (!draggedOptionId || !targetId) {
|
||||
draggedOptionId = undefined
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
swapOptions(draggedOptionId, targetId)
|
||||
draggedOptionId = undefined
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
const handleOptionDragEnd = () => {
|
||||
draggedOptionId = undefined
|
||||
}
|
||||
|
||||
const handleOptionInput = (e: Event) => {
|
||||
const optionId = getOptionId(e.currentTarget)
|
||||
|
||||
if (!optionId || !(e.currentTarget instanceof HTMLInputElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
updateOption(optionId, e.currentTarget.value)
|
||||
}
|
||||
|
||||
const handleRemoveOption = (e: Event) => {
|
||||
const optionId = getClosestOptionId(e.target)
|
||||
|
||||
if (!optionId) {
|
||||
return
|
||||
}
|
||||
|
||||
removeOption(optionId)
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!title.trim()) {
|
||||
return pushToast({theme: "error", message: "Please provide a title for your poll."})
|
||||
@@ -132,6 +186,8 @@
|
||||
history.back()
|
||||
}
|
||||
|
||||
const onSubmit = preventDefault(submit)
|
||||
|
||||
let title = $state("")
|
||||
let pollType = $state<PollType>("singlechoice")
|
||||
let endsAt = $state<number | undefined>()
|
||||
@@ -142,7 +198,7 @@
|
||||
let draggedOptionId = $state<string | undefined>()
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
<Modal tag="form" onsubmit={onSubmit}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create a Poll</ModalTitle>
|
||||
@@ -175,24 +231,26 @@
|
||||
{#each options as option, index (option.id)}
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
data-option-id={option.id}
|
||||
draggable="true"
|
||||
role="listitem"
|
||||
ondragstart={e => onDragStart(e, option.id)}
|
||||
ondragover={e => onDragOver(e, option.id)}
|
||||
ondrop={e => onDrop(e, option.id)}
|
||||
ondragend={onDragEnd}>
|
||||
ondragstart={handleOptionDragStart}
|
||||
ondragover={handleOptionDragOver}
|
||||
ondrop={handleOptionDrop}
|
||||
ondragend={handleOptionDragEnd}>
|
||||
<div class="cursor-move opacity-70" aria-label="Drag handle">
|
||||
<Icon icon={HamburgerMenu} size={4} />
|
||||
</div>
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<input
|
||||
data-option-id={option.id}
|
||||
value={option.value}
|
||||
class="grow"
|
||||
type="text"
|
||||
placeholder={`Option ${index + 1}`}
|
||||
oninput={e => updateOption(option.id, e.currentTarget.value)} />
|
||||
oninput={handleOptionInput} />
|
||||
</label>
|
||||
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
|
||||
<Button class="btn btn-ghost btn-sm" onclick={handleRemoveOption}>
|
||||
<Icon icon={MinusCircle} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {tweened} from "svelte/motion"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {noop} from "@welshman/lib"
|
||||
import {stopPropagation} from "@lib/html"
|
||||
import {getPollType, isPollClosed} from "@app/util/polls"
|
||||
|
||||
type Props = {
|
||||
@@ -23,22 +20,15 @@
|
||||
const selected = $derived(
|
||||
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
|
||||
)
|
||||
const onselect = () =>
|
||||
const handleInputClick = (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleSelectChange = () =>
|
||||
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
|
||||
|
||||
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
|
||||
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
|
||||
|
||||
const tweenedVotes = tweened(votes, {duration: 300})
|
||||
const tweenedMax = tweened(maxVotes, {duration: 300})
|
||||
|
||||
$effect(() => {
|
||||
tweenedVotes.set(votes)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
tweenedMax.set(maxVotes)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
||||
@@ -51,20 +41,20 @@
|
||||
type="radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked={selected}
|
||||
onclick={stopPropagation(noop)}
|
||||
onchange={onselect} />
|
||||
onclick={handleInputClick}
|
||||
onchange={handleSelectChange} />
|
||||
{:else}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-sm"
|
||||
checked={selected}
|
||||
onclick={stopPropagation(noop)}
|
||||
onchange={onselect} />
|
||||
onclick={handleInputClick}
|
||||
onchange={handleSelectChange} />
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="truncate">{option.label}</span>
|
||||
</label>
|
||||
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
|
||||
<progress class="progress progress-primary" value={votes} max={maxVotes}></progress>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user
We actually do need this, sync is only a best-effort thing to speed up page transitions.