mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
polls
This commit is contained in:
@@ -1,42 +1,175 @@
|
||||
<script lang="ts">
|
||||
/** Read-only poll results. Voting needs a write scope and a UI of its own. */
|
||||
/** Mastodon/Pleroma poll choices, voting and results. */
|
||||
import { untrack } from 'svelte'
|
||||
import type { Poll } from '$lib/api/types'
|
||||
import { relativeTime } from '$lib/util/time'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { formatCount } from '$lib/util/profile'
|
||||
|
||||
interface Props {
|
||||
poll: Poll
|
||||
authorId?: string
|
||||
onupdate?: (poll: Poll) => void
|
||||
}
|
||||
|
||||
let { poll }: Props = $props()
|
||||
let { poll, authorId, onupdate }: Props = $props()
|
||||
const { endpoints, session } = useAppServices()
|
||||
|
||||
const total = $derived(poll.votes_count || 0)
|
||||
function pollSignature(value: Poll): string {
|
||||
return [
|
||||
value.id,
|
||||
value.expired,
|
||||
value.voted,
|
||||
value.votes_count,
|
||||
value.own_votes?.join(',') ?? '',
|
||||
value.options.map((option) => option.votes_count ?? '').join(','),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
let incomingSignature = $state(untrack(() => pollSignature(poll)))
|
||||
let currentPoll = $state(untrack(() => poll))
|
||||
let selectedChoices = $state<number[]>([])
|
||||
let singleChoice = $state<number | null>(null)
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const total = $derived(currentPoll.votes_count || 0)
|
||||
const isAuthor = $derived(Boolean(authorId && session.me?.id === authorId))
|
||||
const showResults = $derived(currentPoll.expired || Boolean(currentPoll.voted) || isAuthor)
|
||||
const canChoose = $derived(
|
||||
session.signedIn && !isAuthor && !currentPoll.expired && !currentPoll.voted && !busy,
|
||||
)
|
||||
const choices = $derived(
|
||||
currentPoll.multiple
|
||||
? selectedChoices
|
||||
: singleChoice === null
|
||||
? []
|
||||
: [singleChoice],
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
const next = poll
|
||||
const signature = pollSignature(next)
|
||||
if (signature !== incomingSignature) {
|
||||
incomingSignature = signature
|
||||
currentPoll = next
|
||||
selectedChoices = []
|
||||
singleChoice = null
|
||||
error = null
|
||||
}
|
||||
})
|
||||
|
||||
function share(votes: number | null): number {
|
||||
if (!total || votes === null) return 0
|
||||
return Math.round((votes / total) * 100)
|
||||
}
|
||||
|
||||
function closesIn(value: string): string {
|
||||
const seconds = Math.max(0, Math.ceil((new Date(value).getTime() - Date.now()) / 1000))
|
||||
const units: Array<[string, number]> = [
|
||||
['day', 86_400],
|
||||
['hour', 3_600],
|
||||
['minute', 60],
|
||||
]
|
||||
for (const [label, size] of units) {
|
||||
if (seconds >= size) {
|
||||
const count = Math.ceil(seconds / size)
|
||||
return `${count} ${label}${count === 1 ? '' : 's'}`
|
||||
}
|
||||
}
|
||||
return seconds > 0 ? `${seconds} second${seconds === 1 ? '' : 's'}` : 'soon'
|
||||
}
|
||||
|
||||
async function submitVote(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
if (!canChoose || choices.length === 0) return
|
||||
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
const updated = await endpoints.votePoll(session.api, currentPoll.id, choices)
|
||||
currentPoll = updated
|
||||
selectedChoices = []
|
||||
singleChoice = null
|
||||
onupdate?.(updated)
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not submit your vote.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="poll" data-expired={poll.expired ? 'true' : 'false'}>
|
||||
{#each poll.options as option, index (index)}
|
||||
<div class="poll-option" data-own-vote={poll.own_votes?.includes(index) ? 'true' : 'false'}>
|
||||
<div class="poll-option-label">
|
||||
<span class="poll-option-title">{option.title}</span>
|
||||
<span class="poll-option-share">{share(option.votes_count)}%</span>
|
||||
<div
|
||||
class="poll"
|
||||
data-expired={currentPoll.expired ? 'true' : 'false'}
|
||||
data-voted={currentPoll.voted ? 'true' : 'false'}
|
||||
data-multiple={currentPoll.multiple ? 'true' : 'false'}
|
||||
>
|
||||
{#if showResults}
|
||||
{#each currentPoll.options as option, index (index)}
|
||||
<div class="poll-option" data-own-vote={currentPoll.own_votes?.includes(index) ? 'true' : 'false'}>
|
||||
<div class="poll-option-label">
|
||||
<span class="poll-option-title">
|
||||
{#if currentPoll.own_votes?.includes(index)}
|
||||
<span class="poll-own-vote" aria-label="Your vote">✓</span>
|
||||
{/if}
|
||||
{option.title}
|
||||
</span>
|
||||
<span class="poll-option-share">
|
||||
{option.votes_count === null ? '—' : `${share(option.votes_count)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div class="poll-option-bar" aria-hidden="true">
|
||||
<span class="poll-option-fill" style={`width: ${share(option.votes_count)}%`}></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="poll-option-bar">
|
||||
<span class="poll-option-fill" style="width: {share(option.votes_count)}%"></span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<form class="poll-vote-form" onsubmit={submitVote}>
|
||||
<fieldset class="poll-choices" disabled={!canChoose}>
|
||||
<legend class="visually-hidden">
|
||||
{currentPoll.multiple ? 'Choose one or more poll options' : 'Choose one poll option'}
|
||||
</legend>
|
||||
{#each currentPoll.options as option, index (index)}
|
||||
<label class="poll-choice">
|
||||
{#if currentPoll.multiple}
|
||||
<input type="checkbox" value={index} bind:group={selectedChoices} />
|
||||
{:else}
|
||||
<input type="radio" name={`poll-${currentPoll.id}`} value={index} bind:group={singleChoice} />
|
||||
{/if}
|
||||
<span>{option.title}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
{#if session.signedIn}
|
||||
<div class="poll-vote-actions">
|
||||
<button class="button button--small button--primary" type="submit" disabled={!canChoose || choices.length === 0}>
|
||||
{busy ? 'Voting…' : 'Vote'}
|
||||
</button>
|
||||
{#if currentPoll.multiple}
|
||||
<span class="field-hint">Choose one or more.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="poll-sign-in"><a href="#/login">Sign in to vote</a></p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if isAuthor && !currentPoll.expired}
|
||||
<p class="poll-notice">You created this poll.</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<p class="poll-meta">
|
||||
{formatCount(total)} vote{total === 1 ? '' : 's'}
|
||||
{#if poll.expired}
|
||||
{#if currentPoll.expired}
|
||||
· closed
|
||||
{:else if poll.expires_at}
|
||||
· closes {relativeTime(poll.expires_at).replace(' ago', ' from now')}
|
||||
{:else if currentPoll.expires_at}
|
||||
· closes in {closesIn(currentPoll.expires_at)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user