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 drag-to-reorder for pinned feeds

Drag handles on the feeds page let you reorder pinned feeds via
desktop drag-and-drop or touch on mobile. Order persists to the
server and the tab bar updates everywhere reactively.

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

+131 -5
+10
app/lib/preferences.ts
··· 92 92 return true; 93 93 } 94 94 95 + export async function reorderFeeds(feeds: PinnedFeed[]): Promise<void> { 96 + const previous = get(pinnedFeeds); 97 + pinnedFeeds.set(feeds); 98 + try { 99 + await callXrpc("dev.hatk.putPreference", { key: "pinnedFeeds", value: feeds }); 100 + } catch { 101 + pinnedFeeds.set(previous); 102 + } 103 + } 104 + 95 105 export function resetPreferences(): void { 96 106 pinnedFeeds.set(DEFAULT_PINNED); 97 107 includeExif.set(true);
+121 -5
app/routes/feeds/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { GripVertical } from 'lucide-svelte' 2 3 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 4 import PinButton from '$lib/components/atoms/PinButton.svelte' 4 - import { pinnedFeeds, DEFAULT_PINNED, feedIcon } from '$lib/preferences' 5 + import { pinnedFeeds, DEFAULT_PINNED, feedIcon, reorderFeeds } from '$lib/preferences' 5 6 import { isAuthenticated } from '$lib/stores' 6 7 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 7 8 8 9 const coreIds = new Set(DEFAULT_PINNED.map((f) => f.id)) 9 10 const customFeeds = $derived($pinnedFeeds.filter((f) => !coreIds.has(f.id))) 11 + 12 + // Drag state 13 + let dragIndex: number | null = $state(null) 14 + let overIndex: number | null = $state(null) 15 + 16 + function handleDragStart(e: DragEvent, index: number) { 17 + dragIndex = index 18 + if (e.dataTransfer) { 19 + e.dataTransfer.effectAllowed = 'move' 20 + e.dataTransfer.setData('text/plain', String(index)) 21 + } 22 + } 23 + 24 + function handleDragOver(e: DragEvent, index: number) { 25 + e.preventDefault() 26 + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' 27 + overIndex = index 28 + } 29 + 30 + function handleDrop(e: DragEvent, index: number) { 31 + e.preventDefault() 32 + if (dragIndex !== null && dragIndex !== index) { 33 + const feeds = [...$pinnedFeeds] 34 + const [moved] = feeds.splice(dragIndex, 1) 35 + feeds.splice(index, 0, moved) 36 + reorderFeeds(feeds) 37 + } 38 + dragIndex = null 39 + overIndex = null 40 + } 41 + 42 + function handleDragEnd() { 43 + dragIndex = null 44 + overIndex = null 45 + } 46 + 47 + // Touch reorder 48 + let touchIndex: number | null = $state(null) 49 + let touchY = $state(0) 50 + let touchStartY = $state(0) 51 + let rowHeight = $state(0) 52 + let listEl: HTMLDivElement | undefined = $state(undefined) 53 + 54 + function handleTouchStart(e: TouchEvent, index: number) { 55 + const touch = e.touches[0] 56 + if (!touch) return 57 + touchIndex = index 58 + touchStartY = touch.clientY 59 + touchY = touch.clientY 60 + const row = (e.currentTarget as HTMLElement).closest('.feed-row') as HTMLElement | null 61 + if (row) rowHeight = row.offsetHeight 62 + } 63 + 64 + function handleTouchMove(e: TouchEvent) { 65 + if (touchIndex === null) return 66 + e.preventDefault() 67 + const touch = e.touches[0] 68 + if (!touch) return 69 + touchY = touch.clientY 70 + const delta = touchY - touchStartY 71 + const indexShift = Math.round(delta / rowHeight) 72 + const newIndex = Math.max(0, Math.min($pinnedFeeds.length - 1, touchIndex + indexShift)) 73 + overIndex = newIndex 74 + } 75 + 76 + function handleTouchEnd() { 77 + if (touchIndex !== null && overIndex !== null && touchIndex !== overIndex) { 78 + const feeds = [...$pinnedFeeds] 79 + const [moved] = feeds.splice(touchIndex, 1) 80 + feeds.splice(overIndex, 0, moved) 81 + reorderFeeds(feeds) 82 + } 83 + touchIndex = null 84 + overIndex = null 85 + } 10 86 </script> 11 87 12 88 <OGMeta title="My Feeds - grain" /> 13 89 <DetailHeader label="My Feeds" /> 14 90 15 - <div class="feeds-page"> 16 - {#each DEFAULT_PINNED as feed (feed.id)} 91 + <div class="feeds-page" bind:this={listEl}> 92 + {#each $pinnedFeeds as feed, i (feed.id)} 17 93 {@const Icon = feedIcon(feed)} 18 - <a href={feed.path} class="feed-row"> 94 + <a 95 + href={feed.path} 96 + class="feed-row" 97 + class:dragging={dragIndex === i || touchIndex === i} 98 + class:drag-over={overIndex === i && dragIndex !== i && touchIndex !== i} 99 + draggable={$isAuthenticated} 100 + ondragstart={(e) => handleDragStart(e, i)} 101 + ondragover={(e) => handleDragOver(e, i)} 102 + ondrop={(e) => handleDrop(e, i)} 103 + ondragend={handleDragEnd} 104 + > 19 105 <span class="feed-icon"> 20 106 <Icon size={18} /> 21 107 </span> 22 108 <span class="feed-label">{feed.label}</span> 23 109 {#if $isAuthenticated} 24 110 <PinButton {feed} stopPropagation /> 111 + <!-- svelte-ignore a11y_no_static_element_interactions --> 112 + <span 113 + class="drag-handle" 114 + onclick={(e) => { e.preventDefault(); e.stopPropagation() }} 115 + ontouchstart={(e) => handleTouchStart(e, i)} 116 + ontouchmove={(e) => handleTouchMove(e)} 117 + ontouchend={handleTouchEnd} 118 + > 119 + <GripVertical size={18} /> 120 + </span> 25 121 {/if} 26 122 </a> 27 123 {/each} ··· 56 152 border-bottom: 1px solid var(--border); 57 153 text-decoration: none; 58 154 color: var(--text-primary); 59 - transition: background 0.12s; 155 + transition: background 0.12s, opacity 0.12s; 60 156 } 61 157 .feed-row:hover { 62 158 background: var(--bg-hover); 63 159 } 160 + .feed-row.dragging { 161 + opacity: 0.4; 162 + } 163 + .feed-row.drag-over { 164 + border-top: 2px solid var(--grain); 165 + } 64 166 .feed-icon { 65 167 width: 36px; 66 168 height: 36px; ··· 80 182 overflow: hidden; 81 183 text-overflow: ellipsis; 82 184 min-width: 0; 185 + } 186 + .drag-handle { 187 + display: flex; 188 + align-items: center; 189 + justify-content: center; 190 + width: 32px; 191 + height: 32px; 192 + color: var(--text-muted); 193 + cursor: grab; 194 + touch-action: none; 195 + flex-shrink: 0; 196 + } 197 + .drag-handle:active { 198 + cursor: grabbing; 83 199 } 84 200 .section-label { 85 201 padding: 12px 16px 4px;