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 overflow menu to gallery card for owner delete action

Extract reusable OverflowMenu atom and move gallery delete from the
detail page header into a kebab menu on the GalleryCard itself, making
it accessible from the feed list view.

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

+130 -52
+61
app/lib/components/atoms/OverflowMenu.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + import { EllipsisVertical } from 'lucide-svelte' 4 + 5 + let { children }: { children: Snippet } = $props() 6 + 7 + let open = $state(false) 8 + </script> 9 + 10 + <div class="overflow-menu"> 11 + <button class="overflow-btn" type="button" onclick={(e) => { e.stopPropagation(); open = !open }} aria-label="More options"> 12 + <EllipsisVertical size={18} /> 13 + </button> 14 + {#if open} 15 + <!-- svelte-ignore a11y_no_static_element_interactions --> 16 + <div class="overflow-backdrop" onclick={() => (open = false)}></div> 17 + <div class="overflow-dropdown"> 18 + {@render children()} 19 + </div> 20 + {/if} 21 + </div> 22 + 23 + <style> 24 + .overflow-menu { 25 + position: relative; 26 + flex-shrink: 0; 27 + } 28 + .overflow-btn { 29 + background: none; 30 + border: none; 31 + color: var(--text-muted); 32 + cursor: pointer; 33 + padding: 4px; 34 + display: flex; 35 + align-items: center; 36 + border-radius: 50%; 37 + transition: background 0.15s, color 0.15s; 38 + } 39 + .overflow-btn:hover { 40 + background: var(--bg-hover); 41 + color: var(--text-primary); 42 + } 43 + .overflow-backdrop { 44 + position: fixed; 45 + inset: 0; 46 + z-index: 99; 47 + } 48 + .overflow-dropdown { 49 + position: absolute; 50 + top: 100%; 51 + right: 0; 52 + z-index: 100; 53 + min-width: 160px; 54 + background: var(--bg-elevated); 55 + border: 1px solid var(--border); 56 + border-radius: 8px; 57 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 58 + padding: 4px; 59 + margin-top: 4px; 60 + } 61 + </style>
+68 -4
app/lib/components/molecules/GalleryCard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { GalleryView, PhotoView, ExifView } from '$hatk/client' 3 + import { callXrpc } from '$hatk/client' 4 + import { goto } from '$app/navigation' 3 5 import Avatar from '../atoms/Avatar.svelte' 4 6 import RichText from '../atoms/RichText.svelte' 5 7 import Toast from '../atoms/Toast.svelte' ··· 8 10 import ReportButton from './ReportButton.svelte' 9 11 import ProfilePopover from './ProfilePopover.svelte' 10 12 import { relativeTime } from '$lib/utils' 11 - import { MessageCircle, Send, ChevronLeft, ChevronRight } from 'lucide-svelte' 13 + import { MessageCircle, Send, ChevronLeft, ChevronRight, Trash2 } from 'lucide-svelte' 14 + import OverflowMenu from '../atoms/OverflowMenu.svelte' 12 15 import { share } from '$lib/utils/share' 13 16 import { browser } from '$app/environment' 14 - import { isAuthenticated, requireAuth } from '$lib/stores' 17 + import { isAuthenticated, requireAuth, viewer } from '$lib/stores' 15 18 import { resolveLabels, labelDefsQuery } from '$lib/labels' 16 - import { createQuery } from '@tanstack/svelte-query' 19 + import { createQuery, useQueryClient } from '@tanstack/svelte-query' 17 20 import { EyeOff, AlertTriangle } from 'lucide-svelte' 18 21 19 22 let { gallery, onCommentClick }: { gallery: GalleryView; onCommentClick?: (focusPhoto: PhotoView | null) => void } = $props() 23 + 24 + const queryClient = useQueryClient() 25 + const isOwner = $derived($viewer?.did === gallery.creator?.did) 26 + let deleting = $state(false) 27 + 28 + async function deleteGallery() { 29 + if (deleting) return 30 + if (!confirm('Delete this gallery? This cannot be undone.')) return 31 + 32 + const rkey = gallery.uri.split('/').pop() 33 + deleting = true 34 + try { 35 + await callXrpc('social.grain.unspecced.deleteGallery', { rkey }) 36 + queryClient.invalidateQueries({ queryKey: ['getFeed'] }) 37 + goto(`/profile/${gallery.creator?.did}`) 38 + } catch (err) { 39 + console.error('Failed to delete gallery:', err) 40 + alert('Failed to delete gallery. Please try again.') 41 + } finally { 42 + deleting = false 43 + } 44 + } 20 45 21 46 const isDesktop = browser ? window.matchMedia('(min-width: 768px)').matches : false 22 47 ··· 135 160 </div> 136 161 </a> 137 162 </ProfilePopover> 163 + {#if isOwner} 164 + <OverflowMenu> 165 + <button class="menu-item delete" type="button" onclick={deleteGallery} disabled={deleting}> 166 + <Trash2 size={15} /> 167 + Delete gallery 168 + </button> 169 + </OverflowMenu> 170 + {/if} 138 171 </header> 139 172 140 173 {#if photos.length > 0} ··· 239 272 } 240 273 241 274 /* Header */ 242 - .card-header { padding: 12px 16px; } 275 + .card-header { padding: 12px 16px; display: flex; align-items: center; } 243 276 .author-chip { 244 277 display: flex; 245 278 align-items: center; ··· 272 305 overflow: hidden; 273 306 text-overflow: ellipsis; 274 307 flex-shrink: 1; 308 + } 309 + 310 + .card-header :global(.overflow-menu) { 311 + margin-left: auto; 312 + } 313 + 314 + /* Menu items (inside OverflowMenu) */ 315 + .menu-item { 316 + display: flex; 317 + align-items: center; 318 + gap: 8px; 319 + width: 100%; 320 + padding: 8px 12px; 321 + border: none; 322 + background: none; 323 + color: var(--text-primary); 324 + font-size: 13px; 325 + font-family: inherit; 326 + cursor: pointer; 327 + border-radius: 6px; 328 + transition: background 0.15s; 329 + } 330 + .menu-item:hover { 331 + background: var(--bg-hover); 332 + } 333 + .menu-item.delete { 334 + color: #f87171; 335 + } 336 + .menu-item:disabled { 337 + opacity: 0.5; 338 + cursor: not-allowed; 275 339 } 276 340 277 341 /* Carousel — matches grain-next's grain-image-carousel */
+1 -48
app/routes/profile/[did]/gallery/[rkey]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { goto } from '$app/navigation' 3 - import { createQuery, useQueryClient } from '@tanstack/svelte-query' 4 - import { callXrpc } from '$hatk/client' 2 + import { createQuery } from '@tanstack/svelte-query' 5 3 import { galleryQuery } from '$lib/queries' 6 - import { viewer } from '$lib/stores' 7 4 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 8 5 import GalleryCard from '$lib/components/molecules/GalleryCard.svelte' 9 6 import CommentSheet from '$lib/components/organisms/CommentSheet.svelte' 10 7 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 11 - import { Trash2 } from 'lucide-svelte' 12 8 import BskyIcon from '$lib/components/atoms/BskyIcon.svelte' 13 9 import type { GalleryView, PhotoView } from '$hatk/client' 14 10 15 11 let { data } = $props() 16 - const queryClient = useQueryClient() 17 12 18 13 const did = $derived(data.did) 19 14 const rkey = $derived(data.rkey) ··· 22 17 const gallery = $derived((galleryQ.data as GalleryView) ?? null) 23 18 const bskyUrl = $derived((gallery as any)?.crossPost?.url ?? null) 24 19 25 - const isOwner = $derived($viewer?.did === gallery?.creator?.did) 26 - 27 20 let commentSheetOpen = $state(false) 28 21 let focusPhotoUri = $state<string | null>(null) 29 22 let focusPhotoThumb = $state<string | null>(null) 30 - let deleting = $state(false) 31 23 32 24 function openComments(focusPhoto: PhotoView | null) { 33 25 if (focusPhoto) { ··· 39 31 } 40 32 commentSheetOpen = true 41 33 } 42 - 43 - async function deleteGallery() { 44 - if (!gallery || deleting) return 45 - if (!confirm('Delete this gallery? This cannot be undone.')) return 46 - 47 - deleting = true 48 - try { 49 - await callXrpc('social.grain.unspecced.deleteGallery', { rkey }) 50 - queryClient.invalidateQueries({ queryKey: ['getFeed'] }) 51 - goto(`/profile/${did}`) 52 - } catch (err) { 53 - console.error('Failed to delete gallery:', err) 54 - alert('Failed to delete gallery. Please try again.') 55 - } finally { 56 - deleting = false 57 - } 58 - } 59 34 </script> 60 35 61 36 <OGMeta ··· 69 44 <a class="bsky-link" href={bskyUrl} target="_blank" rel="noopener noreferrer" title="View on Bluesky"> 70 45 <BskyIcon /> 71 46 </a> 72 - {/if} 73 - {#if isOwner} 74 - <button class="delete-btn" type="button" onclick={deleteGallery} disabled={deleting} aria-label="Delete gallery"> 75 - <Trash2 size={18} /> 76 - </button> 77 47 {/if} 78 48 {/snippet} 79 49 </DetailHeader> ··· 116 86 } 117 87 .bsky-link:hover { 118 88 color: #0085ff; 119 - } 120 - .delete-btn { 121 - background: none; 122 - border: none; 123 - color: var(--text-muted); 124 - cursor: pointer; 125 - padding: 4px; 126 - display: flex; 127 - align-items: center; 128 - transition: color 0.15s; 129 - } 130 - .delete-btn:hover { 131 - color: #f87171; 132 - } 133 - .delete-btn:disabled { 134 - opacity: 0.5; 135 - cursor: not-allowed; 136 89 } 137 90 </style>