Coves frontend - a photon fork
at main 111 lines 3.1 kB view raw
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>