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: board items store
TurtlePaw
7 months ago
b9bababf
e8552c55
+392
-3
10 changed files
expand all
collapse all
unified
split
src
app
[did]
[uri]
page.tsx
board
[did]
[rkey]
page.tsx
boards
page.tsx
lib
hooks
useBoardItems.tsx
useBoards.tsx
records.ts
stores
boardItems.tsx
boards.tsx
utils
mapStorage.ts
nav
navbar.tsx
+1
-1
src/app/[did]/[uri]/page.tsx
···
22
22
import { useParams, useSearchParams } from "next/navigation";
23
23
import { use, useEffect, useMemo, useRef, useState } from "react";
24
24
25
25
-
function paramAsString(str: string | string[]): string {
25
25
+
export function paramAsString(str: string | string[]): string {
26
26
if (Array.isArray(str)) {
27
27
return str[0];
28
28
}
+118
src/app/board/[did]/[rkey]/page.tsx
···
1
1
+
"use client";
2
2
+
import { paramAsString } from "@/app/[did]/[uri]/page";
3
3
+
import LikeCounter from "@/components/LikeCounter";
4
4
+
import { SaveButton } from "@/components/SaveButton";
5
5
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
6
6
+
import { Button } from "@/components/ui/button";
7
7
+
import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants";
8
8
+
import { useBoardItemsStore } from "@/lib/stores/boardItems";
9
9
+
import { Board, useBoardsStore } from "@/lib/stores/boards";
10
10
+
import { useAuth } from "@/lib/useAuth";
11
11
+
import { $Typed, AtUri } from "@atproto/api";
12
12
+
import { AppBskyEmbedImages, AppBskyFeedPost } from "@atproto/api/dist/client";
13
13
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
14
14
+
import clsx from "clsx";
15
15
+
import {
16
16
+
ExternalLink,
17
17
+
Heart,
18
18
+
LoaderCircle,
19
19
+
MessagesSquare,
20
20
+
Repeat,
21
21
+
Repeat2,
22
22
+
} from "lucide-react";
23
23
+
import { AnimatePresence, motion } from "motion/react";
24
24
+
import Image, { ImageProps } from "next/image";
25
25
+
import Link from "next/link";
26
26
+
import { useParams, useSearchParams } from "next/navigation";
27
27
+
import { use, useEffect, useMemo, useRef, useState } from "react";
28
28
+
import z from "zod";
29
29
+
30
30
+
export const runtime = "edge";
31
31
+
32
32
+
export default function PostPage({
33
33
+
params,
34
34
+
}: {
35
35
+
params: Promise<{ slug: string }>;
36
36
+
}) {
37
37
+
const { did, rkey } = useParams();
38
38
+
39
39
+
const { boards, isLoading: isBoardsLoading } = useBoardsStore();
40
40
+
const { boardItems: items, isLoading: isItemsLoading } = useBoardItemsStore();
41
41
+
42
42
+
if (!rkey || !did)
43
43
+
return (
44
44
+
<div className="min-h-screen flex items-center justify-center px-4">
45
45
+
<div className="text-center">
46
46
+
<p className="text-red-500 dark:text-red-400">No rkey or did</p>
47
47
+
</div>
48
48
+
</div>
49
49
+
);
50
50
+
if (isItemsLoading || isBoardsLoading)
51
51
+
return (
52
52
+
<div className="min-h-screen flex items-center justify-center px-4">
53
53
+
<LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" />
54
54
+
</div>
55
55
+
);
56
56
+
57
57
+
const board = boards.get(paramAsString(rkey));
58
58
+
const itemsInBoard = items
59
59
+
.entries()
60
60
+
.filter((it) => new AtUri(it[1].list).rkey == paramAsString(rkey))
61
61
+
.toArray();
62
62
+
if (itemsInBoard.length <= 0)
63
63
+
return (
64
64
+
<div className="min-h-screen flex items-center justify-center px-4">
65
65
+
<p className="text-black/70 dark:text-white/70">No items found</p>
66
66
+
</div>
67
67
+
);
68
68
+
if (!board)
69
69
+
return (
70
70
+
<div className="min-h-screen flex items-center justify-center px-4">
71
71
+
<p className="text-black/70 dark:text-white/70">No board found</p>
72
72
+
</div>
73
73
+
);
74
74
+
75
75
+
return (
76
76
+
<div className="py-4 px-4 sm:py-8 sm:px-6 lg:px-8 flex items-center justify-center">
77
77
+
{/* Container that adapts to image width */}
78
78
+
<div className="w-full max-w-4xl flex justify-center">
79
79
+
<div className="inline-block">
80
80
+
<div>
81
81
+
<h2>{board?.name}</h2>
82
82
+
<p>{board.description}</p>
83
83
+
</div>
84
84
+
</div>
85
85
+
</div>
86
86
+
</div>
87
87
+
);
88
88
+
}
89
89
+
90
90
+
type BskyImageProps = {
91
91
+
embed:
92
92
+
| $Typed<AppBskyEmbedImages.View>
93
93
+
| {
94
94
+
$type: string;
95
95
+
}
96
96
+
| undefined;
97
97
+
imageIndex?: number;
98
98
+
className?: string;
99
99
+
width?: number;
100
100
+
height?: number;
101
101
+
} & Omit<ImageProps, "src" | "alt">;
102
102
+
103
103
+
function BskyImage({ embed, imageIndex = 0, ...props }: BskyImageProps) {
104
104
+
if (!AppBskyEmbedImages.isView(embed)) return null;
105
105
+
106
106
+
const image = embed.images?.[imageIndex];
107
107
+
if (!image) return null;
108
108
+
109
109
+
return (
110
110
+
<Image
111
111
+
src={image.fullsize}
112
112
+
alt={image.alt || "Post Image"}
113
113
+
placeholder="blur"
114
114
+
blurDataURL={image.thumb}
115
115
+
{...props}
116
116
+
/>
117
117
+
);
118
118
+
}
+96
src/app/boards/page.tsx
···
1
1
+
"use client";
2
2
+
import { paramAsString } from "@/app/[did]/[uri]/page";
3
3
+
import LikeCounter from "@/components/LikeCounter";
4
4
+
import { SaveButton } from "@/components/SaveButton";
5
5
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
6
6
+
import { Button } from "@/components/ui/button";
7
7
+
import { LIST_COLLECTION } from "@/constants";
8
8
+
import { Board, useBoardsStore } from "@/lib/stores/boards";
9
9
+
import { useAuth } from "@/lib/useAuth";
10
10
+
import { $Typed, AtUri } from "@atproto/api";
11
11
+
import { AppBskyEmbedImages, AppBskyFeedPost } from "@atproto/api/dist/client";
12
12
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
13
13
+
import clsx from "clsx";
14
14
+
import {
15
15
+
ExternalLink,
16
16
+
Heart,
17
17
+
LoaderCircle,
18
18
+
MessagesSquare,
19
19
+
Repeat,
20
20
+
Repeat2,
21
21
+
} from "lucide-react";
22
22
+
import { AnimatePresence, motion } from "motion/react";
23
23
+
import Image, { ImageProps } from "next/image";
24
24
+
import Link from "next/link";
25
25
+
import { useParams, useSearchParams } from "next/navigation";
26
26
+
import { use, useEffect, useMemo, useRef, useState } from "react";
27
27
+
import z from "zod";
28
28
+
29
29
+
export const runtime = "edge";
30
30
+
31
31
+
export default function BoardsPage() {
32
32
+
const { boards, isLoading } = useBoardsStore();
33
33
+
const { agent } = useAuth();
34
34
+
35
35
+
if (isLoading)
36
36
+
return (
37
37
+
<div className="min-h-screen flex items-center justify-center px-4">
38
38
+
<LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" />
39
39
+
</div>
40
40
+
);
41
41
+
if (boards.size <= 0)
42
42
+
return (
43
43
+
<div className="min-h-screen flex items-center justify-center px-4">
44
44
+
<p className="text-black/70 dark:text-white/70">No boards found</p>
45
45
+
</div>
46
46
+
);
47
47
+
48
48
+
return (
49
49
+
<div className="py-4 px-4 sm:py-8 sm:px-6 lg:px-8 flex items-center justify-center">
50
50
+
{/* Container that adapts to image width */}
51
51
+
<div className="w-full max-w-4xl flex justify-center">
52
52
+
<div className="inline-block">
53
53
+
{Array.from(boards.entries()).map(([key, it]) => (
54
54
+
<Link
55
55
+
href={`/board/${agent?.did ?? "unknown"}/${key}`}
56
56
+
key={key}
57
57
+
className="bg-accent/80 p-4 rounded-lg m-2 hover:bg-accent"
58
58
+
>
59
59
+
{it.name}
60
60
+
</Link>
61
61
+
))}
62
62
+
</div>
63
63
+
</div>
64
64
+
</div>
65
65
+
);
66
66
+
}
67
67
+
68
68
+
type BskyImageProps = {
69
69
+
embed:
70
70
+
| $Typed<AppBskyEmbedImages.View>
71
71
+
| {
72
72
+
$type: string;
73
73
+
}
74
74
+
| undefined;
75
75
+
imageIndex?: number;
76
76
+
className?: string;
77
77
+
width?: number;
78
78
+
height?: number;
79
79
+
} & Omit<ImageProps, "src" | "alt">;
80
80
+
81
81
+
function BskyImage({ embed, imageIndex = 0, ...props }: BskyImageProps) {
82
82
+
if (!AppBskyEmbedImages.isView(embed)) return null;
83
83
+
84
84
+
const image = embed.images?.[imageIndex];
85
85
+
if (!image) return null;
86
86
+
87
87
+
return (
88
88
+
<Image
89
89
+
src={image.fullsize}
90
90
+
alt={image.alt || "Post Image"}
91
91
+
placeholder="blur"
92
92
+
blurDataURL={image.thumb}
93
93
+
{...props}
94
94
+
/>
95
95
+
);
96
96
+
}
+41
src/lib/hooks/useBoardItems.tsx
···
1
1
+
"use client";
2
2
+
import { PropsWithChildren, useEffect, useState } from "react";
3
3
+
import { useAuth } from "@/lib/useAuth";
4
4
+
import { useFeedDefsStore } from "../stores/feedDefs";
5
5
+
import { AtUri } from "@atproto/api";
6
6
+
import { Board, useBoardsStore } from "../stores/boards";
7
7
+
import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants";
8
8
+
import { BoardItem, useBoardItemsStore } from "../stores/boardItems";
9
9
+
import { getAllRecords } from "../records";
10
10
+
11
11
+
export function useBoardItems() {
12
12
+
const { agent } = useAuth();
13
13
+
const store = useBoardItemsStore();
14
14
+
const [isLoading, setLoading] = useState(store.boardItems.size == 0);
15
15
+
16
16
+
useEffect(() => {
17
17
+
if (agent == null) return;
18
18
+
const loadItems = async () => {
19
19
+
try {
20
20
+
const boards = await getAllRecords({
21
21
+
collection: LIST_ITEM_COLLECTION,
22
22
+
repo: agent.assertDid,
23
23
+
limit: 100,
24
24
+
agent,
25
25
+
});
26
26
+
27
27
+
for (const item of boards) {
28
28
+
const safeItem = BoardItem.safeParse(item.value);
29
29
+
if (safeItem.success)
30
30
+
store.setBoardItem(new AtUri(item.uri).rkey, safeItem.data);
31
31
+
}
32
32
+
} finally {
33
33
+
setLoading(false);
34
34
+
store.setLoading(false);
35
35
+
}
36
36
+
};
37
37
+
loadItems();
38
38
+
}, [agent]);
39
39
+
40
40
+
return { isLoading };
41
41
+
}
+3
src/lib/hooks/useBoards.tsx
···
5
5
import { AtUri } from "@atproto/api";
6
6
import { Board, useBoardsStore } from "../stores/boards";
7
7
import { LIST_COLLECTION } from "@/constants";
8
8
+
import { useBoardItems } from "./useBoardItems";
8
9
9
10
export function useBoards() {
10
11
const { agent } = useAuth();
···
28
29
}
29
30
} finally {
30
31
setLoading(false);
32
32
+
store.setLoading(false);
31
33
}
32
34
};
33
35
loadBoards();
···
38
40
39
41
export function BoardsProvider({ children }: PropsWithChildren) {
40
42
useBoards();
43
43
+
useBoardItems();
41
44
return children;
42
45
}
+34
src/lib/records.ts
···
1
1
+
import { Agent } from "@atproto/api";
2
2
+
import { Record } from "@atproto/api/dist/client/types/com/atproto/repo/listRecords";
3
3
+
4
4
+
/**
5
5
+
* Fetches all records for a given repo & collection, handling pagination via cursors.
6
6
+
*/
7
7
+
export async function getAllRecords({
8
8
+
repo,
9
9
+
collection,
10
10
+
limit = 100,
11
11
+
agent,
12
12
+
}: {
13
13
+
repo: string;
14
14
+
collection: string;
15
15
+
limit?: number;
16
16
+
agent: Agent;
17
17
+
}) {
18
18
+
let records: Record[] = [];
19
19
+
let cursor: string | undefined = undefined;
20
20
+
21
21
+
do {
22
22
+
const res = await agent.com.atproto.repo.listRecords({
23
23
+
repo,
24
24
+
collection,
25
25
+
limit,
26
26
+
cursor,
27
27
+
});
28
28
+
29
29
+
records = records.concat(res.data.records);
30
30
+
cursor = res.data.cursor;
31
31
+
} while (cursor);
32
32
+
33
33
+
return records;
34
34
+
}
+45
src/lib/stores/boardItems.tsx
···
1
1
+
import { create } from "zustand";
2
2
+
import { persist } from "zustand/middleware";
3
3
+
import * as z from "zod";
4
4
+
import { createMapStorage } from "../utils/mapStorage";
5
5
+
6
6
+
export const BoardItem = z.object({
7
7
+
url: z.string(),
8
8
+
list: z.string(),
9
9
+
$type: z.string(),
10
10
+
createdAt: z.string(),
11
11
+
});
12
12
+
13
13
+
export type BoardItem = z.infer<typeof BoardItem>;
14
14
+
15
15
+
type BoardItemsState = {
16
16
+
boardItems: Map<string, BoardItem>;
17
17
+
setBoardItem: (rkey: string, board: BoardItem) => void;
18
18
+
isLoading: boolean;
19
19
+
setLoading: (value: boolean) => void;
20
20
+
};
21
21
+
22
22
+
export const useBoardItemsStore = create<BoardItemsState>()(
23
23
+
persist(
24
24
+
(set) => ({
25
25
+
boardItems: new Map(),
26
26
+
setBoardItem: (rkey, board) =>
27
27
+
set((state) => ({
28
28
+
boardItems: new Map(state.boardItems).set(rkey, board),
29
29
+
})),
30
30
+
isLoading: true,
31
31
+
setLoading(value) {
32
32
+
set(() => ({
33
33
+
isLoading: value,
34
34
+
}));
35
35
+
},
36
36
+
}),
37
37
+
{
38
38
+
name: "board-items",
39
39
+
partialize: (state) => ({
40
40
+
items: state.boardItems,
41
41
+
}),
42
42
+
storage: createMapStorage("boardItems"),
43
43
+
}
44
44
+
)
45
45
+
);
+12
-2
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
+
import { createMapStorage } from "../utils/mapStorage";
4
5
5
6
export const Board = z.object({
6
7
name: z.string(),
···
12
13
type FeedDefsState = {
13
14
boards: Map<string, Board>;
14
15
setBoard: (rkey: string, board: Board) => void;
16
16
+
isLoading: boolean;
17
17
+
setLoading: (value: boolean) => void;
15
18
};
16
19
17
20
export const useBoardsStore = create<FeedDefsState>()(
···
20
23
boards: new Map(),
21
24
setBoard: (rkey, board) =>
22
25
set((state) => ({
23
23
-
boards: state.boards.set(rkey, board),
26
26
+
boards: new Map(state.boards).set(rkey, board),
24
27
})),
28
28
+
isLoading: true,
29
29
+
setLoading(value) {
30
30
+
set(() => ({
31
31
+
isLoading: value,
32
32
+
}));
33
33
+
},
25
34
}),
26
35
{
27
36
name: "boards",
28
37
partialize: (state) => ({
29
29
-
feeds: state.boards,
38
38
+
boards: state.boards,
30
39
}),
40
40
+
storage: createMapStorage("boards"),
31
41
}
32
42
)
33
43
);
+37
src/lib/utils/mapStorage.ts
···
1
1
+
/* eslint-disable @typescript-eslint/no-explicit-any */
2
2
+
import type { PersistStorage, StorageValue } from "zustand/middleware";
3
3
+
4
4
+
export function createMapStorage<T>(
5
5
+
key: string
6
6
+
): PersistStorage<any> | undefined {
7
7
+
return {
8
8
+
getItem: (name) => {
9
9
+
const str = localStorage.getItem(name);
10
10
+
if (!str) return null;
11
11
+
const existingValue = JSON.parse(str);
12
12
+
return {
13
13
+
...existingValue,
14
14
+
state: {
15
15
+
...existingValue.state,
16
16
+
[key]: new Map(existingValue.state[key]),
17
17
+
},
18
18
+
};
19
19
+
},
20
20
+
setItem: (name, newValue: StorageValue<any>) => {
21
21
+
const mapValue = newValue.state?.[key];
22
22
+
const serializedMap =
23
23
+
mapValue instanceof Map ? Array.from(mapValue.entries()) : [];
24
24
+
25
25
+
const str = JSON.stringify({
26
26
+
...newValue,
27
27
+
state: {
28
28
+
...newValue.state,
29
29
+
[key]: serializedMap,
30
30
+
},
31
31
+
});
32
32
+
localStorage.setItem(name, str);
33
33
+
},
34
34
+
35
35
+
removeItem: (name) => localStorage.removeItem(name),
36
36
+
};
37
37
+
}
+5
src/nav/navbar.tsx
···
76
76
<DropdownMenuLabel>My Account</DropdownMenuLabel>
77
77
<DropdownMenuSeparator />
78
78
{/* <DropdownMenuItem>Profile</DropdownMenuItem> */}
79
79
+
<Link href={"/boards"}>
80
80
+
<DropdownMenuItem className="cursor-pointer">
81
81
+
My Boards
82
82
+
</DropdownMenuItem>
83
83
+
</Link>
79
84
<DropdownMenuItem className="cursor-pointer" onClick={logout}>
80
85
Logout
81
86
</DropdownMenuItem>