Coves frontend - a photon fork
1<script lang="ts">
2 import type {
3 AtUri,
4 CID,
5 CommentStats,
6 CommentViewerState,
7 } from '$lib/api/coves/types'
8 import { coves } from '$lib/api/client.svelte'
9 import { profile } from '$lib/app/auth.svelte'
10 import { t } from '$lib/app/i18n'
11 import FormattedNumber from '$lib/ui/util/FormattedNumber.svelte'
12 import { toast } from 'mono-svelte'
13 import { backOut } from 'svelte/easing'
14 import { fly } from 'svelte/transition'
15 import AnimatedHeart from '$lib/ui/icon/AnimatedHeart.svelte'
16
17 interface Props {
18 uri: AtUri
19 cid: CID
20 stats: CommentStats | undefined
21 viewer: CommentViewerState | undefined
22 }
23
24 let { uri, cid, stats = $bindable(), viewer = $bindable() }: Props = $props()
25
26 let upvotes = $derived(stats?.upvotes ?? 0)
27 let vote = $derived(viewer?.vote)
28 let liked = $derived(vote === 'up')
29
30 let voting = $state(false)
31
32 const castVote = async () => {
33 if (navigator.vibrate) navigator.vibrate(1)
34 if (!profile.current?.jwt) {
35 toast({ content: $t('toast.loginVoteGate'), type: 'warning' })
36 return
37 }
38 if (voting) return
39 voting = true
40
41 const isToggleOff = viewer?.vote === 'up'
42
43 // Save previous state for rollback
44 const prevStats = stats ? { ...stats } : undefined
45 const prevViewer = viewer ? { ...viewer } : undefined
46
47 // Optimistically update local state
48 const newStats = {
49 ...(stats ?? { upvotes: 0, downvotes: 0, score: 0, replyCount: 0 }),
50 }
51 const newViewer = { ...(viewer ?? {}) }
52
53 if (isToggleOff) {
54 newStats.upvotes--
55 newViewer.vote = undefined
56 newViewer.voteUri = undefined
57 } else {
58 newStats.upvotes++
59 newViewer.vote = 'up'
60 }
61 newStats.score = newStats.upvotes
62
63 stats = newStats
64 viewer = newViewer
65
66 try {
67 if (isToggleOff) {
68 await coves().deleteVote({ subject: { uri, cid } })
69 newViewer.voteUri = undefined
70 } else {
71 const result = await coves().createVote({
72 subject: { uri, cid },
73 direction: 'up',
74 })
75 newViewer.voteUri = result.uri
76 }
77 } catch (err) {
78 // Rollback on error
79 stats = prevStats
80 viewer = prevViewer
81 const errorMsg = err instanceof Error ? err.message : String(err)
82 toast({ content: errorMsg, type: 'error' })
83 } finally {
84 voting = false
85 }
86 }
87</script>
88
89<button
90 onclick={castVote}
91 class={[
92 'flex items-center gap-0.5 transition-colors cursor-pointer rounded-full px-1.5 py-1',
93 liked ? 'text-[#FF0033]' : 'btn-tertiary',
94 ]}
95 aria-pressed={liked}
96 aria-label={$t('post.actions.vote.upvote')}
97>
98 <AnimatedHeart {liked} size={18} />
99 <div class="grid text-sm">
100 {#key upvotes}
101 <span
102 style="grid-column: 1; grid-row: 1;"
103 in:fly={{ duration: 400, y: -10, easing: backOut }}
104 out:fly={{ duration: 400, y: 10, easing: backOut }}
105 aria-label={$t('aria.vote.upvotes', { default: upvotes })}
106 >
107 <FormattedNumber number={upvotes} />
108 </span>
109 {/key}
110 </div>
111</button>