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
7f54ba24
b9bababf
+552
-172
11 changed files
expand all
collapse all
unified
split
src
app
board
[did]
[rkey]
page.tsx
boards
page.tsx
page.tsx
components
EditButton.tsx
Feed.tsx
SaveButton.tsx
UnsaveButton.tsx
ui
textarea.tsx
lib
hooks
useBoardItems.tsx
stores
boardItems.tsx
useCurrentBoard.ts
+90
-78
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";
3
3
+
import { Feed } from "@/components/Feed";
4
4
+
import { LoaderCircle } from "lucide-react";
8
5
import { useBoardItemsStore } from "@/lib/stores/boardItems";
9
9
-
import { Board, useBoardsStore } from "@/lib/stores/boards";
6
6
+
import { useBoardsStore } from "@/lib/stores/boards";
7
7
+
import { useCurrentBoard } from "@/lib/stores/useCurrentBoard";
10
8
import { useAuth } from "@/lib/useAuth";
11
11
-
import { $Typed, AtUri } from "@atproto/api";
12
12
-
import { AppBskyEmbedImages, AppBskyFeedPost } from "@atproto/api/dist/client";
9
9
+
import { AtUri } from "@atproto/api";
13
10
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";
11
11
+
import { useParams } from "next/navigation";
12
12
+
import { useEffect, useMemo, useState } from "react";
13
13
+
import { EditButton } from "@/components/EditButton";
29
14
30
15
export const runtime = "edge";
31
16
32
32
-
export default function PostPage({
33
33
-
params,
34
34
-
}: {
35
35
-
params: Promise<{ slug: string }>;
36
36
-
}) {
17
17
+
export default function BoardPage() {
37
18
const { did, rkey } = useParams();
19
19
+
const { agent } = useAuth();
38
20
21
21
+
useCurrentBoard.getState().setCurrentBoard(rkey?.toString() ?? null);
39
22
const { boards, isLoading: isBoardsLoading } = useBoardsStore();
40
23
const { boardItems: items, isLoading: isItemsLoading } = useBoardItemsStore();
41
24
42
42
-
if (!rkey || !did)
25
25
+
const [posts, setPosts] = useState<[number, PostView][]>([]);
26
26
+
const [loading, setLoading] = useState(true);
27
27
+
28
28
+
const itemsInBoard = useMemo(() => {
29
29
+
if (!rkey) return [];
30
30
+
return Array.from(items.entries()).filter(
31
31
+
([, item]) => new AtUri(item.list).rkey === paramAsString(rkey)
32
32
+
);
33
33
+
}, [items, rkey]);
34
34
+
35
35
+
const board = useMemo(
36
36
+
() => (rkey ? boards.get(paramAsString(rkey)) : null),
37
37
+
[boards, rkey]
38
38
+
);
39
39
+
40
40
+
// Initial fetch
41
41
+
useEffect(() => {
42
42
+
if (!agent || !rkey || !did || itemsInBoard.length === 0) {
43
43
+
setLoading(false);
44
44
+
return;
45
45
+
}
46
46
+
47
47
+
let cancelled = false;
48
48
+
49
49
+
const fetchPosts = async () => {
50
50
+
try {
51
51
+
const uris = itemsInBoard.map(([, item]) =>
52
52
+
AtUri.make(item.url.split("?")[0]).toString()
53
53
+
);
54
54
+
55
55
+
const response = await agent.getPosts({ uris });
56
56
+
const skeets = response?.data.posts || [];
57
57
+
58
58
+
if (!cancelled) {
59
59
+
const newPosts: [number, PostView][] = skeets.map((skeet) => {
60
60
+
const uri = new AtUri(skeet.uri);
61
61
+
const index = Number(uri.searchParams.get("image")) || 0;
62
62
+
return [index, skeet] as [number, PostView];
63
63
+
});
64
64
+
65
65
+
setPosts(newPosts);
66
66
+
setLoading(false);
67
67
+
}
68
68
+
} catch (error) {
69
69
+
console.error("Error fetching posts:", error);
70
70
+
if (!cancelled) setLoading(false);
71
71
+
}
72
72
+
};
73
73
+
74
74
+
fetchPosts();
75
75
+
return () => {
76
76
+
cancelled = true;
77
77
+
};
78
78
+
}, [agent, did, rkey, itemsInBoard]);
79
79
+
80
80
+
if (!rkey || !did) {
43
81
return (
44
82
<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>
83
83
+
<p className="text-red-500 dark:text-red-400">No rkey or did</p>
48
84
</div>
49
85
);
50
50
-
if (isItemsLoading || isBoardsLoading)
86
86
+
}
87
87
+
88
88
+
if (isItemsLoading || isBoardsLoading || loading) {
51
89
return (
52
90
<div className="min-h-screen flex items-center justify-center px-4">
53
91
<LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" />
54
92
</div>
55
93
);
94
94
+
}
56
95
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)
96
96
+
if (itemsInBoard.length === 0) {
63
97
return (
64
98
<div className="min-h-screen flex items-center justify-center px-4">
65
99
<p className="text-black/70 dark:text-white/70">No items found</p>
66
100
</div>
67
101
);
68
68
-
if (!board)
102
102
+
}
103
103
+
104
104
+
if (!board) {
69
105
return (
70
106
<div className="min-h-screen flex items-center justify-center px-4">
71
107
<p className="text-black/70 dark:text-white/70">No board found</p>
72
108
</div>
73
109
);
110
110
+
}
74
111
75
112
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>
113
113
+
<div className="px-5">
114
114
+
<div className="flex flex-row">
115
115
+
<div className="mb-5 ml-2">
116
116
+
<h2 className="font-bold text-xl">{board.name}</h2>
117
117
+
<p className="text-black/80 dark:text-white/80">
118
118
+
{board.description}
119
119
+
</p>
84
120
</div>
121
121
+
<EditButton board={board} rkey={paramAsString(rkey)} className="ml-3" />
85
122
</div>
123
123
+
<Feed
124
124
+
feed={posts}
125
125
+
showUnsaveButton={true}
126
126
+
onUnsave={(imageUrl, index) => console.log("Unsave", imageUrl)}
127
127
+
/>
86
128
</div>
87
129
);
88
130
}
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
-
}
+11
-6
src/app/boards/page.tsx
···
28
28
29
29
export const runtime = "edge";
30
30
31
31
+
function truncateString(str: string, num: number) {
32
32
+
return str.length > num ? str.slice(0, num) + "..." : str;
33
33
+
}
34
34
+
31
35
export default function BoardsPage() {
32
36
const { boards, isLoading } = useBoardsStore();
33
37
const { agent } = useAuth();
···
51
55
<div className="w-full max-w-4xl flex justify-center">
52
56
<div className="inline-block">
53
57
{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}
58
58
+
<Link href={`/board/${agent?.did ?? "unknown"}/${key}`} key={key}>
59
59
+
<div className="bg-black/10 dark:bg-white/40 p-4 rounded-lg m-2 hover:bg-black/15 dark:hover:bg-white/60 min-w-lg min-h-2/5 transition-colors">
60
60
+
<h2 className="font-medium text-lg">{it.name}</h2>
61
61
+
<p className="text-sm text-black/80 dark:text-white/80">
62
62
+
{truncateString(it.description, 50)}
63
63
+
</p>
64
64
+
</div>
60
65
</Link>
61
66
))}
62
67
</div>
+3
-3
src/app/page.tsx
···
1
1
"use client";
2
2
3
3
-
import { Feed } from "@/components/Feed";
3
3
+
import { Feed, feedAsMap } from "@/components/Feed";
4
4
import { useFetchTimeline } from "@/lib/hooks/useTimeline";
5
5
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
6
6
import { useRef, useEffect, useState } from "react";
···
96
96
97
97
<TabsContent value="timeline">
98
98
<Feed
99
99
-
feed={feedStore.timeline.posts.map((it) => it.post)}
99
99
+
feed={feedAsMap(feedStore.timeline.posts.map((it) => it.post))}
100
100
isLoading={feedStore.timeline.isLoading}
101
101
/>
102
102
</TabsContent>
···
106
106
.map(([value]) => (
107
107
<TabsContent key={value} value={value}>
108
108
<Feed
109
109
-
feed={feedStore.customFeeds[value].posts}
109
109
+
feed={feedAsMap(feedStore.customFeeds[value].posts)}
110
110
isLoading={feedStore.customFeeds[value].isLoading}
111
111
/>
112
112
</TabsContent>
+136
src/components/EditButton.tsx
···
1
1
+
import {
2
2
+
Dialog,
3
3
+
DialogContent,
4
4
+
DialogDescription,
5
5
+
DialogFooter,
6
6
+
DialogHeader,
7
7
+
DialogTitle,
8
8
+
DialogTrigger,
9
9
+
} from "@/components/ui/dialog";
10
10
+
import { useAuth } from "@/lib/useAuth";
11
11
+
import { useState } from "react";
12
12
+
import { Button } from "./ui/button";
13
13
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
14
14
+
import { EditIcon, LoaderCircle } from "lucide-react";
15
15
+
import { Board, 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";
20
20
+
import { FeedItem } from "./Feed";
21
21
+
import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems";
22
22
+
import clsx from "clsx";
23
23
+
import { Input } from "./ui/input";
24
24
+
import { Textarea } from "./ui/textarea";
25
25
+
26
26
+
export function EditButton({
27
27
+
board,
28
28
+
rkey,
29
29
+
className,
30
30
+
}: {
31
31
+
board: Board;
32
32
+
rkey: string;
33
33
+
className: string;
34
34
+
}) {
35
35
+
const { agent } = useAuth();
36
36
+
const [isLoading, setLoading] = useState(false);
37
37
+
const [isOpen, setOpen] = useState(false);
38
38
+
const [name, setName] = useState(board.name);
39
39
+
const [description, setDescription] = useState(board.description);
40
40
+
const { setBoard } = useBoardsStore();
41
41
+
42
42
+
if (agent == null) return <div>not logged in :(</div>;
43
43
+
return (
44
44
+
<Dialog open={isOpen} onOpenChange={setOpen}>
45
45
+
<DialogTrigger asChild>
46
46
+
<span
47
47
+
onClick={(e) => {
48
48
+
e.stopPropagation();
49
49
+
}}
50
50
+
className={clsx("cursor-pointer", className)}
51
51
+
>
52
52
+
<Button
53
53
+
size="sm"
54
54
+
className={clsx("cursor-pointer")}
55
55
+
variant={"ghost"}
56
56
+
>
57
57
+
<EditIcon />
58
58
+
</Button>
59
59
+
</span>
60
60
+
</DialogTrigger>
61
61
+
62
62
+
<DialogContent>
63
63
+
<DialogHeader>
64
64
+
<DialogTitle>Update board</DialogTitle>
65
65
+
<DialogDescription className="pt-5">
66
66
+
<Input
67
67
+
onChange={(e) => setName(e.target.value)}
68
68
+
value={name}
69
69
+
className="dark:text-white text-black"
70
70
+
/>
71
71
+
<Textarea
72
72
+
className="mt-2 dark:text-white text-black"
73
73
+
onChange={(e) => setDescription(e.target.value)}
74
74
+
value={description}
75
75
+
placeholder="Enter a description of your board..."
76
76
+
/>
77
77
+
</DialogDescription>
78
78
+
</DialogHeader>
79
79
+
<DialogFooter>
80
80
+
<Button
81
81
+
onClick={async (e) => {
82
82
+
e.stopPropagation(); // Optional, but safe
83
83
+
84
84
+
setLoading(true);
85
85
+
try {
86
86
+
const record: Board = {
87
87
+
name,
88
88
+
description,
89
89
+
};
90
90
+
91
91
+
const result = await agent.com.atproto.repo.applyWrites({
92
92
+
repo: agent.assertDid,
93
93
+
writes: [
94
94
+
{
95
95
+
$type: "com.atproto.repo.applyWrites#update",
96
96
+
collection: LIST_COLLECTION,
97
97
+
value: record,
98
98
+
rkey: rkey,
99
99
+
},
100
100
+
],
101
101
+
});
102
102
+
103
103
+
const newRecord = await agent.com.atproto.repo.getRecord({
104
104
+
repo: agent.assertDid,
105
105
+
collection: LIST_COLLECTION,
106
106
+
rkey: rkey,
107
107
+
});
108
108
+
109
109
+
const newRecordData = Board.safeParse(newRecord.data.value);
110
110
+
111
111
+
if (
112
112
+
result?.success &&
113
113
+
newRecord.success &&
114
114
+
newRecordData.success
115
115
+
) {
116
116
+
setBoard(rkey, newRecordData.data);
117
117
+
toast("Board updated");
118
118
+
setOpen(false);
119
119
+
} else {
120
120
+
toast("Failed to update board");
121
121
+
}
122
122
+
} finally {
123
123
+
setLoading(false);
124
124
+
}
125
125
+
}}
126
126
+
disabled={name.length <= 0}
127
127
+
className="cursor-pointer"
128
128
+
>
129
129
+
{isLoading && <LoaderCircle className="animate-spin ml-2" />}
130
130
+
Update
131
131
+
</Button>
132
132
+
</DialogFooter>
133
133
+
</DialogContent>
134
134
+
</Dialog>
135
135
+
);
136
136
+
}
+158
-84
src/components/Feed.tsx
···
5
5
AppBskyEmbedImages,
6
6
AppBskyFeedDefs,
7
7
AppBskyFeedPost,
8
8
+
AtUri,
8
9
} from "@atproto/api";
9
10
import { LoaderCircle } from "lucide-react";
10
11
import { useEffect, useRef, useState, useCallback } from "react";
···
15
16
import Masonry from "react-masonry-css";
16
17
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
17
18
import { SaveButton } from "./SaveButton";
19
19
+
import { UnsaveButton } from "./UnsaveButton";
20
20
+
21
21
+
export type FeedItem = {
22
22
+
id: string;
23
23
+
imageUrl: string;
24
24
+
alt?: string;
25
25
+
author?: {
26
26
+
avatar?: string;
27
27
+
displayName?: string;
28
28
+
handle: string;
29
29
+
did?: string;
30
30
+
};
31
31
+
text?: string;
32
32
+
uri: string;
33
33
+
aspectRatio?: { width: number; height: number };
34
34
+
blurDataURL?: string;
35
35
+
};
36
36
+
37
37
+
// Props for the Feed component
38
38
+
interface FeedProps {
39
39
+
/**
40
40
+
* Map of the index of the embedded media and post view
41
41
+
*/
42
42
+
feed?: [number, PostView][];
43
43
+
44
44
+
isLoading?: boolean;
45
45
+
showUnsaveButton?: boolean;
46
46
+
onUnsave?: (imageUrl: string, index: number) => void;
47
47
+
}
18
48
19
49
function getText(post: PostView) {
20
50
if (!AppBskyFeedPost.isRecord(post.record)) return;
21
51
return (post.record as AppBskyFeedPost.Record).text;
22
52
}
23
53
54
54
+
function getImageFromItem(it: PostView, index: number) {
55
55
+
if (
56
56
+
AppBskyEmbedImages.isMain(it.embed) ||
57
57
+
AppBskyEmbedImages.isView(it.embed)
58
58
+
) {
59
59
+
return it.embed.images[index];
60
60
+
} else return null;
61
61
+
}
62
62
+
63
63
+
function ImageCard({
64
64
+
item,
65
65
+
showUnsaveButton,
66
66
+
onUnsave,
67
67
+
index,
68
68
+
}: {
69
69
+
item: PostView;
70
70
+
showUnsaveButton?: boolean;
71
71
+
onUnsave?: (imageUrl: string, index: number) => void;
72
72
+
index: number;
73
73
+
}) {
74
74
+
const image = getImageFromItem(item, index);
75
75
+
76
76
+
if (!image) return;
77
77
+
78
78
+
const ActionButton = showUnsaveButton ? UnsaveButton : SaveButton;
79
79
+
const txt = getText(item);
80
80
+
81
81
+
return (
82
82
+
<div key={item.uri} className="relative group">
83
83
+
{ActionButton && (
84
84
+
<div className="absolute z-30 top-3 left-3 opacity-0 group-hover:opacity-100 transition-opacity">
85
85
+
<ActionButton image={index} post={item} />
86
86
+
</div>
87
87
+
)}
88
88
+
<Link
89
89
+
href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`}
90
90
+
className="block"
91
91
+
>
92
92
+
<motion.div
93
93
+
initial={{ opacity: 0, y: 5 }}
94
94
+
animate={{ opacity: 1, y: 0 }}
95
95
+
transition={{ duration: 0.5, ease: "easeOut" }}
96
96
+
whileTap={{ scale: 0.95 }}
97
97
+
className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900"
98
98
+
>
99
99
+
{/* Blurred background */}
100
100
+
<Image
101
101
+
src={image.fullsize}
102
102
+
alt=""
103
103
+
fill
104
104
+
placeholder={image.thumb ? "blur" : "empty"}
105
105
+
blurDataURL={image.thumb}
106
106
+
className="object-cover filter blur-xl scale-110 opacity-30"
107
107
+
/>
108
108
+
109
109
+
{/* Centered foreground image */}
110
110
+
<div className="relative z-10 flex items-center justify-center w-full min-h-[120px]">
111
111
+
<Image
112
112
+
src={image.fullsize}
113
113
+
alt={image.alt || ""}
114
114
+
placeholder={image.thumb ? "blur" : "empty"}
115
115
+
blurDataURL={image.thumb}
116
116
+
width={image.aspectRatio?.width ?? 400}
117
117
+
height={image.aspectRatio?.height ?? 400}
118
118
+
className="object-contain max-w-full max-h-full rounded-lg"
119
119
+
/>
120
120
+
</div>
121
121
+
122
122
+
{/* Author info and text overlay (only if author exists) */}
123
123
+
{item.author && (
124
124
+
<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">
125
125
+
<div className="w-fit self-start" />
126
126
+
127
127
+
<div className="flex flex-col gap-2">
128
128
+
<div className="flex items-center gap-2">
129
129
+
<Avatar>
130
130
+
<AvatarImage src={item.author.avatar} />
131
131
+
<AvatarFallback>
132
132
+
{item.author.displayName || item.author.handle}
133
133
+
</AvatarFallback>
134
134
+
</Avatar>
135
135
+
<div className="flex flex-col leading-tight">
136
136
+
<span>{item.author.displayName || item.author.handle}</span>
137
137
+
<span className="text-white/70 text-[0.75rem]">
138
138
+
@{item.author.handle}
139
139
+
</span>
140
140
+
</div>
141
141
+
</div>
142
142
+
143
143
+
{txt && (
144
144
+
<div className="text-sm">
145
145
+
{txt.length > 100 ? txt.slice(0, 100) + "…" : txt}
146
146
+
</div>
147
147
+
)}
148
148
+
</div>
149
149
+
</div>
150
150
+
)}
151
151
+
</motion.div>
152
152
+
</Link>
153
153
+
</div>
154
154
+
);
155
155
+
}
156
156
+
157
157
+
export function feedAsMap(feed: PostView[]) {
158
158
+
const map: [number, PostView][] = [];
159
159
+
for (const it of feed) {
160
160
+
if (
161
161
+
AppBskyEmbedImages.isMain(it.embed) ||
162
162
+
AppBskyEmbedImages.isView(it.embed)
163
163
+
) {
164
164
+
it.embed.images.forEach((image, index) => map.push([index, it]));
165
165
+
}
166
166
+
}
167
167
+
return map;
168
168
+
}
169
169
+
24
170
export function Feed({
25
171
feed,
26
172
isLoading = false,
27
27
-
}: {
28
28
-
feed: PostView[];
29
29
-
isLoading?: boolean;
30
30
-
}) {
173
173
+
showUnsaveButton = false,
174
174
+
onUnsave,
175
175
+
}: FeedProps) {
31
176
const breakpointColumnsObj = {
32
177
default: 5,
33
178
1536: 4,
···
43
188
className="flex -mx-2 w-auto"
44
189
columnClassName="px-2 space-y-4"
45
190
>
46
46
-
{feed.flatMap((post) => {
47
47
-
if (!AppBskyEmbedImages.isView(post.embed)) return [];
48
48
-
const images = post.embed.images || [];
49
49
-
if (images.length === 0) return [];
50
50
-
const t: string = getText(post) || "";
51
51
-
const maxLength = 100;
52
52
-
return images.map((image, index) => (
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"
63
63
-
>
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 */}
72
72
-
<Image
73
73
-
src={image.fullsize}
74
74
-
alt=""
75
75
-
fill
76
76
-
placeholder="blur"
77
77
-
blurDataURL={image.thumb}
78
78
-
className="object-cover filter blur-xl scale-110 opacity-30"
79
79
-
/>
80
80
-
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>
93
93
-
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>
114
114
-
</div>
115
115
-
116
116
-
<div className="text-sm">
117
117
-
{t.length > maxLength ? t.slice(0, maxLength) + "…" : t}
118
118
-
</div>
119
119
-
</div>
120
120
-
</div>
121
121
-
</motion.div>
122
122
-
</Link>
123
123
-
</div>
124
124
-
));
125
125
-
})}
191
191
+
{feed?.map(([index, item]) => (
192
192
+
<ImageCard
193
193
+
key={item.uri}
194
194
+
item={item}
195
195
+
index={index}
196
196
+
showUnsaveButton={showUnsaveButton}
197
197
+
onUnsave={onUnsave}
198
198
+
/>
199
199
+
))}
126
200
</Masonry>
127
201
128
202
{isLoading && (
+6
-1
src/components/SaveButton.tsx
···
17
17
import { toast } from "sonner";
18
18
import { AtUri } from "@atproto/api";
19
19
import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants";
20
20
+
import { FeedItem } from "./Feed";
21
21
+
import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems";
20
22
21
23
export function SaveButton({ post, image }: { post: PostView; image: number }) {
22
24
const { agent } = useAuth();
···
24
26
const [isOpen, setOpen] = useState(false);
25
27
const [selectedBoard, setSelectedBoard] = useState("");
26
28
const boardsStore = useBoardsStore();
29
29
+
const { setBoardItem } = useBoardItemsStore();
27
30
28
31
if (agent == null) return <div>not logged in :(</div>;
29
32
return (
···
82
85
83
86
setLoading(true);
84
87
try {
85
85
-
const record = {
88
88
+
const record: BoardItem = {
86
89
url: post.uri + `?image=${image}`,
87
90
list: AtUri.make(
88
91
agent?.assertDid,
···
99
102
});
100
103
101
104
if (result?.success) {
105
105
+
const rkey = new AtUri(result.data.uri).rkey;
106
106
+
setBoardItem(rkey, record);
102
107
toast("Image saved");
103
108
setOpen(false);
104
109
} else {
+108
src/components/UnsaveButton.tsx
···
1
1
+
import {
2
2
+
Dialog,
3
3
+
DialogClose,
4
4
+
DialogContent,
5
5
+
DialogDescription,
6
6
+
DialogFooter,
7
7
+
DialogHeader,
8
8
+
DialogTitle,
9
9
+
DialogTrigger,
10
10
+
} from "@/components/ui/dialog";
11
11
+
import { useAuth } from "@/lib/useAuth";
12
12
+
import { useState } from "react";
13
13
+
import { Button } from "./ui/button";
14
14
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
15
15
+
import { LoaderCircle } from "lucide-react";
16
16
+
import { useBoardsStore } from "@/lib/stores/boards";
17
17
+
import { BoardsPicker } from "./BoardPicker";
18
18
+
import { toast } from "sonner";
19
19
+
import { AtUri } from "@atproto/api";
20
20
+
import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants";
21
21
+
import { FeedItem } from "./Feed";
22
22
+
import { useCurrentBoard } from "@/lib/stores/useCurrentBoard";
23
23
+
import { useBoardItemsStore } from "@/lib/stores/boardItems";
24
24
+
25
25
+
export function UnsaveButton({
26
26
+
post,
27
27
+
image,
28
28
+
}: {
29
29
+
post: PostView;
30
30
+
image: number;
31
31
+
}) {
32
32
+
const { agent } = useAuth();
33
33
+
const [isLoading, setLoading] = useState(false);
34
34
+
const [isOpen, setOpen] = useState(false);
35
35
+
const { removeBoardItem, boardItems } = useBoardItemsStore();
36
36
+
37
37
+
if (agent == null) return <div>not logged in :(</div>;
38
38
+
return (
39
39
+
<Dialog open={isOpen} onOpenChange={setOpen}>
40
40
+
<DialogTrigger asChild>
41
41
+
<span
42
42
+
onClick={(e) => {
43
43
+
e.stopPropagation();
44
44
+
}}
45
45
+
className="cursor-pointer"
46
46
+
>
47
47
+
<Button size="sm" className="cursor-pointer">
48
48
+
Remove
49
49
+
</Button>
50
50
+
</span>
51
51
+
</DialogTrigger>
52
52
+
53
53
+
<DialogContent>
54
54
+
<DialogHeader>
55
55
+
<DialogTitle>Remove from board?</DialogTitle>
56
56
+
<DialogDescription>
57
57
+
Are you sure you want to remove this from your board?
58
58
+
</DialogDescription>
59
59
+
</DialogHeader>
60
60
+
<DialogFooter>
61
61
+
<DialogClose>
62
62
+
<Button className="cursor-pointer">Cancel</Button>
63
63
+
</DialogClose>
64
64
+
<Button
65
65
+
onClick={async (e) => {
66
66
+
e.stopPropagation(); // Optional, but safe
67
67
+
68
68
+
setLoading(true);
69
69
+
try {
70
70
+
const postRkey = AtUri.make(post.uri).rkey;
71
71
+
const record = boardItems
72
72
+
.entries()
73
73
+
.find((e) => AtUri.make(e[1].url).rkey == postRkey);
74
74
+
if (!record)
75
75
+
return toast(
76
76
+
"Couldn't find post. You might be viewing stale data."
77
77
+
);
78
78
+
79
79
+
const rkey = record[0];
80
80
+
console.log("using rkey", rkey, "and record", record);
81
81
+
const result = await agent.com.atproto.repo.deleteRecord({
82
82
+
collection: LIST_ITEM_COLLECTION,
83
83
+
rkey,
84
84
+
repo: agent.assertDid,
85
85
+
});
86
86
+
87
87
+
if (result?.success) {
88
88
+
removeBoardItem(rkey);
89
89
+
toast("Removed from board");
90
90
+
setOpen(false);
91
91
+
} else {
92
92
+
toast("Failed to remove");
93
93
+
}
94
94
+
} finally {
95
95
+
setLoading(false);
96
96
+
}
97
97
+
}}
98
98
+
className="cursor-pointer"
99
99
+
variant="destructive"
100
100
+
>
101
101
+
{isLoading && <LoaderCircle className="animate-spin ml-2" />}
102
102
+
Remove
103
103
+
</Button>
104
104
+
</DialogFooter>
105
105
+
</DialogContent>
106
106
+
</Dialog>
107
107
+
);
108
108
+
}
+18
src/components/ui/textarea.tsx
···
1
1
+
import * as React from "react"
2
2
+
3
3
+
import { cn } from "@/lib/utils"
4
4
+
5
5
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
6
+
return (
7
7
+
<textarea
8
8
+
data-slot="textarea"
9
9
+
className={cn(
10
10
+
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
11
+
className
12
12
+
)}
13
13
+
{...props}
14
14
+
/>
15
15
+
)
16
16
+
}
17
17
+
18
18
+
export { Textarea }
+1
src/lib/hooks/useBoardItems.tsx
···
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
+
else console.warn(`${item.uri} could not be parsed safely`);
31
32
}
32
33
} finally {
33
34
setLoading(false);
+9
src/lib/stores/boardItems.tsx
···
15
15
type BoardItemsState = {
16
16
boardItems: Map<string, BoardItem>;
17
17
setBoardItem: (rkey: string, board: BoardItem) => void;
18
18
+
removeBoardItem: (rkey: string) => void;
18
19
isLoading: boolean;
19
20
setLoading: (value: boolean) => void;
20
21
};
···
27
28
set((state) => ({
28
29
boardItems: new Map(state.boardItems).set(rkey, board),
29
30
})),
31
31
+
removeBoardItem: (rkey) =>
32
32
+
set((state) => {
33
33
+
const newMap = new Map(state.boardItems);
34
34
+
newMap.delete(rkey);
35
35
+
return {
36
36
+
boardItems: newMap,
37
37
+
};
38
38
+
}),
30
39
isLoading: true,
31
40
setLoading(value) {
32
41
set(() => ({
+12
src/lib/stores/useCurrentBoard.ts
···
1
1
+
// lib/stores/useCurrentBoard.ts
2
2
+
import { create } from "zustand";
3
3
+
4
4
+
interface CurrentBoardState {
5
5
+
currentBoard: string | null;
6
6
+
setCurrentBoard: (board: string | null) => void;
7
7
+
}
8
8
+
9
9
+
export const useCurrentBoard = create<CurrentBoardState>((set) => ({
10
10
+
currentBoard: null,
11
11
+
setCurrentBoard: (board) => set({ currentBoard: board }),
12
12
+
}));