mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
178 lines
5.7 KiB
Svelte
178 lines
5.7 KiB
Svelte
<script lang="ts">
|
|
/** Mastodon/Pleroma poll choices, voting and results. */
|
|
import { untrack } from 'svelte'
|
|
import type { CustomEmoji, Poll } from '$lib/api/types'
|
|
import { useAppServices } from '$lib/app-services'
|
|
import { formatCount } from '$lib/util/profile'
|
|
import EmojiText from '../common/EmojiText.svelte'
|
|
|
|
interface Props {
|
|
poll: Poll
|
|
emojis?: CustomEmoji[]
|
|
authorId?: string
|
|
onupdate?: (poll: Poll) => void
|
|
}
|
|
|
|
let { poll, emojis = poll.emojis ?? [], authorId, onupdate }: Props = $props()
|
|
const { endpoints, session } = useAppServices()
|
|
|
|
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={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}
|
|
<EmojiText text={option.title} {emojis} />
|
|
</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>
|
|
{/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}
|
|
<EmojiText text={option.title} {emojis} />
|
|
</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 currentPoll.expired}
|
|
· closed
|
|
{:else if currentPoll.expires_at}
|
|
· closes in {closesIn(currentPoll.expires_at)}
|
|
{/if}
|
|
</p>
|
|
</div>
|