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: add profile popover on gallery card author hover

Show a Bluesky-style profile card on hover of author avatar/name in
gallery cards. Lazy-loads profile data with a 300ms delay to avoid
unnecessary fetches. Shows avatar, display name, handle, follow status,
stats, bio, and known followers. Hidden on mobile where tap-through
to the profile page is sufficient.

Also fixes three pre-existing type errors: regenerate hatk types to
include labels on StoryView, add missing parseTextToFacets import,
and remove invalid $type from bsky post record.

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

+277 -18
+18 -15
app/lib/components/molecules/GalleryCard.svelte
··· 6 6 import ExifInfo from '../atoms/ExifInfo.svelte' 7 7 import FavoriteButton from './FavoriteButton.svelte' 8 8 import ReportButton from './ReportButton.svelte' 9 + import ProfilePopover from './ProfilePopover.svelte' 9 10 import { relativeTime } from '$lib/utils' 10 11 import { MessageCircle, Send, ChevronLeft, ChevronRight } from 'lucide-svelte' 11 12 import { share } from '$lib/utils/share' ··· 117 118 {/if} 118 119 <div class:content-obscured={labelResult.action === 'warn-content' && !revealed}> 119 120 <header class="card-header"> 120 - <a href="/profile/{gallery.creator?.did}" class="author-chip"> 121 - <Avatar did={gallery.creator?.did ?? ''} src={avatarSrc} size={32} /> 122 - <div class="author-info"> 123 - <span class="author-name-row"> 124 - <span class="author-handle">{displayName}</span> 125 - {#if handle}<span class="author-subtext">{handle}</span>{/if} 126 - </span> 127 - {#if gallery.location} 128 - <!-- svelte-ignore node_invalid_placement_ssr --> 129 - <a class="location-link" href="/location/{encodeURIComponent(gallery.location.value)}?name={encodeURIComponent(gallery.location.name ?? gallery.location.value)}" onclick={(e) => e.stopPropagation()}> 130 - {gallery.location.name ?? gallery.address?.locality ?? gallery.location.value} 131 - </a> 132 - {/if} 133 - </div> 134 - </a> 121 + <ProfilePopover did={gallery.creator?.did ?? ''}> 122 + <a href="/profile/{gallery.creator?.did}" class="author-chip"> 123 + <Avatar did={gallery.creator?.did ?? ''} src={avatarSrc} size={32} /> 124 + <div class="author-info"> 125 + <span class="author-name-row"> 126 + <span class="author-handle">{displayName}</span> 127 + {#if handle}<span class="author-subtext">{handle}</span>{/if} 128 + </span> 129 + {#if gallery.location} 130 + <!-- svelte-ignore node_invalid_placement_ssr --> 131 + <a class="location-link" href="/location/{encodeURIComponent(gallery.location.value)}?name={encodeURIComponent(gallery.location.name ?? gallery.location.value)}" onclick={(e) => e.stopPropagation()}> 132 + {gallery.location.name ?? gallery.address?.locality ?? gallery.location.value} 133 + </a> 134 + {/if} 135 + </div> 136 + </a> 137 + </ProfilePopover> 135 138 </header> 136 139 137 140 {#if photos.length > 0}
+256
app/lib/components/molecules/ProfilePopover.svelte
··· 1 + <script lang="ts"> 2 + import { createQuery } from '@tanstack/svelte-query' 3 + import type { GrainActorDefsProfileViewDetailed, GetKnownFollowersFollowerItem } from '$hatk/client' 4 + import { actorProfileQuery, knownFollowersQuery } from '$lib/queries' 5 + import { viewer } from '$lib/stores' 6 + import Avatar from '../atoms/Avatar.svelte' 7 + import FollowButton from './FollowButton.svelte' 8 + 9 + let { 10 + did, 11 + children, 12 + }: { 13 + did: string 14 + children: import('svelte').Snippet 15 + } = $props() 16 + 17 + let hovering = $state(false) 18 + let hoverTimer: ReturnType<typeof setTimeout> | null = null 19 + let leaveTimer: ReturnType<typeof setTimeout> | null = null 20 + let shouldFetch = $state(false) 21 + 22 + const isOwnProfile = $derived($viewer?.did === did) 23 + 24 + const profile = createQuery(() => ({ 25 + ...actorProfileQuery(did, $viewer?.did), 26 + enabled: shouldFetch, 27 + })) 28 + 29 + const knownFollowers = createQuery(() => ({ 30 + ...knownFollowersQuery(did, $viewer?.did ?? ''), 31 + enabled: shouldFetch && !!$viewer?.did && !isOwnProfile, 32 + })) 33 + 34 + const p = $derived(profile.data as GrainActorDefsProfileViewDetailed | undefined) 35 + const knownList = $derived( 36 + ((knownFollowers.data as { items?: GetKnownFollowersFollowerItem[] } | undefined)?.items) ?? [] 37 + ) 38 + const followedBy = $derived(p?.viewer?.followedBy) 39 + const viewerFollow = $derived(p?.viewer?.following ?? null) 40 + 41 + function handleEnter() { 42 + if (leaveTimer) { clearTimeout(leaveTimer); leaveTimer = null } 43 + hoverTimer = setTimeout(() => { 44 + shouldFetch = true 45 + hovering = true 46 + }, 300) 47 + } 48 + 49 + function handleLeave() { 50 + if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null } 51 + leaveTimer = setTimeout(() => { 52 + hovering = false 53 + }, 200) 54 + } 55 + 56 + function formatCount(n: number): string { 57 + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` 58 + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` 59 + return String(n) 60 + } 61 + </script> 62 + 63 + <!-- svelte-ignore a11y_no_static_element_interactions --> 64 + <span 65 + class="popover-trigger" 66 + onmouseenter={handleEnter} 67 + onmouseleave={handleLeave} 68 + onfocusin={handleEnter} 69 + onfocusout={handleLeave} 70 + > 71 + {@render children()} 72 + {#if hovering && p} 73 + <div class="popover" onmouseenter={handleEnter} onmouseleave={handleLeave}> 74 + <div class="popover-header"> 75 + <a href="/profile/{p.did}" class="popover-avatar-link"> 76 + <Avatar did={p.did} src={p.avatar ?? null} size={48} /> 77 + </a> 78 + {#if !isOwnProfile && $viewer} 79 + <FollowButton did={p.did} {viewerFollow} /> 80 + {/if} 81 + </div> 82 + 83 + <a href="/profile/{p.did}" class="popover-name-link"> 84 + <span class="popover-name">{p.displayName || p.handle || p.did}</span> 85 + </a> 86 + 87 + <div class="popover-meta"> 88 + {#if p.handle} 89 + <span class="popover-handle">@{p.handle}</span> 90 + {/if} 91 + {#if followedBy} 92 + <span class="follows-you">Follows you</span> 93 + {/if} 94 + </div> 95 + 96 + {#if p.description} 97 + <p class="popover-bio">{p.description}</p> 98 + {/if} 99 + 100 + <div class="popover-stats"> 101 + <a href="/profile/{p.did}/followers" class="stat-link"> 102 + <strong>{formatCount(p.followersCount ?? 0)}</strong> <span>followers</span> 103 + </a> 104 + <a href="/profile/{p.did}/following" class="stat-link"> 105 + <strong>{formatCount(p.followsCount ?? 0)}</strong> <span>following</span> 106 + </a> 107 + {#if (p.galleryCount ?? 0) > 0} 108 + <a href="/profile/{p.did}" class="stat-link"> 109 + <strong>{formatCount(p.galleryCount ?? 0)}</strong> <span>galleries</span> 110 + </a> 111 + {/if} 112 + </div> 113 + 114 + {#if knownList.length > 0} 115 + <div class="known-followers"> 116 + <div class="known-avatars"> 117 + {#each knownList.slice(0, 3) as kf} 118 + <Avatar did={kf.did} src={kf.avatar ?? null} size={18} /> 119 + {/each} 120 + </div> 121 + <span class="known-text"> 122 + Followed by {knownList.slice(0, 2).map((kf) => kf.displayName || kf.handle).join(', ')} 123 + {#if knownList.length > 2} 124 + and {knownList.length - 2} other{knownList.length - 2 === 1 ? '' : 's'} you follow 125 + {/if} 126 + </span> 127 + </div> 128 + {/if} 129 + </div> 130 + {/if} 131 + </span> 132 + 133 + <style> 134 + .popover-trigger { 135 + position: relative; 136 + display: inline-flex; 137 + } 138 + 139 + .popover { 140 + position: absolute; 141 + top: 100%; 142 + left: 0; 143 + z-index: 100; 144 + margin-top: 8px; 145 + width: 300px; 146 + background: var(--bg-root); 147 + border: 1px solid var(--border); 148 + border-radius: 12px; 149 + padding: 16px; 150 + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); 151 + cursor: default; 152 + } 153 + @media (max-width: 600px) { 154 + .popover { display: none; } 155 + } 156 + 157 + .popover-header { 158 + display: flex; 159 + align-items: flex-start; 160 + justify-content: space-between; 161 + margin-bottom: 8px; 162 + } 163 + 164 + .popover-avatar-link { 165 + text-decoration: none; 166 + color: inherit; 167 + } 168 + 169 + .popover-name-link { 170 + text-decoration: none; 171 + color: inherit; 172 + } 173 + .popover-name-link:hover .popover-name { 174 + text-decoration: underline; 175 + } 176 + 177 + .popover-name { 178 + font-weight: 700; 179 + font-size: 15px; 180 + color: var(--text-primary); 181 + } 182 + 183 + .popover-meta { 184 + display: flex; 185 + align-items: center; 186 + gap: 6px; 187 + margin-top: 1px; 188 + } 189 + 190 + .popover-handle { 191 + font-size: 13px; 192 + color: var(--text-muted); 193 + } 194 + 195 + .follows-you { 196 + font-size: 11px; 197 + color: var(--text-muted); 198 + background: var(--bg-elevated); 199 + padding: 1px 6px; 200 + border-radius: 4px; 201 + } 202 + 203 + .popover-bio { 204 + margin: 8px 0 0; 205 + font-size: 13px; 206 + color: var(--text-secondary); 207 + line-height: 1.4; 208 + display: -webkit-box; 209 + -webkit-line-clamp: 3; 210 + line-clamp: 3; 211 + -webkit-box-orient: vertical; 212 + overflow: hidden; 213 + } 214 + 215 + .popover-stats { 216 + display: flex; 217 + gap: 12px; 218 + margin-top: 10px; 219 + font-size: 13px; 220 + } 221 + 222 + .stat-link { 223 + text-decoration: none; 224 + color: var(--text-secondary); 225 + } 226 + .stat-link:hover { 227 + text-decoration: underline; 228 + } 229 + .stat-link strong { 230 + color: var(--text-primary); 231 + font-weight: 700; 232 + } 233 + 234 + .known-followers { 235 + display: flex; 236 + align-items: center; 237 + gap: 6px; 238 + margin-top: 10px; 239 + padding-top: 10px; 240 + border-top: 1px solid var(--border); 241 + } 242 + 243 + .known-avatars { 244 + display: flex; 245 + flex-shrink: 0; 246 + } 247 + .known-avatars :global(> *:not(:first-child)) { 248 + margin-left: -4px; 249 + } 250 + 251 + .known-text { 252 + font-size: 12px; 253 + color: var(--text-muted); 254 + line-height: 1.3; 255 + } 256 + </style>
+1 -2
app/lib/utils/bsky-post.ts
··· 90 90 await callXrpc('dev.hatk.createRecord', { 91 91 collection: 'app.bsky.feed.post', 92 92 record: { 93 - $type: 'app.bsky.feed.post', 94 93 text: postText, 95 94 facets: postFacets.length > 0 ? postFacets : undefined, 96 95 embed: imageRefs.length > 0 97 - ? { $type: 'app.bsky.embed.images', images: imageRefs } 96 + ? { $type: 'app.bsky.embed.images' as const, images: imageRefs } 98 97 : undefined, 99 98 tags: ['grainsocial'], 100 99 createdAt: new Date().toISOString(),
+1
app/routes/create/+page.svelte
··· 6 6 import { processPhotos, type ProcessedPhoto } from '$lib/utils/image-resize' 7 7 import { reverseGeocode, formatLocationName, extractAddress } from '$lib/utils/nominatim' 8 8 import { createBskyPost } from '$lib/utils/bsky-post' 9 + import { parseTextToFacets } from '$lib/utils/rich-text' 9 10 import { latLonToH3 } from '$lib/utils/h3' 10 11 import { X, LoaderCircle } from 'lucide-svelte' 11 12 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte'
+1 -1
hatk.generated.ts
··· 58 58 const grainPhotoDefsLex = {"lexicon":1,"id":"social.grain.photo.defs","defs":{"photoView":{"type":"object","required":["uri","cid","thumb","fullsize","aspectRatio"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"thumb":{"type":"string","format":"uri","description":"Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View."},"fullsize":{"type":"string","format":"uri","description":"Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View."},"alt":{"type":"string","description":"Alt text description of the image, for accessibility."},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"exif":{"type":"ref","ref":"social.grain.photo.defs#exifView","description":"EXIF metadata for the photo, if available."},"gallery":{"type":"ref","ref":"#galleryState"}}},"exifView":{"type":"object","required":["uri","cid","photo","record","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"photo":{"type":"string","format":"at-uri"},"record":{"type":"unknown"},"createdAt":{"type":"string","format":"datetime"},"dateTimeOriginal":{"type":"string"},"exposureTime":{"type":"string"},"fNumber":{"type":"string"},"flash":{"type":"string"},"focalLengthIn35mmFormat":{"type":"string"},"iSO":{"type":"integer"},"lensMake":{"type":"string"},"lensModel":{"type":"string"},"make":{"type":"string"},"model":{"type":"string"}}},"galleryState":{"type":"object","required":["item","itemCreatedAt","itemPosition"],"description":"Metadata about the photo's relationship with the subject content. Only has meaningful content when photo is attached to a gallery.","properties":{"item":{"type":"string","format":"at-uri"},"itemCreatedAt":{"type":"string","format":"datetime"},"itemPosition":{"type":"integer"}}}}} as const 59 59 const exifLex = {"lexicon":1,"id":"social.grain.photo.exif","defs":{"main":{"type":"record","description":"Basic EXIF metadata for a photo. Integers are scaled by 1000000 to accommodate decimal values and potentially other tags in the future.","key":"tid","record":{"type":"object","required":["photo","createdAt"],"properties":{"photo":{"type":"string","format":"at-uri"},"createdAt":{"type":"string","format":"datetime"},"dateTimeOriginal":{"type":"string","format":"datetime"},"exposureTime":{"type":"integer"},"fNumber":{"type":"integer"},"flash":{"type":"string"},"focalLengthIn35mmFormat":{"type":"integer"},"iSO":{"type":"integer"},"lensMake":{"type":"string"},"lensModel":{"type":"string"},"make":{"type":"string"},"model":{"type":"string"}}}}}} as const 60 60 const storyLex = {"lexicon":1,"id":"social.grain.story","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["media","aspectRatio","createdAt"],"properties":{"media":{"type":"blob","accept":["image/*","video/*"],"maxSize":5000000},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 61 - const grainStoryDefsLex = {"lexicon":1,"id":"social.grain.story.defs","defs":{"storyView":{"type":"object","required":["uri","cid","creator","thumb","fullsize","aspectRatio","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"creator":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"thumb":{"type":"string","format":"uri","description":"Thumbnail URL for the story image."},"fullsize":{"type":"string","format":"uri","description":"Full-size URL for the story image."},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"createdAt":{"type":"string","format":"datetime"},"crossPost":{"type":"ref","ref":"social.grain.gallery.defs#crossPostInfo"}}}}} as const 61 + const grainStoryDefsLex = {"lexicon":1,"id":"social.grain.story.defs","defs":{"storyView":{"type":"object","required":["uri","cid","creator","thumb","fullsize","aspectRatio","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"creator":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"thumb":{"type":"string","format":"uri","description":"Thumbnail URL for the story image."},"fullsize":{"type":"string","format":"uri","description":"Full-size URL for the story image."},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"createdAt":{"type":"string","format":"datetime"},"labels":{"type":"array","items":{"type":"ref","ref":"com.atproto.label.defs#label"}},"crossPost":{"type":"ref","ref":"social.grain.gallery.defs#crossPostInfo"}}}}} as const 62 62 const deleteGalleryLex = {"lexicon":1,"id":"social.grain.unspecced.deleteGallery","defs":{"main":{"type":"procedure","description":"Delete a gallery and all associated records (items, photos, EXIF, favorites, comments).","input":{"encoding":"application/json","schema":{"type":"object","required":["rkey"],"properties":{"rkey":{"type":"string","description":"Record key of the gallery to delete."}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const 63 63 const getActorProfileLex = {"lexicon":1,"id":"social.grain.unspecced.getActorProfile","defs":{"main":{"type":"query","description":"Get an actor's profile with gallery stats and follow relationships.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"}}},"output":{"encoding":"application/json","schema":{"type":"ref","ref":"social.grain.actor.defs#profileViewDetailed"}}}}} as const 64 64 const getCamerasLex = {"lexicon":1,"id":"social.grain.unspecced.getCameras","defs":{"main":{"type":"query","description":"Get top cameras by photo count.","output":{"encoding":"application/json","schema":{"type":"object","properties":{"cameras":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getCameras#cameraItem"}}}}}},"cameraItem":{"type":"object","required":["camera","photoCount"],"properties":{"camera":{"type":"string"},"photoCount":{"type":"integer"}}}}} as const