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: story rings on avatars, improved story strip, settings atoms

- Show gradient story ring on gallery card and popover avatars when
author has an active story, tapping opens story viewer
- Redesign "Your story" button to show viewer avatar with plus badge
and create/view menu when user has existing stories
- Filter own user from story author list to avoid duplication
- Extract SettingsGroup and SettingsToggleRow reusable atoms
- Refactor upload-defaults page to use new atoms
- Remove icons from settings hub rows
- Fix Avatar ring padding and hover clipping

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

+208 -79
+5 -2
app/lib/components/atoms/Avatar.svelte
··· 23 23 const fallback = $derived(name?.[0]?.toUpperCase() || initials(did)) 24 24 let imgError = $state(false) 25 25 $effect(() => { void url; imgError = false }) 26 - const innerSize = $derived(hasStory ? size - 6 : size) 26 + const innerSize = $derived(hasStory ? size - 4 : size) 27 27 const fontSize = $derived(Math.round(innerSize * 0.35)) 28 28 </script> 29 29 ··· 67 67 cursor: pointer; 68 68 border-radius: 50%; 69 69 line-height: 0; 70 + display: inline-flex; 71 + align-items: center; 72 + justify-content: center; 70 73 } 71 - .avatar-btn:hover { transform: scale(1.08); } 74 + .avatar-btn:hover { opacity: 0.85; } 72 75 .avatar-wrap { 73 76 display: inline-flex; 74 77 align-items: center;
+27
app/lib/components/atoms/SettingsGroup.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + 4 + let { label, children }: { label?: string; children: Snippet } = $props() 5 + </script> 6 + 7 + {#if label} 8 + <h3 class="section-label">{label}</h3> 9 + {/if} 10 + <div class="settings-group"> 11 + {@render children()} 12 + </div> 13 + 14 + <style> 15 + .section-label { 16 + font-size: 13px; 17 + font-weight: 500; 18 + color: var(--text-muted); 19 + text-transform: uppercase; 20 + margin-bottom: 8px; 21 + } 22 + .settings-group { 23 + border: 1px solid var(--border); 24 + border-radius: 10px; 25 + overflow: hidden; 26 + } 27 + </style>
+38
app/lib/components/atoms/SettingsToggleRow.svelte
··· 1 + <script lang="ts"> 2 + import Checkbox from './Checkbox.svelte' 3 + 4 + let { 5 + checked = $bindable(false), 6 + label, 7 + description, 8 + }: { 9 + checked: boolean 10 + label: string 11 + description?: string 12 + } = $props() 13 + </script> 14 + 15 + <div class="toggle-row"> 16 + <Checkbox bind:checked {label} /> 17 + {#if description} 18 + <span class="toggle-desc">{description}</span> 19 + {/if} 20 + </div> 21 + 22 + <style> 23 + .toggle-row { 24 + display: flex; 25 + flex-direction: column; 26 + gap: 4px; 27 + padding: 14px 16px; 28 + color: var(--text-primary); 29 + } 30 + .toggle-row:not(:last-child) { 31 + border-bottom: 1px solid var(--border); 32 + } 33 + .toggle-desc { 34 + font-size: 12px; 35 + color: var(--text-muted); 36 + padding-left: 28px; 37 + } 38 + </style>
+7 -2
app/lib/components/molecules/GalleryCard.svelte
··· 17 17 import { isAuthenticated, requireAuth, viewer } from '$lib/stores' 18 18 import { resolveLabels, labelDefsQuery } from '$lib/labels' 19 19 import { createQuery, useQueryClient } from '@tanstack/svelte-query' 20 + import { storyAuthorsQuery } from '$lib/queries' 20 21 import { EyeOff, AlertTriangle, Info } from 'lucide-svelte' 21 22 22 - let { gallery, onCommentClick }: { gallery: GalleryView; onCommentClick?: () => void } = $props() 23 + let { gallery, onCommentClick, onStoryTap }: { gallery: GalleryView; onCommentClick?: () => void; onStoryTap?: (did: string) => void } = $props() 23 24 24 25 const queryClient = useQueryClient() 25 26 const isOwner = $derived($viewer?.did === gallery.creator?.did) 27 + const storyAuthors = createQuery(() => storyAuthorsQuery()) 28 + const creatorHasStory = $derived( 29 + storyAuthors.data?.some((a) => a.profile.did === gallery.creator?.did) ?? false 30 + ) 26 31 let deleting = $state(false) 27 32 let reportOpen = $state(false) 28 33 let doFavorite: (() => void) | undefined = $state(undefined) ··· 155 160 <header class="card-header"> 156 161 <ProfilePopover did={gallery.creator?.did ?? ''}> 157 162 <a href="/profile/{gallery.creator?.did}" class="author-chip"> 158 - <Avatar did={gallery.creator?.did ?? ''} src={avatarSrc} size={32} /> 163 + <Avatar did={gallery.creator?.did ?? ''} src={avatarSrc} size={32} hasStory={creatorHasStory} onclick={creatorHasStory && onStoryTap ? () => { onStoryTap!(gallery.creator!.did) } : undefined} /> 159 164 <div class="author-info"> 160 165 <span class="author-name-row"> 161 166 <span class="author-handle">{displayName}</span>
+5 -2
app/lib/components/molecules/ProfilePopover.svelte
··· 1 1 <script lang="ts"> 2 2 import { createQuery } from '@tanstack/svelte-query' 3 3 import type { GrainActorDefsProfileViewDetailed, GetKnownFollowersFollowerItem } from '$hatk/client' 4 - import { actorProfileQuery, knownFollowersQuery } from '$lib/queries' 4 + import { actorProfileQuery, knownFollowersQuery, storyAuthorsQuery } from '$lib/queries' 5 5 import { viewer } from '$lib/stores' 6 6 import Avatar from '../atoms/Avatar.svelte' 7 7 import FollowButton from './FollowButton.svelte' ··· 30 30 ...knownFollowersQuery(did, $viewer?.did ?? ''), 31 31 enabled: shouldFetch && !!$viewer?.did && !isOwnProfile, 32 32 })) 33 + 34 + const storyAuthors = createQuery(() => storyAuthorsQuery()) 35 + const hasStory = $derived(storyAuthors.data?.some((a) => a.profile.did === did) ?? false) 33 36 34 37 const p = $derived(profile.data as GrainActorDefsProfileViewDetailed | undefined) 35 38 const knownList = $derived( ··· 73 76 <div class="popover" onmouseenter={handleEnter} onmouseleave={handleLeave}> 74 77 <div class="popover-header"> 75 78 <a href="/profile/{p.did}" class="popover-avatar-link"> 76 - <Avatar did={p.did} src={p.avatar ?? null} size={48} /> 79 + <Avatar did={p.did} src={p.avatar ?? null} size={48} {hasStory} /> 77 80 </a> 78 81 {#if !isOwnProfile && $viewer} 79 82 <FollowButton did={p.did} {viewerFollow} />
+111 -22
app/lib/components/molecules/StoryStrip.svelte
··· 2 2 import { createQuery } from '@tanstack/svelte-query' 3 3 import { Plus } from 'lucide-svelte' 4 4 import { storyAuthorsQuery } from '$lib/queries' 5 - import { isAuthenticated } from '$lib/stores' 5 + import { isAuthenticated, viewer } from '$lib/stores' 6 6 7 7 let { 8 8 onCreateStory, ··· 13 13 } = $props() 14 14 15 15 const authors = createQuery(() => storyAuthorsQuery()) 16 + 17 + const viewerDid = $derived($viewer?.did) 18 + const viewerAvatar = $derived($viewer?.avatar) 19 + const ownAuthor = $derived(authors.data?.find((a) => a.profile.did === viewerDid)) 20 + const otherAuthors = $derived(authors.data?.filter((a) => a.profile.did !== viewerDid) ?? []) 21 + 22 + let menuOpen = $state(false) 23 + let menuAnchor = $state<HTMLButtonElement | undefined>() 24 + let menuX = $state(0) 25 + let menuY = $state(0) 26 + 27 + function handleOwnTap() { 28 + if (ownAuthor) { 29 + if (menuAnchor) { 30 + const rect = menuAnchor.getBoundingClientRect() 31 + menuX = rect.left 32 + menuY = rect.bottom + 4 33 + } 34 + menuOpen = !menuOpen 35 + } else { 36 + onCreateStory() 37 + } 38 + } 39 + 40 + function handleMenuCreate() { 41 + menuOpen = false 42 + onCreateStory() 43 + } 44 + 45 + function handleMenuView() { 46 + menuOpen = false 47 + if (viewerDid) onViewStory(viewerDid) 48 + } 16 49 </script> 17 50 18 - {#if $isAuthenticated || (authors.data && authors.data.length > 0)} 51 + <svelte:window onclick={() => { if (menuOpen) menuOpen = false }} /> 52 + 53 + {#if $isAuthenticated || otherAuthors.length > 0} 19 54 <div class="story-strip"> 20 55 {#if $isAuthenticated} 21 - <button class="story-circle create" onclick={onCreateStory}> 22 - <div class="avatar-wrapper"> 23 - <div class="plus-icon"><Plus size={20} /></div> 24 - </div> 25 - <span class="label">Your story</span> 26 - </button> 27 - {/if} 28 - {#if authors.data} 29 - {#each authors.data as author (author.profile.did)} 30 - <button class="story-circle" onclick={() => onViewStory(author.profile.did)}> 31 - <div class="avatar-wrapper ring"> 32 - {#if author.profile.avatar} 33 - <img src={author.profile.avatar} alt={author.profile.displayName ?? author.profile.handle} /> 56 + <div class="own-story-wrapper"> 57 + <button class="story-circle" bind:this={menuAnchor} onclick={(e) => { e.stopPropagation(); handleOwnTap() }}> 58 + <div class="avatar-wrapper" class:ring={!!ownAuthor}> 59 + {#if viewerAvatar} 60 + <img src={viewerAvatar} alt="Your story" /> 34 61 {:else} 35 62 <div class="avatar-placeholder"></div> 36 63 {/if} 64 + <div class="plus-badge"><Plus size={12} strokeWidth={3} /></div> 37 65 </div> 38 - <span class="label">{author.profile.displayName ?? author.profile.handle}</span> 66 + <span class="label">Your story</span> 39 67 </button> 40 - {/each} 68 + </div> 41 69 {/if} 70 + {#each otherAuthors as author (author.profile.did)} 71 + <button class="story-circle" onclick={() => onViewStory(author.profile.did)}> 72 + <div class="avatar-wrapper ring"> 73 + {#if author.profile.avatar} 74 + <img src={author.profile.avatar} alt={author.profile.displayName ?? author.profile.handle} /> 75 + {:else} 76 + <div class="avatar-placeholder"></div> 77 + {/if} 78 + </div> 79 + <span class="label">{author.profile.displayName ?? author.profile.handle}</span> 80 + </button> 81 + {/each} 82 + </div> 83 + {/if} 84 + 85 + {#if menuOpen} 86 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 87 + <div class="own-menu" style="left: {menuX}px; top: {menuY}px;" onclick={(e) => e.stopPropagation()}> 88 + <button class="menu-item" onclick={handleMenuCreate}>Create story</button> 89 + <button class="menu-item" onclick={handleMenuView}>View your story</button> 42 90 </div> 43 91 {/if} 44 92 ··· 67 115 width: 56px; 68 116 height: 56px; 69 117 border-radius: 50%; 70 - overflow: hidden; 118 + overflow: visible; 71 119 display: flex; 72 120 align-items: center; 73 121 justify-content: center; 74 122 background: var(--bg-elevated); 123 + position: relative; 75 124 } 76 125 .avatar-wrapper.ring { 77 126 background: linear-gradient(135deg, #c97cf8, var(--grain), #5bf0d6); ··· 89 138 background: var(--bg-hover); 90 139 border-radius: 50%; 91 140 } 92 - .plus-icon { 93 - color: var(--grain); 141 + .plus-badge { 142 + position: absolute; 143 + bottom: -2px; 144 + right: -2px; 145 + width: 20px; 146 + height: 20px; 147 + border-radius: 50%; 148 + background: var(--grain-btn); 149 + color: white; 150 + display: flex; 151 + align-items: center; 152 + justify-content: center; 153 + border: 2px solid var(--bg); 94 154 } 95 - .create .avatar-wrapper { 96 - border: 2px dashed var(--border); 155 + .own-story-wrapper { 156 + position: relative; 157 + flex-shrink: 0; 158 + } 159 + .own-menu { 160 + position: fixed; 161 + background: var(--bg-elevated); 162 + border: 1px solid var(--border); 163 + border-radius: 8px; 164 + overflow: hidden; 165 + z-index: 10; 166 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 167 + min-width: 150px; 168 + } 169 + .menu-item { 170 + display: block; 171 + width: 100%; 172 + padding: 10px 14px; 173 + font-size: 14px; 174 + font-family: var(--font-body); 175 + color: var(--text-primary); 176 + background: none; 177 + border: none; 178 + cursor: pointer; 179 + text-align: left; 180 + } 181 + .menu-item:not(:last-child) { 182 + border-bottom: 1px solid var(--border); 183 + } 184 + .menu-item:hover { 185 + background: var(--bg-hover); 97 186 } 98 187 .label { 99 188 font-size: 11px;
+3 -1
app/lib/components/organisms/FeedList.svelte
··· 15 15 initialItems, 16 16 initialCursor, 17 17 skeleton = false, 18 + onStoryTap, 18 19 }: { 19 20 feed: string 20 21 params?: Record<string, string> 21 22 initialItems?: GalleryView[] 22 23 initialCursor?: string 23 24 skeleton?: boolean 25 + onStoryTap?: (did: string) => void 24 26 } = $props() 25 27 26 28 let items: GalleryView[] = $state([]) ··· 94 96 </div> 95 97 {:else} 96 98 {#each items as item, i (`${item.uri}:${i}`)} 97 - <GalleryCard gallery={item} onCommentClick={() => openComments(item)} /> 99 + <GalleryCard gallery={item} onCommentClick={() => openComments(item)} {onStoryTap} /> 98 100 {#if i === 4 && $isAuthenticated} 99 101 <SuggestedFollows /> 100 102 {/if}
+2 -2
app/routes/+page.svelte
··· 70 70 {#if needsActor && !actorDid} 71 71 <div class="empty">Log in to see this feed.</div> 72 72 {:else if feed.isLoading} 73 - <FeedList feed={firstFeed} params={needsActor ? { actor: actorDid } : undefined} skeleton /> 73 + <FeedList feed={firstFeed} params={needsActor ? { actor: actorDid } : undefined} skeleton onStoryTap={openViewer} /> 74 74 {:else} 75 - <FeedList feed={firstFeed} params={needsActor ? { actor: actorDid } : undefined} initialItems={feed.data?.items ?? []} initialCursor={feed.data?.cursor} /> 75 + <FeedList feed={firstFeed} params={needsActor ? { actor: actorDid } : undefined} initialItems={feed.data?.items ?? []} initialCursor={feed.data?.cursor} onStoryTap={openViewer} /> 76 76 {/if} 77 77 </PullToRefresh> 78 78
+2 -2
app/routes/feeds/recent/+page.svelte
··· 48 48 <PullToRefresh onRefresh={refresh}> 49 49 <StoryStrip onCreateStory={openCreate} onViewStory={openViewer} /> 50 50 {#if feed.isLoading} 51 - <FeedList feed="recent" skeleton /> 51 + <FeedList feed="recent" skeleton onStoryTap={openViewer} /> 52 52 {:else} 53 - <FeedList feed="recent" initialItems={feed.data?.items ?? []} initialCursor={feed.data?.cursor} /> 53 + <FeedList feed="recent" initialItems={feed.data?.items ?? []} initialCursor={feed.data?.cursor} onStoryTap={openViewer} /> 54 54 {/if} 55 55 </PullToRefresh> 56 56
+2 -7
app/routes/settings/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 - import { UserPen, Shield, Bell, ChevronRight, ExternalLink, User, Upload } from 'lucide-svelte' 3 + import { ChevronRight, ExternalLink } from 'lucide-svelte' 4 4 import { viewer } from '$lib/stores' 5 5 import { logout } from '$lib/auth' 6 6 import { resetPreferences } from '$lib/preferences' ··· 19 19 <div class="settings-page"> 20 20 <div class="settings-group"> 21 21 <a href="/settings/account" class="settings-row"> 22 - <User size={18} /> 23 22 <span class="settings-label">Account</span> 24 23 <ChevronRight size={16} class="chevron" /> 25 24 </a> 26 25 <a href="/settings/profile" class="settings-row"> 27 - <UserPen size={18} /> 28 26 <span class="settings-label">Edit Profile</span> 29 27 <ChevronRight size={16} class="chevron" /> 30 28 </a> 31 29 <a href="/settings/notifications" class="settings-row"> 32 - <Bell size={18} /> 33 30 <span class="settings-label">Notifications</span> 34 31 <ChevronRight size={16} class="chevron" /> 35 32 </a> 36 33 <a href="/settings/moderation" class="settings-row"> 37 - <Shield size={18} /> 38 34 <span class="settings-label">Moderation</span> 39 35 <ChevronRight size={16} class="chevron" /> 40 36 </a> 41 - <a href="/settings/upload-defaults" class="settings-row"> 42 - <Upload size={18} /> 37 + <a href="/settings/upload-defaults" class="settings-row"> 43 38 <span class="settings-label">Privacy</span> 44 39 <ChevronRight size={16} class="chevron" /> 45 40 </a>
+6 -39
app/routes/settings/upload-defaults/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { createQuery } from '@tanstack/svelte-query' 3 3 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 4 - import Checkbox from '$lib/components/atoms/Checkbox.svelte' 4 + import SettingsGroup from '$lib/components/atoms/SettingsGroup.svelte' 5 + import SettingsToggleRow from '$lib/components/atoms/SettingsToggleRow.svelte' 5 6 import { setIncludeExif, setIncludeLocation } from '$lib/preferences' 6 7 import { preferencesQuery } from '$lib/queries' 7 8 ··· 36 37 <DetailHeader label="Privacy" /> 37 38 38 39 <div class="settings-page"> 39 - <h3 class="section-label">Defaults for new uploads</h3> 40 - <div class="settings-group"> 41 - <div class="toggle-row"> 42 - <Checkbox bind:checked={localIncludeLocation} label="Include location" /> 43 - <span class="toggle-desc">Auto-detected from photo metadata</span> 44 - </div> 45 - <div class="toggle-row"> 46 - <Checkbox bind:checked={localIncludeExif} label="Include camera data" /> 47 - <span class="toggle-desc">Make, model, and exposure info</span> 48 - </div> 49 - </div> 40 + <SettingsGroup label="Defaults for new uploads"> 41 + <SettingsToggleRow bind:checked={localIncludeLocation} label="Include location" description="Auto-detected from photo metadata" /> 42 + <SettingsToggleRow bind:checked={localIncludeExif} label="Include camera data" description="Make, model, and exposure info" /> 43 + </SettingsGroup> 50 44 </div> 51 45 52 46 <style> 53 - .section-label { 54 - font-size: 13px; 55 - font-weight: 500; 56 - color: var(--text-muted); 57 - text-transform: uppercase; 58 - margin-bottom: 8px; 59 - } 60 47 .settings-page { 61 48 max-width: 600px; 62 49 margin: 0 auto; 63 50 padding: 16px; 64 - } 65 - .settings-group { 66 - border: 1px solid var(--border); 67 - border-radius: 10px; 68 - overflow: hidden; 69 - } 70 - .toggle-row { 71 - display: flex; 72 - flex-direction: column; 73 - gap: 4px; 74 - padding: 14px 16px; 75 - color: var(--text-primary); 76 - } 77 - .toggle-row:not(:last-child) { 78 - border-bottom: 1px solid var(--border); 79 - } 80 - .toggle-desc { 81 - font-size: 12px; 82 - color: var(--text-muted); 83 - padding-left: 28px; 84 51 } 85 52 </style>