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: bulk select and delete galleries on profile page

Add select mode to gallery grid with SelectCheck atom, floating action
bar with confirmation dialog, progress tracking, and partial failure
handling. Refactor import page to share the same SelectCheck component.

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

+224 -34
+34
app/lib/components/atoms/SelectCheck.svelte
··· 1 + <script lang="ts"> 2 + import { Check } from 'lucide-svelte' 3 + 4 + let { checked = false, onMedia = false }: { checked?: boolean; onMedia?: boolean } = $props() 5 + </script> 6 + 7 + {#if checked} 8 + <div class="check-on"><Check size={14} /></div> 9 + {:else} 10 + <div class="check-off" class:on-media={onMedia}></div> 11 + {/if} 12 + 13 + <style> 14 + .check-on { 15 + width: 22px; 16 + height: 22px; 17 + border-radius: 50%; 18 + background: var(--grain); 19 + color: #fff; 20 + display: flex; 21 + align-items: center; 22 + justify-content: center; 23 + } 24 + .check-off { 25 + width: 22px; 26 + height: 22px; 27 + border-radius: 50%; 28 + border: 2px solid var(--border); 29 + } 30 + .check-off.on-media { 31 + border-color: #fff; 32 + background: rgba(0, 0, 0, 0.3); 33 + } 34 + </style>
+27 -1
app/lib/components/organisms/GalleryGrid.svelte
··· 5 5 import { resolveLabels, labelDefsQuery } from '$lib/labels' 6 6 import { createQuery } from '@tanstack/svelte-query' 7 7 import { Info } from 'lucide-svelte' 8 + import SelectCheck from '../atoms/SelectCheck.svelte' 8 9 import { infiniteScroll } from '$lib/actions/infinite-scroll' 9 10 10 11 const labelDefs = createQuery(() => labelDefsQuery()) ··· 16 17 hasMore = false, 17 18 loadingMore = false, 18 19 onLoadMore, 20 + selectMode = false, 21 + selectedUris = new Set<string>(), 22 + onToggle, 19 23 }: { 20 24 items: GalleryView[] 21 25 loading?: boolean ··· 23 27 hasMore?: boolean 24 28 loadingMore?: boolean 25 29 onLoadMore?: () => void 30 + selectMode?: boolean 31 + selectedUris?: Set<string> 32 + onToggle?: (uri: string) => void 26 33 } = $props() 27 34 28 35 function thumb(gallery: GalleryView): string | undefined { ··· 51 58 <div class="grid"> 52 59 {#each items as gallery, i (`${gallery.uri}:${i}`)} 53 60 {@const lr = resolveLabels(gallery.labels, labelDefs.data ?? [])} 54 - <a class="cell" href="/profile/{gallery.creator?.did}/gallery/{rkey(gallery.uri)}"> 61 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 62 + <a 63 + class="cell" 64 + href={selectMode ? undefined : `/profile/${gallery.creator?.did}/gallery/${rkey(gallery.uri)}`} 65 + role={selectMode ? 'button' : undefined} 66 + tabindex={selectMode ? 0 : undefined} 67 + onclick={selectMode ? (e) => { e.preventDefault(); onToggle?.(gallery.uri) } : undefined} 68 + > 55 69 {#if lr.action === 'warn-media' || lr.action === 'warn-content' || lr.action === 'hide'} 56 70 <div class="label-cover"> 57 71 <Info size={14} /> ··· 76 90 {/if} 77 91 <div class="overlay"> 78 92 <span class="overlay-title">{gallery.title}</span> 93 + </div> 94 + {/if} 95 + {#if selectMode} 96 + <div class="select-check"> 97 + <SelectCheck checked={selectedUris.has(gallery.uri)} onMedia /> 79 98 </div> 80 99 {/if} 81 100 </a> ··· 152 171 color: var(--text-secondary); 153 172 font-size: 11px; 154 173 font-weight: 500; 174 + } 175 + .select-check { 176 + position: absolute; 177 + top: 6px; 178 + left: 6px; 179 + z-index: 2; 180 + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); 155 181 } 156 182 .sentinel { 157 183 display: flex;
+161 -12
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, Share } from 'lucide-svelte' 11 + import { ArrowUpRight, Grid3x3, Heart, Clock, Ban, VolumeX, Share, Trash2, X, LoaderCircle } from 'lucide-svelte' 12 + import { callXrpc } from '$hatk/client' 12 13 import { share } from '$lib/utils/share' 13 14 import Toast from '$lib/components/atoms/Toast.svelte' 14 15 import { createQuery, createInfiniteQuery, useQueryClient } from '@tanstack/svelte-query' ··· 38 39 $effect(() => { void did; void profile.data; followersOffset = 0 }) 39 40 40 41 function setTab(tab: ViewMode) { 42 + exitSelectMode() 41 43 const url = new URL(page.url) 42 44 if (tab === 'grid') { 43 45 url.searchParams.delete('tab') ··· 87 89 } 88 90 89 91 let showToast = $state(false) 92 + let toastMessage = $state('Link copied') 93 + 94 + // Bulk select & delete 95 + let selectMode = $state(false) 96 + let selectedUris = $state(new Set<string>()) 97 + let deleting = $state(false) 98 + let deleteProgress = $state({ current: 0, total: 0 }) 99 + 100 + function toggleSelect(uri: string) { 101 + const next = new Set(selectedUris) 102 + if (next.has(uri)) next.delete(uri) 103 + else next.add(uri) 104 + selectedUris = next 105 + } 106 + 107 + function exitSelectMode() { 108 + selectMode = false 109 + selectedUris = new Set() 110 + } 111 + 112 + async function bulkDelete() { 113 + if (selectedUris.size === 0) return 114 + const count = selectedUris.size 115 + if (!confirm(`Delete ${count} ${count === 1 ? 'gallery' : 'galleries'}? This cannot be undone.`)) return 116 + deleting = true 117 + const uris = [...selectedUris] 118 + deleteProgress = { current: 0, total: uris.length } 119 + let deleted = 0 120 + const failed = new Set<string>() 121 + for (const uri of uris) { 122 + const rkey = uri.split('/').pop()! 123 + try { 124 + await callXrpc('social.grain.unspecced.deleteGallery', { rkey }) 125 + deleted++ 126 + } catch (err) { 127 + console.error('Failed to delete gallery:', err) 128 + failed.add(uri) 129 + } 130 + deleteProgress.current++ 131 + } 132 + deleting = false 133 + queryClient.invalidateQueries({ queryKey: ['getFeed'] }) 134 + if (failed.size > 0) { 135 + selectedUris = failed 136 + toastMessage = `Deleted ${deleted} of ${count}. ${failed.size} failed.` 137 + } else { 138 + exitSelectMode() 139 + toastMessage = `Deleted ${deleted} ${deleted === 1 ? 'gallery' : 'galleries'}` 140 + } 141 + showToast = true 142 + } 90 143 91 144 async function handleShare() { 92 145 const url = `${window.location.origin}/profile/${did}` 93 146 const result = await share(url) 94 147 if (result.success && result.method === 'clipboard') { 148 + toastMessage = 'Link copied' 95 149 showToast = true 96 150 } 97 151 } ··· 114 168 }) 115 169 </script> 116 170 171 + <div class="page-wrapper"> 117 172 {#if profile.isLoading} 118 173 <DetailHeader label={'\u00A0'} /> 119 174 <div class="profile-header"> ··· 234 289 235 290 {#if !blockHide} 236 291 <div class="view-toggle"> 237 - <button class="toggle-btn" class:active={viewMode === 'grid'} onclick={() => setTab('grid')} aria-label="Grid view"> 238 - <Grid3x3 size={20} /> 239 - </button> 240 - {#if isOwnProfile} 241 - <button class="toggle-btn" class:active={viewMode === 'favorites'} onclick={() => setTab('favorites')} aria-label="Favorites"> 242 - <Heart size={20} /> 292 + <div class="toggle-tabs"> 293 + <button class="toggle-btn" class:active={viewMode === 'grid'} onclick={() => setTab('grid')} aria-label="Grid view"> 294 + <Grid3x3 size={20} /> 243 295 </button> 244 - <button class="toggle-btn" class:active={viewMode === 'stories'} onclick={() => setTab('stories')} aria-label="Story archive"> 245 - <Clock size={20} /> 246 - </button> 296 + {#if isOwnProfile} 297 + <button class="toggle-btn" class:active={viewMode === 'favorites'} onclick={() => setTab('favorites')} aria-label="Favorites"> 298 + <Heart size={20} /> 299 + </button> 300 + <button class="toggle-btn" class:active={viewMode === 'stories'} onclick={() => setTab('stories')} aria-label="Story archive"> 301 + <Clock size={20} /> 302 + </button> 303 + {/if} 304 + </div> 305 + {#if isOwnProfile && viewMode === 'grid' && !selectMode} 306 + <button class="select-text-btn" onclick={() => (selectMode = true)}>Select</button> 247 307 {/if} 248 308 </div> 249 309 ··· 265 325 hasMore={feed.hasNextPage} 266 326 loadingMore={feed.isFetchingNextPage} 267 327 onLoadMore={() => feed.fetchNextPage()} 328 + {selectMode} 329 + {selectedUris} 330 + onToggle={toggleSelect} 268 331 /> 269 332 {/if} 270 333 {/if} ··· 274 337 {/if} 275 338 {/if} 276 339 277 - <Toast message="Link copied" bind:visible={showToast} /> 340 + {#if selectMode} 341 + <div class="floating-bar"> 342 + {#if deleting} 343 + <LoaderCircle size={16} class="spin" /> 344 + <span class="bar-text">Deleting {deleteProgress.current} / {deleteProgress.total}...</span> 345 + {:else} 346 + <button class="bar-btn cancel" onclick={exitSelectMode}> 347 + <X size={16} /> 348 + Cancel 349 + </button> 350 + <span class="bar-text">{selectedUris.size} selected</span> 351 + <button class="bar-btn delete" onclick={bulkDelete} disabled={selectedUris.size === 0}> 352 + <Trash2 size={16} /> 353 + Delete 354 + </button> 355 + {/if} 356 + </div> 357 + {/if} 358 + </div> 359 + 360 + <Toast message={toastMessage} bind:visible={showToast} /> 278 361 279 362 <style> 363 + .page-wrapper { 364 + display: flex; 365 + flex-direction: column; 366 + min-height: 100%; 367 + } 280 368 .profile-header { border-bottom: 1px solid var(--border); } 281 369 .profile-header { position: relative; } 282 370 .actions { position: absolute; top: 12px; right: 16px; display: flex; gap: 8px; align-items: center; z-index: 1; } ··· 325 413 .germ-logo { width: 14px; height: 14px; object-fit: contain; } 326 414 .view-toggle { 327 415 display: flex; 416 + align-items: center; 328 417 justify-content: center; 329 - gap: 4px; 330 418 padding: 8px 16px; 331 419 border-bottom: 1px solid var(--border); 420 + position: relative; 332 421 } 422 + .toggle-tabs { 423 + display: flex; 424 + gap: 4px; 425 + } 426 + .select-text-btn { 427 + position: absolute; 428 + right: 16px; 429 + background: none; 430 + border: none; 431 + font-size: 13px; 432 + font-weight: 500; 433 + font-family: inherit; 434 + color: var(--grain); 435 + cursor: pointer; 436 + padding: 4px 0; 437 + } 438 + .select-text-btn:hover { opacity: 0.8; } 333 439 .toggle-btn { 334 440 display: flex; 335 441 align-items: center; ··· 398 504 .menu-item:hover { background: var(--bg-hover); } 399 505 .menu-item.danger { color: #f87171; } 400 506 .menu-divider { height: 1px; background: var(--border); margin: 4px 0; } 507 + .floating-bar { 508 + position: sticky; 509 + bottom: 0; 510 + margin-top: auto; 511 + display: flex; 512 + align-items: center; 513 + justify-content: center; 514 + gap: 16px; 515 + padding: 12px 16px; 516 + background: rgba(8, 11, 18, 0.92); 517 + backdrop-filter: blur(16px); 518 + -webkit-backdrop-filter: blur(16px); 519 + border-top: 1px solid var(--border); 520 + z-index: 100; 521 + } 522 + .bar-text { 523 + font-size: 14px; 524 + font-weight: 600; 525 + color: var(--text-primary); 526 + } 527 + .bar-btn { 528 + display: flex; 529 + align-items: center; 530 + gap: 6px; 531 + padding: 8px 16px; 532 + border: none; 533 + border-radius: 8px; 534 + font-size: 13px; 535 + font-weight: 600; 536 + font-family: inherit; 537 + cursor: pointer; 538 + transition: opacity 0.15s; 539 + } 540 + .bar-btn:disabled { opacity: 0.4; cursor: not-allowed; } 541 + .bar-btn.cancel { 542 + background: var(--bg-elevated); 543 + color: var(--text-primary); 544 + } 545 + .bar-btn.delete { 546 + background: #dc2626; 547 + color: #fff; 548 + } 549 + .bar-btn:not(:disabled):hover { opacity: 0.85; } 401 550 </style>
+2 -21
app/routes/settings/import/+page.svelte
··· 8 8 import Button from '$lib/components/atoms/Button.svelte' 9 9 import ContentWarningPicker from '$lib/components/atoms/ContentWarningPicker.svelte' 10 10 import { LoaderCircle, Check, ImageIcon, X } from 'lucide-svelte' 11 + import SelectCheck from '$lib/components/atoms/SelectCheck.svelte' 11 12 import { viewer } from '$lib/stores' 12 13 13 14 function galleryTitle(date: Date): string { ··· 227 228 {#each posts as post, i} 228 229 <div class="post-card" class:deselected={!post.selected}> 229 230 <button class="post-check" type="button" onclick={() => togglePost(i)}> 230 - {#if post.selected} 231 - <div class="check-on"><Check size={14} /></div> 232 - {:else} 233 - <div class="check-off"></div> 234 - {/if} 231 + <SelectCheck checked={post.selected} /> 235 232 </button> 236 233 <div class="post-content"> 237 234 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> ··· 416 413 } 417 414 .post-labels { 418 415 margin-top: 4px; 419 - } 420 - .check-on { 421 - width: 22px; 422 - height: 22px; 423 - border-radius: 50%; 424 - background: var(--grain); 425 - color: #fff; 426 - display: flex; 427 - align-items: center; 428 - justify-content: center; 429 - } 430 - .check-off { 431 - width: 22px; 432 - height: 22px; 433 - border-radius: 50%; 434 - border: 2px solid var(--border); 435 416 } 436 417 .post-content { 437 418 flex: 1;