personal web client for Bluesky
typescript solidjs bluesky atcute
at trunk 7.9 kB view raw
1import { createMemo } from 'solid-js'; 2 3import { dequal } from '~/api/utils/dequal'; 4 5import { openModal, useModalContext } from '~/globals/modals'; 6 7import { createDerivedSignal } from '~/lib/hooks/derived-signal'; 8import { Key } from '~/lib/keyed'; 9import DraggablePreview from '~/lib/pragmatic-dnd/DraggablePreview'; 10import DropIndicator from '~/lib/pragmatic-dnd/DropIndicator'; 11import { Reorderable, useReorderableItem } from '~/lib/pragmatic-dnd/reorder'; 12import type { 13 SavedFeed, 14 SavedGeneratorFeed, 15 SavedListFeed, 16 SavedSearchFeed, 17} from '~/lib/preferences/account'; 18import { useSession } from '~/lib/states/session'; 19import { assertUnreachable } from '~/lib/utils/invariant'; 20import { snapshot } from '~/lib/utils/state'; 21 22import Avatar from '~/components/avatar'; 23import Button from '~/components/button'; 24import IconButton from '~/components/icon-button'; 25import MagnifyingGlassOutlinedIcon from '~/components/icons-central/magnifying-glass-outline'; 26import MoreHorizOutlinedIcon from '~/components/icons-central/more-horiz-outline'; 27import PinOutlinedIcon from '~/components/icons-central/pin-outline'; 28import PinSolidIcon from '~/components/icons-central/pin-solid'; 29import * as Menu from '~/components/menu'; 30import * as Page from '~/components/page'; 31 32const ExploreFeedsSettingsPage = () => { 33 const { currentAccount } = useSession(); 34 35 const currentFeeds = createMemo(() => { 36 return currentAccount ? snapshot(currentAccount.preferences.feeds) : []; 37 }); 38 39 const [feeds, setFeeds] = createDerivedSignal(currentFeeds); 40 41 const isEqual = createMemo(() => { 42 return dequal(currentFeeds(), feeds()); 43 }); 44 45 const apply = () => { 46 if (currentAccount) { 47 currentAccount.preferences.feeds = feeds(); 48 } 49 }; 50 51 return ( 52 <> 53 <Page.Header> 54 <Page.HeaderAccessory> 55 <Page.Back to="/explore" /> 56 </Page.HeaderAccessory> 57 58 <Page.Heading title="My saved feeds" /> 59 60 <Page.HeaderAccessory> 61 {!isEqual() && ( 62 <Button 63 variant="ghost" 64 onClick={() => { 65 setFeeds(currentFeeds()); 66 }} 67 > 68 Reset 69 </Button> 70 )} 71 72 <Button disabled={isEqual()} onClick={apply} variant="primary"> 73 Save 74 </Button> 75 </Page.HeaderAccessory> 76 </Page.Header> 77 78 <div class="flex flex-col pb-4"> 79 <div class="shrink-0 p-4"> 80 <p class="text-pretty text-sm text-contrast-muted"> 81 Your saved feeds appear in the Explore page. You can rearrange them, pin your favorites to the 82 Home page, or remove them entirely. 83 </p> 84 </div> 85 86 <Reorderable list={feeds()} onReorder={setFeeds}> 87 <Key 88 each={feeds()} 89 by={getFeedId} 90 fallback={<p class="py-6 text-center text-base font-medium">No saved feeds yet.</p>} 91 > 92 {(feed, index) => { 93 const draggable = useReorderableItem({ 94 index, 95 renderPreview: true, 96 }); 97 98 const type = feed().type; 99 100 const isPinned = createMemo(() => { 101 switch (type) { 102 case 'generator': 103 case 'list': { 104 const $feed = feed() as SavedGeneratorFeed | SavedListFeed; 105 return $feed.pinned; 106 } 107 default: { 108 return false; 109 } 110 } 111 }); 112 113 const name = createMemo((): string => { 114 switch (type) { 115 case 'generator': { 116 return (feed() as SavedGeneratorFeed).info.displayName; 117 } 118 case 'list': { 119 return (feed() as SavedListFeed).info.name; 120 } 121 case 'search': { 122 const $feed = feed() as SavedSearchFeed; 123 return $feed.name || $feed.query; 124 } 125 default: { 126 assertUnreachable(type); 127 } 128 } 129 }); 130 131 return ( 132 <div 133 ref={(node) => { 134 draggable.refs.element(node); 135 }} 136 class="relative flex shrink-0 cursor-grab select-none items-center gap-4 px-4 py-3 hover:bg-contrast/sm" 137 > 138 <DropIndicator edge={draggable.edge} /> 139 <DraggablePreview container={draggable.preview}> 140 <div class="flex max-w-64 items-center gap-2 rounded border border-outline bg-background p-2"> 141 {type === 'generator' || type === 'list' ? ( 142 <Avatar 143 type={type} 144 src={(feed() as SavedGeneratorFeed | SavedListFeed).info.avatar} 145 size="sm" 146 /> 147 ) : type === 'search' ? ( 148 <div class="my-0.5 grid h-6 w-6 place-items-center rounded-md bg-accent text-sm text-accent-fg"> 149 <MagnifyingGlassOutlinedIcon /> 150 </div> 151 ) : null} 152 153 <span class="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-bold"> 154 {name()} 155 </span> 156 </div> 157 </DraggablePreview> 158 159 {type === 'generator' || type === 'list' ? ( 160 <Avatar 161 type={type} 162 src={(feed() as SavedGeneratorFeed | SavedListFeed).info.avatar} 163 class="pointer-events-none my-0.5" 164 /> 165 ) : type === 'search' ? ( 166 <div class="my-0.5 grid h-9 w-9 place-items-center rounded-md bg-accent text-xl text-accent-fg"> 167 <MagnifyingGlassOutlinedIcon /> 168 </div> 169 ) : null} 170 171 <div class="min-w-0 grow"> 172 <p class="text-sm font-bold">{name()}</p> 173 174 <p class="overflow-hidden text-ellipsis whitespace-nowrap text-de text-contrast-muted empty:hidden"> 175 {(() => { 176 switch (type) { 177 case 'generator': { 178 return `Feed by @${(feed() as SavedGeneratorFeed).info.creator.handle}`; 179 } 180 case 'list': { 181 return `User list by @${(feed() as SavedListFeed).info.creator.handle}`; 182 } 183 } 184 })()} 185 </p> 186 </div> 187 188 <div class="-mx-2 flex items-center gap-2 empty:hidden"> 189 {(type === 'generator' || type === 'list') && ( 190 <IconButton 191 icon={!isPinned() ? PinOutlinedIcon : PinSolidIcon} 192 title={!isPinned() ? `Pin` : `Unpin`} 193 variant={!isPinned() ? 'ghost' : 'accent'} 194 onClick={() => { 195 const $feed = feed() as SavedGeneratorFeed | SavedListFeed; 196 setFeeds(feeds().with(index(), { ...$feed, pinned: !$feed.pinned })); 197 }} 198 /> 199 )} 200 201 <IconButton 202 icon={MoreHorizOutlinedIcon} 203 title="Actions" 204 onClick={(ev) => { 205 const anchor = ev.currentTarget; 206 207 openModal(() => { 208 const { close } = useModalContext(); 209 210 return ( 211 <Menu.Container anchor={anchor}> 212 <Menu.Item 213 label="Move up" 214 disabled={!draggable.canMove(-1)} 215 onClick={() => { 216 close(); 217 draggable.move(-1); 218 }} 219 /> 220 221 <Menu.Item 222 label="Move down" 223 disabled={!draggable.canMove(1)} 224 onClick={() => { 225 close(); 226 draggable.move(1); 227 }} 228 /> 229 230 <Menu.Divider /> 231 232 <Menu.Item 233 label="Remove" 234 variant="danger" 235 onClick={() => { 236 close(); 237 setFeeds(feeds().toSpliced(index(), 1)); 238 }} 239 /> 240 </Menu.Container> 241 ); 242 }); 243 }} 244 /> 245 </div> 246 </div> 247 ); 248 }} 249 </Key> 250 </Reorderable> 251 </div> 252 </> 253 ); 254}; 255 256export default ExploreFeedsSettingsPage; 257 258const getFeedId = (feed: SavedFeed) => { 259 switch (feed.type) { 260 case 'generator': 261 case 'list': { 262 return `${feed.type}:${feed.info.uri}`; 263 } 264 case 'search': { 265 return `${feed.type}:${feed.query}:${feed.kind}`; 266 } 267 } 268};