grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: double-click/tap photo to favorite with heart animation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+41 -2
+10
app/lib/components/molecules/FavoriteButton.svelte
··· 8 8 galleryUri, 9 9 viewerFav = null, 10 10 favCount = 0, 11 + favorite = $bindable(undefined), 11 12 }: { 12 13 galleryUri: string 13 14 viewerFav?: string | null 14 15 favCount?: number 16 + favorite?: () => void 15 17 } = $props() 16 18 17 19 let favOverride: string | null | undefined = $state(undefined) ··· 62 64 favOverride = context?.prev ?? undefined 63 65 }, 64 66 })) 67 + 68 + function doFavorite() { 69 + if (isFaved || createFavMut.isPending || deleteFavMut.isPending) return 70 + if (!requireAuth()) return 71 + createFavMut.mutate() 72 + } 73 + 74 + $effect(() => { favorite = doFavorite }) 65 75 </script> 66 76 67 77 {#if isFaved}
+31 -2
app/lib/components/molecules/GalleryCard.svelte
··· 10 10 import ReportButton from './ReportButton.svelte' 11 11 import ProfilePopover from './ProfilePopover.svelte' 12 12 import { relativeTime } from '$lib/utils' 13 - import { MessageCircle, Send, ChevronLeft, ChevronRight, Trash2 } from 'lucide-svelte' 13 + import { MessageCircle, Send, ChevronLeft, ChevronRight, Trash2, Heart } from 'lucide-svelte' 14 14 import OverflowMenu from '../atoms/OverflowMenu.svelte' 15 15 import { share } from '$lib/utils/share' 16 16 import { browser } from '$app/environment' ··· 24 24 const queryClient = useQueryClient() 25 25 const isOwner = $derived($viewer?.did === gallery.creator?.did) 26 26 let deleting = $state(false) 27 + let doFavorite: (() => void) | undefined = $state(undefined) 28 + let showHeartAnim = $state(false) 27 29 28 30 async function deleteGallery() { 29 31 if (deleting) return ··· 185 187 <button class="media-warning-show" onclick={() => (revealed = true)}>Show</button> 186 188 </div> 187 189 {/if} 190 + <!-- svelte-ignore a11y_no_static_element_interactions --> 188 191 <div 189 192 class="carousel" 190 193 bind:this={carouselEl} 191 194 onscroll={onScroll} 195 + ondblclick={() => { doFavorite?.(); showHeartAnim = true; setTimeout(() => (showHeartAnim = false), 800) }} 192 196 style={hasPortrait ? `aspect-ratio: ${minRatio};` : ''} 193 197 > 194 198 {#each photos as photo, i} ··· 233 237 {/each} 234 238 </div> 235 239 {/if} 240 + 241 + {#if showHeartAnim} 242 + <div class="heart-anim"> 243 + <Heart size={64} fill="currentColor" /> 244 + </div> 245 + {/if} 236 246 </div> 237 247 {/if} 238 248 239 249 <div class="engagement"> 240 - <FavoriteButton galleryUri={gallery.uri} viewerFav={gallery.viewer?.fav ?? null} {favCount} /> 250 + <FavoriteButton galleryUri={gallery.uri} viewerFav={gallery.viewer?.fav ?? null} {favCount} bind:favorite={doFavorite} /> 241 251 <button class="stat" type="button" onclick={() => requireAuth() && onCommentClick?.(photos[currentIndex] ?? null)}> 242 252 <MessageCircle size={20} /> 243 253 {#if commentCount > 0}<span class="stat-count">{commentCount}</span>{/if} ··· 357 367 .carousel-host { 358 368 display: block; 359 369 position: relative; 370 + } 371 + .heart-anim { 372 + position: absolute; 373 + inset: 0; 374 + display: flex; 375 + align-items: center; 376 + justify-content: center; 377 + pointer-events: none; 378 + color: #f87171; 379 + animation: heart-pop 0.8s ease-out forwards; 380 + z-index: 5; 381 + } 382 + @keyframes heart-pop { 383 + 0% { opacity: 0; transform: scale(0); } 384 + 15% { opacity: 1; transform: scale(1.2); } 385 + 30% { transform: scale(0.95); } 386 + 45% { transform: scale(1); } 387 + 70% { opacity: 1; } 388 + 100% { opacity: 0; transform: scale(1); } 360 389 } 361 390 .carousel { 362 391 display: flex;