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: profile share, login marquee, feed tab color, notification avatar rings

- Add share button to profile overflow menu with native share/clipboard fallback
- Replace login modal subtitle with atmosphere app logos marquee
- Change active feed tab border color to grain accent color
- Add ring around grouped notification avatars matching bg-root/bg-hover

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

+72 -12
+9
app/lib/components/atoms/NotificationItem.svelte
··· 277 277 text-decoration: none; 278 278 position: relative; 279 279 } 280 + .grouped-avatar-link :global(img), 281 + .grouped-avatar-link :global(.avatar) { 282 + box-shadow: 0 0 0 2px var(--bg-root); 283 + transition: box-shadow 0.12s; 284 + } 285 + .notif:hover .grouped-avatar-link :global(img), 286 + .notif:hover .grouped-avatar-link :global(.avatar) { 287 + box-shadow: 0 0 0 2px var(--bg-hover); 288 + } 280 289 .grouped-avatar-link:hover { 281 290 z-index: 1; 282 291 }
+1 -1
app/lib/components/molecules/FeedTabs.svelte
··· 68 68 transition: color 0.15s, border-bottom-color 0.15s, background-color 0.15s; 69 69 } 70 70 .feed-tab:hover { color: var(--text-secondary); background: var(--bg-hover); } 71 - .feed-tab.active { color: var(--text-primary); border-bottom-color: var(--text-primary); font-weight: 600; } 71 + .feed-tab.active { color: var(--text-primary); border-bottom-color: var(--grain); font-weight: 600; } 72 72 </style>
+35 -4
app/lib/components/organisms/LoginModal.svelte
··· 13 13 let debounceTimer: ReturnType<typeof setTimeout> | null = null 14 14 let inputEl: HTMLInputElement | undefined = $state() 15 15 16 + const atmoApps = [ 17 + 'bluesky', 'tangled', 'anisota', 'beacon-bits', 'eurosky', 'flashes', 18 + 'gander', 'germ', 'leaflet', 'northsky', 'offprint', 'pckt', 'plyr', 19 + 'popfeed', 'blento', 'semble', 'skylight', 'blacksky', 'spark', 'stream-place', 20 + ] 21 + 16 22 $effect(() => { 17 23 if (open && inputEl) inputEl.focus() 18 24 }) ··· 89 95 } 90 96 </script> 91 97 92 - <Modal bind:open title="Log in with your internet handle"> 93 - <p class="subtitle">Enter the domain you use as your identity across the open social web. <a href="https://internethandle.org" target="_blank" rel="noopener noreferrer" class="link">Learn more</a></p> 98 + <Modal bind:open title="Log in with your atmosphere account"> 99 + <div class="marquee-wrapper"> 100 + <div class="marquee-track"> 101 + {#each [...atmoApps, ...atmoApps] as app} 102 + <img src="/atmo/atmo-{app}.{app === 'spark' ? 'png' : 'jpg'}" alt={app} class="marquee-logo" /> 103 + {/each} 104 + </div> 105 + </div> 94 106 <div class="input-wrapper"> 95 107 <input 96 108 type="text" ··· 146 158 </Modal> 147 159 148 160 <style> 149 - .subtitle { font-size: 13px; color: var(--text-muted); margin-bottom: 20px; } 150 - .link { color: var(--grain); } 161 + .marquee-wrapper { 162 + overflow: hidden; 163 + margin-bottom: 20px; 164 + } 165 + .marquee-track { 166 + display: flex; 167 + gap: 12px; 168 + width: max-content; 169 + animation: marquee-scroll 60s linear infinite; 170 + } 171 + .marquee-logo { 172 + width: 40px; 173 + height: 40px; 174 + border-radius: 10px; 175 + object-fit: cover; 176 + flex-shrink: 0; 177 + } 178 + @keyframes marquee-scroll { 179 + from { transform: translateX(0); } 180 + to { transform: translateX(-50%); } 181 + } 151 182 .input-wrapper { 152 183 position: relative; 153 184 margin-bottom: 16px;
+27 -7
app/routes/profile/[did]/+page.svelte
··· 8 8 import FollowButton from '$lib/components/molecules/FollowButton.svelte' 9 9 import OverflowMenu from '$lib/components/atoms/OverflowMenu.svelte' 10 10 import RichText from '$lib/components/atoms/RichText.svelte' 11 - import { ArrowUpRight, Grid3x3, Heart, Clock, Ban, VolumeX } from 'lucide-svelte' 11 + import { ArrowUpRight, Grid3x3, Heart, Clock, Ban, VolumeX, Share } from 'lucide-svelte' 12 + import { share } from '$lib/utils/share' 13 + import Toast from '$lib/components/atoms/Toast.svelte' 12 14 import { createQuery, createInfiniteQuery, useQueryClient } from '@tanstack/svelte-query' 13 15 import { actorProfileQuery, actorFeedQuery, actorFavoritesInfiniteQuery, knownFollowersQuery, storiesQuery } from '$lib/queries' 14 16 import { viewer as viewerStore, requireAuth } from '$lib/stores' ··· 84 86 } 85 87 } 86 88 89 + let showToast = $state(false) 90 + 91 + async function handleShare() { 92 + const url = `${window.location.origin}/profile/${did}` 93 + const result = await share(url) 94 + if (result.success && result.method === 'clipboard') { 95 + showToast = true 96 + } 97 + } 98 + 87 99 const blockHide = $derived(!!profile.data?.viewer?.blocking || !!profile.data?.viewer?.blockedBy) 88 100 89 101 const showGermButton = $derived.by(() => { ··· 125 137 <div class="profile-info"> 126 138 <div class="top-row"> 127 139 <Avatar {did} src={p.avatar ?? null} name={p.displayName} size={64} {hasStory} onclick={hasStory ? () => (showStoryViewer = true) : p.avatar ? () => (lightboxSrc = p.avatar!) : undefined} /> 128 - {#if viewerDid && viewerDid !== did} 129 - <div class="actions"> 140 + <div class="actions"> 141 + {#if viewerDid && viewerDid !== did} 130 142 {#if !p.viewer?.blocking && !p.viewer?.blockedBy} 131 143 <FollowButton {did} viewerFollow={p.viewer?.following ?? null} onCountChange={(d) => (followersOffset += d)} /> 132 144 {/if} 133 - <OverflowMenu> 145 + {/if} 146 + <OverflowMenu> 147 + <button class="menu-item" type="button" onclick={handleShare}> 148 + <Share size={15} /> 149 + Share 150 + </button> 151 + {#if viewerDid && viewerDid !== did} 134 152 {#if !blockHide} 135 153 <button class="menu-item" type="button" onclick={handleMute}> 136 154 <VolumeX size={15} /> ··· 141 159 <Ban size={15} /> 142 160 {p.viewer?.blocking ? 'Unblock' : 'Block'} 143 161 </button> 144 - </OverflowMenu> 145 - </div> 146 - {/if} 162 + {/if} 163 + </OverflowMenu> 164 + </div> 147 165 </div> 148 166 <div class="profile-name">{p.displayName || did.slice(0, 18)}</div> 149 167 <div class="handle-row"> ··· 250 268 <StoryViewer initialDid={did} onclose={() => (showStoryViewer = false)} /> 251 269 {/if} 252 270 {/if} 271 + 272 + <Toast message="Link copied" bind:visible={showToast} /> 253 273 254 274 <style> 255 275 .profile-header { border-bottom: 1px solid var(--border); }
static/atmo/atmo-anisota.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-beacon-bits.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-blacksky.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-blento.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-bluesky.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-eurosky.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-flashes.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-gander.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-germ.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-leaflet.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-northsky.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-offprint.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-pckt.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-plyr.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-popfeed.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-semble.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-skylight.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-slices.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-spark.png

This is a binary file and will not be displayed.

static/atmo/atmo-stream-place.jpg

This is a binary file and will not be displayed.

static/atmo/atmo-tangled.jpg

This is a binary file and will not be displayed.