chore: fixed type errors 4d0e38fa
Steve · 2025-11-01 22:52 5 file(s) · +132 −33
src/components/app-sidebar.tsx +118 −18
1 1
"use client";
2 2
3 3
import * as React from "react";
4 -
import { Plus, RotateCw } from "lucide-react";
4 +
import { Plus, RotateCw, MoreVertical, Check, X } from "lucide-react";
5 5
6 6
import { NavUser } from "@/components/nav-user";
7 7
import { NavFeeds } from "@/components/nav-feeds";
16 16
	SidebarMenu,
17 17
	SidebarMenuButton,
18 18
	SidebarMenuItem,
19 -
	useSidebar,
20 19
} from "@/components/ui/sidebar";
21 20
22 21
import { Button } from "@/components/ui/button";
30 29
	DialogTitle,
31 30
	DialogTrigger,
32 31
} from "@/components/ui/dialog";
32 +
import {
33 +
	DropdownMenu,
34 +
	DropdownMenuContent,
35 +
	DropdownMenuItem,
36 +
	DropdownMenuTrigger,
37 +
} from "@/components/ui/dropdown-menu";
33 38
import { Input } from "@/components/ui/input";
34 39
import { Label } from "@/components/ui/label";
35 40
import { toast } from "sonner";
38 43
	allPostsQuery,
39 44
	postsByFeedQuery,
40 45
	allReadStatusesQuery,
46 +
	allReadStatusesWithUnreadQuery,
41 47
	useEvolu,
42 48
	reset,
43 49
} from "@/lib/evolu";
76 82
	const [isAddingFeed, setIsAddingFeed] = React.useState(false);
77 83
	const [statusMessage, setStatusMessage] = React.useState("");
78 84
79 -
	const { setOpen } = useSidebar();
80 -
81 85
	const { insert, update } = useEvolu();
82 86
	const allFeeds = useQuery(allFeedsQuery);
83 87
	const allReadStatuses = useQuery(allReadStatusesQuery);
88 +
	const allReadStatusesWithUnread = useQuery(allReadStatusesWithUnreadQuery);
84 89
85 90
	// Get posts based on selected feed
86 91
	const allPosts = useQuery(allPostsQuery);
116 121
	// Handle post selection and mark as read
117 122
	const handlePostSelect = React.useCallback(
118 123
		(postId: string) => {
119 -
			// Mark as read if not already read
120 -
			if (!isPostRead(postId)) {
121 -
				const post = feedPosts.find((p) => p.id === postId);
122 -
				if (post) {
123 -
					insert("readStatus", {
124 -
						postId: postId as any,
125 -
						feedId: post.feedId,
126 -
					});
127 -
				}
124 +
			// Mark as read
125 +
			const existingStatus = allReadStatuses.find(
126 +
				(status) => status.postId === postId,
127 +
			);
128 +
			const post = feedPosts.find((p) => p.id === postId);
129 +
130 +
			if (existingStatus) {
131 +
				// Update existing status to read
132 +
				update("readStatus", {
133 +
					id: existingStatus.id,
134 +
					isRead: true,
135 +
				});
136 +
			} else if (post && post.feedId) {
137 +
				// Create new read status
138 +
				insert("readStatus", {
139 +
					postId: postId as any,
140 +
					feedId: post.feedId,
141 +
					isRead: true,
142 +
				});
128 143
			}
144 +
129 145
			// Call the original onPostSelect
130 146
			onPostSelect(postId);
131 147
		},
132 -
		[isPostRead, feedPosts, insert, onPostSelect],
148 +
		[allReadStatuses, feedPosts, insert, update, onPostSelect],
133 149
	);
134 150
151 +
	// Mark all visible posts as read
152 +
	const handleMarkAllAsRead = React.useCallback(() => {
153 +
		let markedCount = 0;
154 +
		filteredPosts.forEach((post) => {
155 +
			const existingStatus = allReadStatusesWithUnread.find(
156 +
				(status) => status.postId === post.id,
157 +
			);
158 +
159 +
			if (existingStatus && !existingStatus.isRead) {
160 +
				// Update existing status to read
161 +
				update("readStatus", {
162 +
					id: existingStatus.id,
163 +
					isRead: true,
164 +
				});
165 +
				markedCount++;
166 +
			} else if (!existingStatus && post.feedId) {
167 +
				// Create new read status
168 +
				insert("readStatus", {
169 +
					postId: post.id as any,
170 +
					feedId: post.feedId,
171 +
					isRead: true,
172 +
				});
173 +
				markedCount++;
174 +
			}
175 +
		});
176 +
		toast.success(
177 +
			`Marked ${markedCount} post${markedCount !== 1 ? "s" : ""} as read`,
178 +
		);
179 +
	}, [filteredPosts, allReadStatusesWithUnread, insert, update]);
180 +
181 +
	// Mark all visible posts as unread
182 +
	const handleMarkAllAsUnread = React.useCallback(() => {
183 +
		let unmarkedCount = 0;
184 +
		filteredPosts.forEach((post) => {
185 +
			const existingStatus = allReadStatusesWithUnread.find(
186 +
				(status) => status.postId === post.id,
187 +
			);
188 +
189 +
			if (existingStatus && existingStatus.isRead) {
190 +
				// Update existing status to unread
191 +
				update("readStatus", {
192 +
					id: existingStatus.id,
193 +
					isRead: false,
194 +
				});
195 +
				unmarkedCount++;
196 +
			} else if (!existingStatus && post.feedId) {
197 +
				// Create new unread status
198 +
				insert("readStatus", {
199 +
					postId: post.id as any,
200 +
					feedId: post.feedId,
201 +
					isRead: false,
202 +
				});
203 +
				unmarkedCount++;
204 +
			}
205 +
		});
206 +
		toast.success(
207 +
			`Marked ${unmarkedCount} post${unmarkedCount !== 1 ? "s" : ""} as unread`,
208 +
		);
209 +
	}, [filteredPosts, allReadStatusesWithUnread, insert, update]);
210 +
135 211
	async function addFeed() {
136 212
		if (!urlInput.trim()) {
137 213
			setStatusMessage("Please enter a URL");
242 318
				category: categoryInput || "Uncategorized",
243 319
				dateUpdated: new Date().toISOString(),
244 320
			});
321 +
322 +
			if (!result.ok) {
323 +
				throw new Error("Failed to insert feed");
324 +
			}
245 325
246 326
			// Process posts/entries
247 327
			for (const post of posts) {
382 462
			{/* Posts List Panel - Separate from main sidebar */}
383 463
			<div className="bg-sidebar text-sidebar-foreground hidden md:flex overflow-y-scroll h-screen w-[320px] flex-col border-r">
384 464
				<div className="gap-2 border-b p-3 flex flex-col">
385 -
					<div className="flex w-full items-center justify-between">
465 +
					<div className="flex w-full items-center justify-between gap-2">
386 466
						<div className="text-foreground text-sm font-semibold truncate">
387 467
							{selectedFeedId
388 468
								? allFeeds.find((f) => f.id === selectedFeedId)?.title ||
389 469
									"Posts"
390 470
								: "All Posts"}
391 471
						</div>
392 -
						<span className="text-muted-foreground text-xs whitespace-nowrap ml-2">
393 -
							{filteredPosts.length}
394 -
						</span>
472 +
						<div className="flex items-center gap-1">
473 +
							<span className="text-muted-foreground text-xs whitespace-nowrap">
474 +
								{filteredPosts.length}
475 +
							</span>
476 +
							<DropdownMenu>
477 +
								<DropdownMenuTrigger asChild>
478 +
									<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
479 +
										<MoreVertical className="h-4 w-4" />
480 +
									</Button>
481 +
								</DropdownMenuTrigger>
482 +
								<DropdownMenuContent align="end">
483 +
									<DropdownMenuItem onClick={handleMarkAllAsRead}>
484 +
										<Check className="h-4 w-4 mr-2" />
485 +
										Mark all as read
486 +
									</DropdownMenuItem>
487 +
									<DropdownMenuItem onClick={handleMarkAllAsUnread}>
488 +
										<X className="h-4 w-4 mr-2" />
489 +
										Mark all as unread
490 +
									</DropdownMenuItem>
491 +
								</DropdownMenuContent>
492 +
							</DropdownMenu>
493 +
						</div>
395 494
					</div>
396 495
					<Input
397 496
						placeholder="Search..."
410 509
							const isRead = isPostRead(post.id);
411 510
							return (
412 511
								<button
512 +
									type="button"
413 513
									key={post.id}
414 514
									onClick={() => handlePostSelect(post.id)}
415 515
									className={`hover:bg-sidebar-accent flex items-start gap-2 border-b px-3 py-3 text-sm text-left w-full last:border-b-0 transition-colors ${
src/components/nav-feeds.tsx +6 −4
20 20
21 21
interface Feed {
22 22
	id: string;
23 -
	title: string;
23 +
	title: string | null;
24 24
	category: string | null;
25 25
}
26 26
27 27
interface NavFeedsProps {
28 -
	feeds: Feed[];
28 +
	feeds: readonly Feed[];
29 29
	selectedFeedId?: string | null;
30 30
	onFeedSelect: (feedId: string | null) => void;
31 31
}
50 50
51 51
	// Sort feeds within each category alphabetically by title
52 52
	Object.keys(feedsByCategory).forEach((category) => {
53 -
		feedsByCategory[category].sort((a, b) => a.title.localeCompare(b.title));
53 +
		feedsByCategory[category].sort((a, b) =>
54 +
			(a.title || "").localeCompare(b.title || ""),
55 +
		);
54 56
	});
55 57
56 58
	const categories = Object.keys(feedsByCategory).sort();
93 95
												onClick={() => onFeedSelect(feed.id)}
94 96
												isActive={selectedFeedId === feed.id}
95 97
											>
96 -
												<span>{feed.title}</span>
98 +
												<span>{feed.title || "Untitled"}</span>
97 99
											</SidebarMenuSubButton>
98 100
										</SidebarMenuSubItem>
99 101
									))}
src/components/nav-user.tsx +1 −9
1 -
import {
2 -
	BadgeCheck,
3 -
	Bell,
4 -
	ChevronsUpDown,
5 -
	CreditCard,
6 -
	LogOut,
7 -
	Settings,
8 -
	Sparkles,
9 -
} from "lucide-react";
1 +
import { BadgeCheck, Bell, CreditCard, LogOut, Sparkles } from "lucide-react";
10 2
11 3
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
12 4
import {
src/lib/evolu.ts +5 −1
33 33
);
34 34
35 35
export const allReadStatusesQuery = evolu.createQuery((db) =>
36 -
	db.selectFrom("readStatus").selectAll().where("isDeleted", "is", null),
36 +
	db.selectFrom("readStatus").selectAll().where("isRead", "=", 1),
37 +
);
38 +
39 +
export const allReadStatusesWithUnreadQuery = evolu.createQuery((db) =>
40 +
	db.selectFrom("readStatus").selectAll(),
37 41
);
38 42
39 43
export function reset() {
src/lib/scheme.ts +2 −1
4 4
	NonEmptyString,
5 5
	NonEmptyString1000,
6 6
	nullOr,
7 +
	SqliteBoolean,
7 8
} from "@evolu/common";
8 9
9 10
// RSS Feed ID
43 44
		id: id("ReadStatus"),
44 45
		feedId: RSSFeedId,
45 46
		postId: RSSPostId,
46 -
		isDeleted: nullOr(NonEmptyString),
47 +
		isRead: SqliteBoolean,
47 48
	},
48 49
	userPreferences: {
49 50
		id: id("UserPreferences"),