personal web client for Bluesky
typescript
solidjs
bluesky
atcute
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};