tangled
alpha
login
or
join now
bunware.org
/
pin.to.it
6
fork
atom
Scrapboard.org client
6
fork
atom
overview
issues
pulls
pipelines
feat: boards
TurtlePaw
7 months ago
1549b74b
5d88b046
+675
-105
16 changed files
expand all
collapse all
unified
split
bun.lock
package.json
src
app
[did]
[uri]
page.tsx
layout.tsx
page.tsx
components
BoardPicker.tsx
Feed.tsx
SaveButton.tsx
ui
command.tsx
popover.tsx
sonner.tsx
constants.ts
lib
hooks
useBoards.tsx
useTimeline.tsx
stores
boards.tsx
feedDefs.tsx
+33
-1
bun.lock
···
10
10
"@radix-ui/react-avatar": "^1.1.10",
11
11
"@radix-ui/react-dialog": "^1.1.14",
12
12
"@radix-ui/react-dropdown-menu": "^2.1.15",
13
13
+
"@radix-ui/react-popover": "^1.1.14",
13
14
"@radix-ui/react-scroll-area": "^1.2.9",
14
15
"@radix-ui/react-slot": "^1.2.3",
15
16
"@radix-ui/react-tabs": "^1.1.12",
16
17
"class-variance-authority": "^0.7.1",
17
18
"clsx": "^2.1.1",
19
19
+
"cmdk": "^1.1.1",
18
20
"lucide-react": "^0.526.0",
19
21
"motion": "^12.23.11",
20
22
"next": "15.4.4",
···
22
24
"react": "19.1.0",
23
25
"react-dom": "19.1.0",
24
26
"react-masonry-css": "^1.0.16",
27
27
+
"sonner": "^2.0.7",
25
28
"tailwind-merge": "^3.3.1",
29
29
+
"zod": "^4.0.14",
26
30
"zustand": "^5.0.7",
27
31
},
28
32
"devDependencies": {
···
328
332
329
333
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="],
330
334
335
335
+
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="],
336
336
+
331
337
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
332
338
333
339
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
···
633
639
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
634
640
635
641
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
642
642
+
643
643
+
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
636
644
637
645
"code-block-writer": ["code-block-writer@10.1.1", "", {}, "sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw=="],
638
646
···
1268
1276
1269
1277
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
1270
1278
1279
1279
+
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
1280
1280
+
1271
1281
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
1272
1282
1273
1283
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
···
1452
1462
1453
1463
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
1454
1464
1455
1455
-
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1465
1465
+
"zod": ["zod@4.0.14", "", {}, "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw=="],
1456
1466
1457
1467
"zustand": ["zustand@5.0.7", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg=="],
1458
1468
1469
1469
+
"@atproto-labs/did-resolver/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1470
1470
+
1471
1471
+
"@atproto-labs/handle-resolver/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1472
1472
+
1473
1473
+
"@atproto/api/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1474
1474
+
1475
1475
+
"@atproto/common-web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1476
1476
+
1477
1477
+
"@atproto/did/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1478
1478
+
1479
1479
+
"@atproto/jwk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1480
1480
+
1459
1481
"@atproto/jwk-jose/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
1482
1482
+
1483
1483
+
"@atproto/jwk-webcrypto/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1484
1484
+
1485
1485
+
"@atproto/lexicon/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1486
1486
+
1487
1487
+
"@atproto/oauth-client/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1488
1488
+
1489
1489
+
"@atproto/oauth-types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1490
1490
+
1491
1491
+
"@atproto/xrpc/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1460
1492
1461
1493
"@cloudflare/next-on-pages/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
1462
1494
+4
package.json
···
18
18
"@radix-ui/react-avatar": "^1.1.10",
19
19
"@radix-ui/react-dialog": "^1.1.14",
20
20
"@radix-ui/react-dropdown-menu": "^2.1.15",
21
21
+
"@radix-ui/react-popover": "^1.1.14",
21
22
"@radix-ui/react-scroll-area": "^1.2.9",
22
23
"@radix-ui/react-slot": "^1.2.3",
23
24
"@radix-ui/react-tabs": "^1.1.12",
24
25
"class-variance-authority": "^0.7.1",
25
26
"clsx": "^2.1.1",
27
27
+
"cmdk": "^1.1.1",
26
28
"lucide-react": "^0.526.0",
27
29
"motion": "^12.23.11",
28
30
"next": "15.4.4",
···
30
32
"react": "19.1.0",
31
33
"react-dom": "19.1.0",
32
34
"react-masonry-css": "^1.0.16",
35
35
+
"sonner": "^2.0.7",
33
36
"tailwind-merge": "^3.3.1",
37
37
+
"zod": "^4.0.14",
34
38
"zustand": "^5.0.7"
35
39
},
36
40
"devDependencies": {
+20
-15
src/app/[did]/[uri]/page.tsx
···
1
1
"use client";
2
2
import LikeCounter from "@/components/LikeCounter";
3
3
+
import { SaveButton } from "@/components/SaveButton";
3
4
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
4
5
import { Button } from "@/components/ui/button";
5
6
import { useAuth } from "@/lib/useAuth";
···
230
231
</div>
231
232
232
233
{/* External Link */}
233
233
-
<Link
234
234
-
href={
235
235
-
"https://bsky.app/profile/" +
236
236
-
post.author.did +
237
237
-
"/post/" +
238
238
-
post.uri.split("/").pop()
239
239
-
}
240
240
-
target="_blank"
241
241
-
rel="noopener noreferrer"
242
242
-
>
243
243
-
<Button variant="outline" className="cursor-pointer">
244
244
-
Open in Bluesky
245
245
-
<ExternalLink className="w-4 h-4" />
246
246
-
</Button>
247
247
-
</Link>
234
234
+
<div>
235
235
+
<SaveButton image={Number(imageIndex)} post={post} />
236
236
+
<Link
237
237
+
href={
238
238
+
"https://bsky.app/profile/" +
239
239
+
post.author.did +
240
240
+
"/post/" +
241
241
+
post.uri.split("/").pop()
242
242
+
}
243
243
+
target="_blank"
244
244
+
rel="noopener noreferrer"
245
245
+
className="ml-2"
246
246
+
>
247
247
+
<Button variant="outline" className="cursor-pointer">
248
248
+
Open in Bluesky
249
249
+
<ExternalLink className="w-4 h-4" />
250
250
+
</Button>
251
251
+
</Link>
252
252
+
</div>
248
253
</div>
249
254
</div>
250
255
</div>
+2
src/app/layout.tsx
···
5
5
import { Navbar } from "@/nav/navbar";
6
6
import { AuthProvider } from "@/lib/useAuth";
7
7
import { ProfileProvider } from "@/lib/useProfile";
8
8
+
import { Toaster } from "sonner";
8
9
9
10
const geistSans = Geist({
10
11
variable: "--font-geist-sans",
···
42
43
</ProfileProvider>
43
44
</AuthProvider>
44
45
</ThemeProvider>
46
46
+
<Toaster />
45
47
</body>
46
48
</html>
47
49
);
+20
-6
src/app/page.tsx
···
10
10
import { useFeedDefsStore } from "@/lib/stores/feedDefs";
11
11
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
12
12
import { useAuth } from "@/lib/useAuth";
13
13
+
import { useBoards } from "@/lib/hooks/useBoards";
13
14
14
15
export default function Home() {
15
16
const { fetchFeed } = useFetchTimeline();
16
17
const feedStore = useFeedStore();
17
18
const { isLoading } = useFeeds();
18
18
-
const { feeds } = useFeedDefsStore();
19
19
+
const { feeds, defaultFeed, setDefaultFeed } = useFeedDefsStore();
19
20
const { session } = useAuth();
21
21
+
useBoards();
20
22
const sentinelRef = useRef<HTMLDivElement>(null);
21
21
-
const [feed, setFeed] = useState<"timeline" | string>("timeline");
23
23
+
const [feed, setFeed] = useState<"timeline" | string>(
24
24
+
defaultFeed ?? "timeline"
25
25
+
);
22
26
23
27
useEffect(() => {
24
28
const observer = new IntersectionObserver(
···
35
39
return () => {
36
40
if (sentinel) observer.unobserve(sentinel);
37
41
};
38
38
-
}, [fetchFeed]);
42
42
+
}, [fetchFeed, feed]);
43
43
+
44
44
+
useEffect(() => {
45
45
+
fetchFeed(feed);
46
46
+
}, [feed]);
39
47
40
48
if (session == null) {
41
49
return (
···
57
65
58
66
return (
59
67
<main className="px-5">
60
60
-
<Tabs defaultValue="timeline" className="w-full">
68
68
+
<Tabs defaultValue={defaultFeed} className="w-full">
61
69
<TabsList
62
70
className="overflow-x-auto w-full justify-start" //"flex w-full overflow-x-auto whitespace-nowrap no-scrollbar pl-10 pr-4 space-x-4"
63
71
style={{ justifyItems: "unset" }}
64
72
>
65
73
<TabsTrigger
66
66
-
onClick={() => setFeed("timeline")}
74
74
+
onClick={() => {
75
75
+
setFeed("timeline");
76
76
+
setDefaultFeed("timeline");
77
77
+
}}
67
78
value="timeline"
68
79
className="shrink-0"
69
80
>
···
71
82
</TabsTrigger>
72
83
{Object.entries(feeds).map(([value, it]) => (
73
84
<TabsTrigger
74
74
-
onClick={() => setFeed(value)}
85
85
+
onClick={() => {
86
86
+
setFeed(value);
87
87
+
setDefaultFeed(value);
88
88
+
}}
75
89
key={value}
76
90
value={value}
77
91
className="shrink-0"
+103
src/components/BoardPicker.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import * as React from "react";
4
4
+
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
5
5
+
6
6
+
import { cn } from "@/lib/utils";
7
7
+
import { Button } from "@/components/ui/button";
8
8
+
import {
9
9
+
Command,
10
10
+
CommandEmpty,
11
11
+
CommandGroup,
12
12
+
CommandInput,
13
13
+
CommandItem,
14
14
+
CommandList,
15
15
+
} from "@/components/ui/command";
16
16
+
import {
17
17
+
Popover,
18
18
+
PopoverContent,
19
19
+
PopoverTrigger,
20
20
+
} from "@/components/ui/popover";
21
21
+
import { Board } from "@/lib/stores/boards";
22
22
+
23
23
+
export function BoardsPicker({
24
24
+
boards,
25
25
+
onCreateBoard,
26
26
+
onSelected: setValue,
27
27
+
selected: value,
28
28
+
}: {
29
29
+
selected: string;
30
30
+
onSelected: (value: string) => unknown;
31
31
+
boards: Map<string, Board>;
32
32
+
onCreateBoard: (name: string) => void; // New prop
33
33
+
}) {
34
34
+
const [open, setOpen] = React.useState(false);
35
35
+
const [search, setSearch] = React.useState("");
36
36
+
37
37
+
const entries = Array.from(boards.entries()).filter(([_, board]) =>
38
38
+
board.name.toLowerCase().includes(search.toLowerCase())
39
39
+
);
40
40
+
41
41
+
const selectedBoard = boards.get(value);
42
42
+
43
43
+
return (
44
44
+
<Popover open={open} onOpenChange={setOpen}>
45
45
+
<PopoverTrigger asChild>
46
46
+
<Button
47
47
+
variant="outline"
48
48
+
role="combobox"
49
49
+
aria-expanded={open}
50
50
+
className="w-full justify-between"
51
51
+
>
52
52
+
{selectedBoard ? selectedBoard.name : "Select board..."}
53
53
+
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
54
54
+
</Button>
55
55
+
</PopoverTrigger>
56
56
+
<PopoverContent className="w-full p-0">
57
57
+
<Command shouldFilter={false}>
58
58
+
<CommandInput
59
59
+
placeholder="Search or create a board..."
60
60
+
onValueChange={(val) => setSearch(val)}
61
61
+
/>
62
62
+
<CommandList>
63
63
+
<CommandGroup>
64
64
+
{entries.length > 0 ? (
65
65
+
entries.map(([key, it]) => (
66
66
+
<CommandItem
67
67
+
key={key}
68
68
+
value={it.name}
69
69
+
onSelect={() => {
70
70
+
setValue(key === value ? "" : key);
71
71
+
setOpen(false);
72
72
+
}}
73
73
+
>
74
74
+
<CheckIcon
75
75
+
className={cn(
76
76
+
"mr-2 h-4 w-4",
77
77
+
value === key ? "opacity-100" : "opacity-0"
78
78
+
)}
79
79
+
/>
80
80
+
{it.name}
81
81
+
</CommandItem>
82
82
+
))
83
83
+
) : (
84
84
+
<CommandItem
85
85
+
onSelect={() => {
86
86
+
onCreateBoard(search.trim());
87
87
+
setSearch("");
88
88
+
setOpen(false);
89
89
+
}}
90
90
+
>
91
91
+
<PlusIcon className="h-4 w-4" />
92
92
+
<span>
93
93
+
Create board: <b>{search.trim()}</b>
94
94
+
</span>
95
95
+
</CommandItem>
96
96
+
)}
97
97
+
</CommandGroup>
98
98
+
</CommandList>
99
99
+
</Command>
100
100
+
</PopoverContent>
101
101
+
</Popover>
102
102
+
);
103
103
+
}
+60
-54
src/components/Feed.tsx
···
14
14
import Link from "next/link";
15
15
import Masonry from "react-masonry-css";
16
16
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
17
17
+
import { SaveButton } from "./SaveButton";
17
18
18
19
function getText(post: PostView) {
19
20
if (!AppBskyFeedPost.isRecord(post.record)) return;
···
49
50
const t: string = getText(post) || "";
50
51
const maxLength = 100;
51
52
return images.map((image, index) => (
52
52
-
<Link
53
53
-
href={`/${post.author.did}/${post.uri
54
54
-
.split("/")
55
55
-
.pop()}?image=${index}`}
56
56
-
key={image.fullsize}
57
57
-
className="block"
58
58
-
>
59
59
-
<motion.div
60
60
-
initial={{ opacity: 0, y: 5 }}
61
61
-
animate={{ opacity: 1, y: 0 }}
62
62
-
transition={{ duration: 0.5, ease: "easeOut" }}
63
63
-
whileTap={{ scale: 0.99 }}
64
64
-
className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900"
53
53
+
<div key={image.fullsize} className="relative group">
54
54
+
<div className="absolute z-30 top-3 left-3 opacity-0 group-hover:opacity-100 transition-opacity">
55
55
+
<SaveButton post={post} image={index} />
56
56
+
</div>
57
57
+
<Link
58
58
+
href={`/${post.author.did}/${post.uri
59
59
+
.split("/")
60
60
+
.pop()}?image=${index}`}
61
61
+
key={image.fullsize}
62
62
+
className="block"
65
63
>
66
66
-
{/* Blurred background */}
67
67
-
<Image
68
68
-
src={image.fullsize}
69
69
-
alt=""
70
70
-
fill
71
71
-
placeholder="blur"
72
72
-
blurDataURL={image.thumb}
73
73
-
className="object-cover filter blur-xl scale-110 opacity-30"
74
74
-
/>
75
75
-
76
76
-
{/* Centered foreground image */}
77
77
-
<div className="relative z-10 flex items-center justify-center w-full min-h-[120px]">
64
64
+
<motion.div
65
65
+
initial={{ opacity: 0, y: 5 }}
66
66
+
animate={{ opacity: 1, y: 0 }}
67
67
+
transition={{ duration: 0.5, ease: "easeOut" }}
68
68
+
whileTap={{ scale: 0.99 }}
69
69
+
className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900"
70
70
+
>
71
71
+
{/* Blurred background */}
78
72
<Image
79
73
src={image.fullsize}
80
80
-
alt={image.alt}
74
74
+
alt=""
75
75
+
fill
81
76
placeholder="blur"
82
77
blurDataURL={image.thumb}
83
83
-
width={image?.aspectRatio?.width ?? 400}
84
84
-
height={image?.aspectRatio?.height ?? 400}
85
85
-
className="object-contain max-w-full max-h-full rounded-lg"
78
78
+
className="object-cover filter blur-xl scale-110 opacity-30"
86
79
/>
87
87
-
</div>
88
80
89
89
-
{/* Bottom: Avatar, display name, and handle */}
90
90
-
<div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-between p-3">
91
91
-
<div className="w-fit self-start" />
81
81
+
{/* Centered foreground image */}
82
82
+
<div className="relative z-10 flex items-center justify-center w-full min-h-[120px]">
83
83
+
<Image
84
84
+
src={image.fullsize}
85
85
+
alt={image.alt}
86
86
+
placeholder="blur"
87
87
+
blurDataURL={image.thumb}
88
88
+
width={image?.aspectRatio?.width ?? 400}
89
89
+
height={image?.aspectRatio?.height ?? 400}
90
90
+
className="object-contain max-w-full max-h-full rounded-lg"
91
91
+
/>
92
92
+
</div>
92
93
93
93
-
<div className="flex flex-col gap-2">
94
94
-
<div className="flex items-center gap-2">
95
95
-
<Avatar>
96
96
-
<AvatarImage src={post.author.avatar} />
97
97
-
<AvatarFallback>
98
98
-
{post.author.displayName || post.author.handle}
99
99
-
</AvatarFallback>
100
100
-
</Avatar>
101
101
-
<div className="flex flex-col leading-tight">
102
102
-
<span>
103
103
-
{post.author.displayName || post.author.handle}
104
104
-
</span>
105
105
-
<span className="text-white/70 text-[0.75rem]">
106
106
-
@{post.author.handle}
107
107
-
</span>
94
94
+
{/* Bottom: Avatar, display name, and handle */}
95
95
+
<div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-between p-3">
96
96
+
<div className="w-fit self-start" />
97
97
+
98
98
+
<div className="flex flex-col gap-2">
99
99
+
<div className="flex items-center gap-2">
100
100
+
<Avatar>
101
101
+
<AvatarImage src={post.author.avatar} />
102
102
+
<AvatarFallback>
103
103
+
{post.author.displayName || post.author.handle}
104
104
+
</AvatarFallback>
105
105
+
</Avatar>
106
106
+
<div className="flex flex-col leading-tight">
107
107
+
<span>
108
108
+
{post.author.displayName || post.author.handle}
109
109
+
</span>
110
110
+
<span className="text-white/70 text-[0.75rem]">
111
111
+
@{post.author.handle}
112
112
+
</span>
113
113
+
</div>
108
114
</div>
109
109
-
</div>
110
115
111
111
-
<div className="text-sm">
112
112
-
{t.length > maxLength ? t.slice(0, maxLength) + "…" : t}
116
116
+
<div className="text-sm">
117
117
+
{t.length > maxLength ? t.slice(0, maxLength) + "…" : t}
118
118
+
</div>
113
119
</div>
114
120
</div>
115
115
-
</div>
116
116
-
</motion.div>
117
117
-
</Link>
121
121
+
</motion.div>
122
122
+
</Link>
123
123
+
</div>
118
124
));
119
125
})}
120
126
</Masonry>
+85
-12
src/components/SaveButton.tsx
···
12
12
import { Button } from "./ui/button";
13
13
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
14
14
import { LoaderCircle } from "lucide-react";
15
15
+
import { useBoardsStore } from "@/lib/stores/boards";
16
16
+
import { BoardsPicker } from "./BoardPicker";
17
17
+
import { toast } from "sonner";
18
18
+
import { AtUri } from "@atproto/api";
19
19
+
import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants";
15
20
16
16
-
function SaveButton(post: PostView) {
17
17
-
const { login } = useAuth();
18
18
-
const [handle, setHandle] = useState("");
21
21
+
export function SaveButton({ post, image }: { post: PostView; image: number }) {
22
22
+
const { agent } = useAuth();
19
23
const [isLoading, setLoading] = useState(false);
24
24
+
const [isOpen, setOpen] = useState(false);
25
25
+
const [selectedBoard, setSelectedBoard] = useState("");
26
26
+
const boardsStore = useBoardsStore();
27
27
+
28
28
+
if (agent == null) return <div>not logged in :(</div>;
20
29
return (
21
21
-
<Dialog>
22
22
-
<DialogTrigger>
23
23
-
<Button size="sm" className="cursor-pointer">
24
24
-
Login
25
25
-
</Button>
30
30
+
<Dialog open={isOpen} onOpenChange={setOpen}>
31
31
+
<DialogTrigger asChild>
32
32
+
<span
33
33
+
onClick={(e) => {
34
34
+
e.stopPropagation();
35
35
+
}}
36
36
+
className="cursor-pointer"
37
37
+
>
38
38
+
<Button size="sm" className="cursor-pointer">
39
39
+
Save
40
40
+
</Button>
41
41
+
</span>
26
42
</DialogTrigger>
43
43
+
27
44
<DialogContent>
28
45
<DialogHeader>
29
46
<DialogTitle>Save post to board</DialogTitle>
30
30
-
<DialogDescription className="pt-5"></DialogDescription>
47
47
+
<DialogDescription className="pt-5">
48
48
+
<BoardsPicker
49
49
+
onSelected={setSelectedBoard}
50
50
+
selected={selectedBoard}
51
51
+
boards={boardsStore.boards}
52
52
+
onCreateBoard={async (name) => {
53
53
+
const record = {
54
54
+
name: name,
55
55
+
$type: LIST_COLLECTION,
56
56
+
createdAt: new Date().toISOString(),
57
57
+
description: "",
58
58
+
};
59
59
+
const result = await agent?.com.atproto.repo.createRecord({
60
60
+
collection: LIST_COLLECTION,
61
61
+
record,
62
62
+
repo: agent.assertDid,
63
63
+
});
64
64
+
65
65
+
if (result?.success) {
66
66
+
toast("Board created");
67
67
+
68
68
+
const rkey = new AtUri(result.data.uri).rkey;
69
69
+
boardsStore.setBoard(rkey, record);
70
70
+
setSelectedBoard(rkey);
71
71
+
} else {
72
72
+
toast("Failed to create board");
73
73
+
}
74
74
+
}}
75
75
+
/>
76
76
+
</DialogDescription>
31
77
</DialogHeader>
32
78
<DialogFooter>
33
79
<Button
34
34
-
onClick={() => {
80
80
+
onClick={async (e) => {
81
81
+
e.stopPropagation(); // Optional, but safe
82
82
+
35
83
setLoading(true);
36
36
-
login(handle);
84
84
+
try {
85
85
+
const record = {
86
86
+
url: post.uri + `?image=${image}`,
87
87
+
list: AtUri.make(
88
88
+
agent?.assertDid,
89
89
+
LIST_COLLECTION,
90
90
+
selectedBoard
91
91
+
).toString(),
92
92
+
$type: LIST_ITEM_COLLECTION,
93
93
+
createdAt: new Date().toISOString(),
94
94
+
};
95
95
+
const result = await agent.com.atproto.repo.createRecord({
96
96
+
collection: LIST_ITEM_COLLECTION,
97
97
+
record,
98
98
+
repo: agent.assertDid,
99
99
+
});
100
100
+
101
101
+
if (result?.success) {
102
102
+
toast("Image saved");
103
103
+
setOpen(false);
104
104
+
} else {
105
105
+
toast("Failed to save image");
106
106
+
}
107
107
+
} finally {
108
108
+
setLoading(false);
109
109
+
}
37
110
}}
38
38
-
disabled={!handle}
111
111
+
disabled={selectedBoard.trim().length <= 0}
39
112
className="cursor-pointer"
40
113
>
41
114
{isLoading && <LoaderCircle className="animate-spin ml-2" />}
+184
src/components/ui/command.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import * as React from "react";
4
4
+
import { Command as CommandPrimitive } from "cmdk";
5
5
+
import { SearchIcon } from "lucide-react";
6
6
+
7
7
+
import { cn } from "@/lib/utils";
8
8
+
import {
9
9
+
Dialog,
10
10
+
DialogContent,
11
11
+
DialogDescription,
12
12
+
DialogHeader,
13
13
+
DialogTitle,
14
14
+
} from "@/components/ui/dialog";
15
15
+
16
16
+
function Command({
17
17
+
className,
18
18
+
...props
19
19
+
}: React.ComponentProps<typeof CommandPrimitive>) {
20
20
+
return (
21
21
+
<CommandPrimitive
22
22
+
data-slot="command"
23
23
+
className={cn(
24
24
+
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
25
25
+
className
26
26
+
)}
27
27
+
{...props}
28
28
+
/>
29
29
+
);
30
30
+
}
31
31
+
32
32
+
function CommandDialog({
33
33
+
title = "Command Palette",
34
34
+
description = "Search for a command to run...",
35
35
+
children,
36
36
+
className,
37
37
+
showCloseButton = true,
38
38
+
...props
39
39
+
}: React.ComponentProps<typeof Dialog> & {
40
40
+
title?: string;
41
41
+
description?: string;
42
42
+
className?: string;
43
43
+
showCloseButton?: boolean;
44
44
+
}) {
45
45
+
return (
46
46
+
<Dialog {...props}>
47
47
+
<DialogHeader className="sr-only">
48
48
+
<DialogTitle>{title}</DialogTitle>
49
49
+
<DialogDescription>{description}</DialogDescription>
50
50
+
</DialogHeader>
51
51
+
<DialogContent
52
52
+
className={cn("overflow-hidden p-0", className)}
53
53
+
showCloseButton={showCloseButton}
54
54
+
>
55
55
+
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
56
56
+
{children}
57
57
+
</Command>
58
58
+
</DialogContent>
59
59
+
</Dialog>
60
60
+
);
61
61
+
}
62
62
+
63
63
+
function CommandInput({
64
64
+
className,
65
65
+
...props
66
66
+
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
67
67
+
return (
68
68
+
<div
69
69
+
data-slot="command-input-wrapper"
70
70
+
className="flex h-9 items-center gap-2 border-b px-3"
71
71
+
>
72
72
+
<SearchIcon className="size-4 shrink-0 opacity-50" />
73
73
+
<CommandPrimitive.Input
74
74
+
data-slot="command-input"
75
75
+
className={cn(
76
76
+
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
77
77
+
className
78
78
+
)}
79
79
+
{...props}
80
80
+
/>
81
81
+
</div>
82
82
+
);
83
83
+
}
84
84
+
85
85
+
function CommandList({
86
86
+
className,
87
87
+
...props
88
88
+
}: React.ComponentProps<typeof CommandPrimitive.List>) {
89
89
+
return (
90
90
+
<CommandPrimitive.List
91
91
+
data-slot="command-list"
92
92
+
className={cn(
93
93
+
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
94
94
+
className
95
95
+
)}
96
96
+
{...props}
97
97
+
/>
98
98
+
);
99
99
+
}
100
100
+
101
101
+
function CommandEmpty({
102
102
+
...props
103
103
+
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
104
104
+
return (
105
105
+
<CommandPrimitive.Empty
106
106
+
data-slot="command-empty"
107
107
+
className="py-6 text-center text-sm"
108
108
+
{...props}
109
109
+
/>
110
110
+
);
111
111
+
}
112
112
+
113
113
+
function CommandGroup({
114
114
+
className,
115
115
+
...props
116
116
+
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
117
117
+
return (
118
118
+
<CommandPrimitive.Group
119
119
+
data-slot="command-group"
120
120
+
className={cn(
121
121
+
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
122
122
+
className
123
123
+
)}
124
124
+
{...props}
125
125
+
/>
126
126
+
);
127
127
+
}
128
128
+
129
129
+
function CommandSeparator({
130
130
+
className,
131
131
+
...props
132
132
+
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
133
133
+
return (
134
134
+
<CommandPrimitive.Separator
135
135
+
data-slot="command-separator"
136
136
+
className={cn("bg-border -mx-1 h-px", className)}
137
137
+
{...props}
138
138
+
/>
139
139
+
);
140
140
+
}
141
141
+
142
142
+
function CommandItem({
143
143
+
className,
144
144
+
...props
145
145
+
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
146
146
+
return (
147
147
+
<CommandPrimitive.Item
148
148
+
data-slot="command-item"
149
149
+
className={cn(
150
150
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
151
151
+
className
152
152
+
)}
153
153
+
{...props}
154
154
+
/>
155
155
+
);
156
156
+
}
157
157
+
158
158
+
function CommandShortcut({
159
159
+
className,
160
160
+
...props
161
161
+
}: React.ComponentProps<"span">) {
162
162
+
return (
163
163
+
<span
164
164
+
data-slot="command-shortcut"
165
165
+
className={cn(
166
166
+
"text-muted-foreground ml-auto text-xs tracking-widest",
167
167
+
className
168
168
+
)}
169
169
+
{...props}
170
170
+
/>
171
171
+
);
172
172
+
}
173
173
+
174
174
+
export {
175
175
+
Command,
176
176
+
CommandDialog,
177
177
+
CommandInput,
178
178
+
CommandList,
179
179
+
CommandEmpty,
180
180
+
CommandGroup,
181
181
+
CommandItem,
182
182
+
CommandShortcut,
183
183
+
CommandSeparator,
184
184
+
};
+48
src/components/ui/popover.tsx
···
1
1
+
"use client"
2
2
+
3
3
+
import * as React from "react"
4
4
+
import * as PopoverPrimitive from "@radix-ui/react-popover"
5
5
+
6
6
+
import { cn } from "@/lib/utils"
7
7
+
8
8
+
function Popover({
9
9
+
...props
10
10
+
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
11
11
+
return <PopoverPrimitive.Root data-slot="popover" {...props} />
12
12
+
}
13
13
+
14
14
+
function PopoverTrigger({
15
15
+
...props
16
16
+
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
17
17
+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
18
18
+
}
19
19
+
20
20
+
function PopoverContent({
21
21
+
className,
22
22
+
align = "center",
23
23
+
sideOffset = 4,
24
24
+
...props
25
25
+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
26
26
+
return (
27
27
+
<PopoverPrimitive.Portal>
28
28
+
<PopoverPrimitive.Content
29
29
+
data-slot="popover-content"
30
30
+
align={align}
31
31
+
sideOffset={sideOffset}
32
32
+
className={cn(
33
33
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
34
34
+
className
35
35
+
)}
36
36
+
{...props}
37
37
+
/>
38
38
+
</PopoverPrimitive.Portal>
39
39
+
)
40
40
+
}
41
41
+
42
42
+
function PopoverAnchor({
43
43
+
...props
44
44
+
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
45
45
+
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
46
46
+
}
47
47
+
48
48
+
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+25
src/components/ui/sonner.tsx
···
1
1
+
"use client"
2
2
+
3
3
+
import { useTheme } from "next-themes"
4
4
+
import { Toaster as Sonner, ToasterProps } from "sonner"
5
5
+
6
6
+
const Toaster = ({ ...props }: ToasterProps) => {
7
7
+
const { theme = "system" } = useTheme()
8
8
+
9
9
+
return (
10
10
+
<Sonner
11
11
+
theme={theme as ToasterProps["theme"]}
12
12
+
className="toaster group"
13
13
+
style={
14
14
+
{
15
15
+
"--normal-bg": "var(--popover)",
16
16
+
"--normal-text": "var(--popover-foreground)",
17
17
+
"--normal-border": "var(--border)",
18
18
+
} as React.CSSProperties
19
19
+
}
20
20
+
{...props}
21
21
+
/>
22
22
+
)
23
23
+
}
24
24
+
25
25
+
export { Toaster }
+2
src/constants.ts
···
1
1
+
export const LIST_COLLECTION = "org.scrapboard.list";
2
2
+
export const LIST_ITEM_COLLECTION = "org.scrapboard.listitem";
+36
src/lib/hooks/useBoards.tsx
···
1
1
+
import { useEffect, useState } from "react";
2
2
+
import { useAuth } from "@/lib/useAuth";
3
3
+
import { useFeedDefsStore } from "../stores/feedDefs";
4
4
+
import { AtUri } from "@atproto/api";
5
5
+
import { Board, useBoardsStore } from "../stores/boards";
6
6
+
import { LIST_COLLECTION } from "@/constants";
7
7
+
8
8
+
export function useBoards() {
9
9
+
const { agent } = useAuth();
10
10
+
const store = useBoardsStore();
11
11
+
const [isLoading, setLoading] = useState(store.boards.size == 0);
12
12
+
13
13
+
useEffect(() => {
14
14
+
if (agent == null) return;
15
15
+
const loadBoards = async () => {
16
16
+
try {
17
17
+
const boards = await agent.com.atproto.repo.listRecords({
18
18
+
collection: LIST_COLLECTION,
19
19
+
repo: agent.assertDid,
20
20
+
limit: 100,
21
21
+
});
22
22
+
23
23
+
for (const board of boards.data.records) {
24
24
+
const safeBoard = Board.safeParse(board.value);
25
25
+
if (safeBoard.success)
26
26
+
store.setBoard(new AtUri(board.uri).rkey, safeBoard.data);
27
27
+
}
28
28
+
} finally {
29
29
+
setLoading(false);
30
30
+
}
31
31
+
};
32
32
+
loadBoards();
33
33
+
}, [agent]);
34
34
+
35
35
+
return { isLoading };
36
36
+
}
+11
-16
src/lib/hooks/useTimeline.tsx
···
5
5
import { useFeedStore } from "../stores/feeds";
6
6
import { FeedViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
7
7
8
8
-
function filterPosts(posts: FeedViewPost[], seenImageUrls: Set<string>) {
8
8
+
function filterPosts(posts: FeedViewPost[], seenPosts: Set<string>) {
9
9
return posts.filter((it) => {
10
10
-
if (it.reason?.$type === "app.bsky.feed.defs#reasonRepost") return false;
11
10
if (
12
11
!(
13
12
AppBskyEmbedImages.isMain(it.post.embed) ||
···
16
15
)
17
16
return false;
18
17
19
19
-
const images = (it.post.embed as AppBskyEmbedImages.View)?.images || [];
20
20
-
const hasNew = images.some((img) => !seenImageUrls.has(img.fullsize));
21
21
-
if (!hasNew) return false;
18
18
+
if (seenPosts.has(it.post.uri)) return false;
19
19
+
seenPosts.add(it.post.uri);
22
20
23
23
-
images.forEach((img) => seenImageUrls.add(img.fullsize));
24
21
return true;
25
22
});
26
23
}
···
29
26
const { agent } = useAuth();
30
27
const {
31
28
timeline,
32
32
-
setTimeline,
33
29
appendTimeline,
34
30
setTimelineLoading,
35
35
-
setCustomFeed,
36
31
setCustomFeedLoading,
37
32
customFeeds,
33
33
+
appendCustomFeed,
38
34
} = useFeedStore();
39
35
const seenImageUrls = useRef<Set<string>>(new Set());
40
36
···
61
57
});
62
58
63
59
if (!response.success) throw new Error("Failed to fetch timeline");
64
64
-
setCustomFeed(
65
65
-
feed,
66
66
-
filterPosts(response.data.feed, seenImageUrls.current).map(
67
67
-
(it) => it.post
68
68
-
),
69
69
-
response.data.cursor
70
70
-
);
60
60
+
const filtered = filterPosts(
61
61
+
response.data.feed,
62
62
+
seenImageUrls.current
63
63
+
).map((it) => it.post);
64
64
+
console.log("feed", filtered);
65
65
+
appendCustomFeed(feed, filtered, response.data.cursor);
71
66
} else {
72
67
const response = await agent.getTimeline({
73
68
cursor: timeline.cursor,
···
92
87
} else setTimelineLoading(false);
93
88
}
94
89
},
95
95
-
[agent, timeline]
90
90
+
[agent, timeline, customFeeds]
96
91
);
97
92
98
93
useEffect(() => {
+33
src/lib/stores/boards.tsx
···
1
1
+
import { create } from "zustand";
2
2
+
import { persist } from "zustand/middleware";
3
3
+
import * as z from "zod";
4
4
+
5
5
+
export const Board = z.object({
6
6
+
name: z.string(),
7
7
+
description: z.string(),
8
8
+
});
9
9
+
10
10
+
export type Board = z.infer<typeof Board>;
11
11
+
12
12
+
type FeedDefsState = {
13
13
+
boards: Map<string, Board>;
14
14
+
setBoard: (rkey: string, board: Board) => void;
15
15
+
};
16
16
+
17
17
+
export const useBoardsStore = create<FeedDefsState>()(
18
18
+
persist(
19
19
+
(set) => ({
20
20
+
boards: new Map(),
21
21
+
setBoard: (rkey, board) =>
22
22
+
set((state) => ({
23
23
+
boards: state.boards.set(rkey, board),
24
24
+
})),
25
25
+
}),
26
26
+
{
27
27
+
name: "boards",
28
28
+
partialize: (state) => ({
29
29
+
feeds: state.boards,
30
30
+
}),
31
31
+
}
32
32
+
)
33
33
+
);
+9
-1
src/lib/stores/feedDefs.tsx
···
8
8
type FeedDefsState = {
9
9
feeds: Record<string, BasicFeedItem>;
10
10
setFeedDef: (id: string, feed: BasicFeedItem) => void;
11
11
+
defaultFeed: string;
12
12
+
setDefaultFeed: (feed: string) => void;
11
13
};
12
14
13
15
export const useFeedDefsStore = create<FeedDefsState>()(
···
21
23
[id]: feed,
22
24
},
23
25
})),
26
26
+
defaultFeed: "timeline",
27
27
+
setDefaultFeed: (feed) =>
28
28
+
set(() => ({
29
29
+
defaultFeed: feed,
30
30
+
})),
24
31
}),
25
32
{
26
33
name: "feed-defs",
27
34
partialize: (state) => ({
28
28
-
feeds: state.feeds, // Only persist this part
35
35
+
feeds: state.feeds,
36
36
+
defaultFeed: state.defaultFeed,
29
37
}),
30
38
}
31
39
)