chore: refactored ui and rendering eb21c77e
Steve · 2025-10-27 22:43 4 file(s) · +368 −189
src/components/app-sidebar.tsx +148 −175
1 1
"use client";
2 2
3 3
import * as React from "react";
4 -
import {
5 -
	ArchiveX,
6 -
	Circle,
7 -
	Command,
8 -
	File,
9 -
	Inbox,
10 -
	Plus,
11 -
	RotateCw,
12 -
	Send,
13 -
	Star,
14 -
	Trash2,
15 -
} from "lucide-react";
4 +
import { Command, Plus, RotateCw } from "lucide-react";
16 5
17 6
import { NavUser } from "@/components/nav-user";
18 -
import { Label } from "@/components/ui/label";
7 +
import { NavFeeds } from "@/components/nav-feeds";
19 8
import {
20 9
	Sidebar,
21 10
	SidebarContent,
22 11
	SidebarFooter,
23 12
	SidebarGroup,
24 13
	SidebarGroupContent,
14 +
	SidebarGroupLabel,
25 15
	SidebarHeader,
26 16
	SidebarInput,
27 17
	SidebarMenu,
29 19
	SidebarMenuItem,
30 20
	useSidebar,
31 21
} from "@/components/ui/sidebar";
32 -
import { Switch } from "@/components/ui/switch";
33 22
34 23
import { Button } from "@/components/ui/button";
35 24
import {
43 32
	DialogTrigger,
44 33
} from "@/components/ui/dialog";
45 34
import { Input } from "@/components/ui/input";
46 -
import { allFeedsQuery, useEvolu, reset } from "@/lib/evolu";
47 -
import { XMLParser, XMLBuilder, XMLValidator } from "fast-xml-parser";
35 +
import { Label } from "@/components/ui/label";
36 +
import {
37 +
	allFeedsQuery,
38 +
	allPostsQuery,
39 +
	postsByFeedQuery,
40 +
	useEvolu,
41 +
	reset,
42 +
} from "@/lib/evolu";
43 +
import { XMLParser } from "fast-xml-parser";
48 44
import { useQuery } from "@evolu/react";
49 45
const parser = new XMLParser();
50 46
55 51
		email: "m@example.com",
56 52
		avatar: "/avatars/shadcn.jpg",
57 53
	},
58 -
	navMain: [
59 -
		{
60 -
			title: "Today",
61 -
			url: "#",
62 -
			icon: Inbox,
63 -
			isActive: true,
64 -
		},
65 -
		{
66 -
			title: "Unread",
67 -
			url: "#",
68 -
			icon: Circle,
69 -
			isActive: false,
70 -
		},
71 -
		{
72 -
			title: "Starred",
73 -
			url: "#",
74 -
			icon: Star,
75 -
			isActive: false,
76 -
		},
77 -
	],
78 54
};
79 55
80 -
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
81 -
	// Note: I'm using state to show active item.
82 -
	// IRL you should use the url/router.
83 -
	const [activeItem, setActiveItem] = React.useState(data.navMain[0]);
56 +
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
57 +
	selectedFeedId?: string | null;
58 +
	onFeedSelect?: (feedId: string | null) => void;
59 +
	selectedPostId?: string | null;
60 +
	onPostSelect?: (postId: string) => void;
61 +
}
62 +
63 +
export function AppSidebar({
64 +
	selectedFeedId = null,
65 +
	onFeedSelect = () => {},
66 +
	selectedPostId = null,
67 +
	onPostSelect = () => {},
68 +
	...props
69 +
}: AppSidebarProps) {
84 70
	const [urlInput, setUrlInput] = React.useState("");
85 71
	const [categoryInput, setCategoryInput] = React.useState("");
86 72
	const { setOpen } = useSidebar();
87 73
	const [dialogOpen, setDialogOpen] = React.useState(false);
74 +
	const [searchQuery, setSearchQuery] = React.useState("");
88 75
89 76
	const { insert, update } = useEvolu();
90 77
	const allFeeds = useQuery(allFeedsQuery);
91 -
	console.log(allFeeds);
78 +
79 +
	// Get posts based on selected feed
80 +
	const allPosts = useQuery(allPostsQuery);
81 +
	const feedPosts = selectedFeedId
82 +
		? useQuery(postsByFeedQuery(selectedFeedId))
83 +
		: allPosts;
84 +
85 +
	// Filter posts by search query
86 +
	const filteredPosts = React.useMemo(() => {
87 +
		if (!searchQuery) return feedPosts;
88 +
		return feedPosts.filter((post) =>
89 +
			post.title?.toLowerCase().includes(searchQuery.toLowerCase()),
90 +
		);
91 +
	}, [feedPosts, searchQuery]);
92 92
93 93
	async function addFeed() {
94 94
		try {
130 130
				feedUrl: urlInput,
131 131
				title: feedData.title,
132 132
				description: feedData.description || feedData.subtitle || "",
133 -
				category: "tech",
133 +
				category: categoryInput || "Uncategorized",
134 134
			});
135 -
			console.log(result);
136 135
137 136
			// Process posts/entries
138 137
			for (const post of posts) {
139 -
				const addPost = insert("rssPost", {
138 +
				insert("rssPost", {
140 139
					title: post.title,
141 140
					author: isAtom
142 141
						? post.author?.name || "Author"
148 147
						: post.link || post.id,
149 148
					feedId: result.value.id,
150 149
				});
151 -
				console.log(addPost);
152 150
			}
151 +
			setUrlInput("");
152 +
			setCategoryInput("");
153 153
			setDialogOpen(false);
154 154
		} catch (error) {
155 155
			console.log(error);
157 157
	}
158 158
159 159
	return (
160 -
		<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
161 -
			<Sidebar
162 -
				collapsible="icon"
163 -
				className="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
164 -
				{...props}
165 -
			>
166 -
				{/* This is the first sidebar */}
167 -
				{/* We disable collapsible and adjust width to icon. */}
168 -
				{/* This will make the sidebar appear as icons. */}
169 -
				<Sidebar
170 -
					collapsible="none"
171 -
					className="w-[calc(var(--sidebar-width-icon)+1px)]! border-r"
172 -
				>
160 +
		<>
161 +
			<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
162 +
				<Sidebar collapsible="icon" {...props}>
173 163
					<SidebarHeader>
174 164
						<SidebarMenu>
175 165
							<SidebarMenuItem>
179 169
											<Command className="size-4" />
180 170
										</div>
181 171
										<div className="grid flex-1 text-left text-sm leading-tight">
182 -
											<span className="truncate font-medium">Acme Inc</span>
183 -
											<span className="truncate text-xs">Enterprise</span>
172 +
											<span className="truncate font-medium">Alcove</span>
173 +
											<span className="truncate text-xs">RSS Reader</span>
184 174
										</div>
185 175
									</a>
186 176
								</SidebarMenuButton>
188 178
						</SidebarMenu>
189 179
					</SidebarHeader>
190 180
					<SidebarContent>
191 -
						<DialogContent className="sm:max-w-[425px]">
192 -
							<DialogHeader>
193 -
								<DialogTitle>Add Feed</DialogTitle>
194 -
								<DialogDescription>
195 -
									Add a new feed with the RSS URL
196 -
								</DialogDescription>
197 -
							</DialogHeader>
198 -
							<div className="grid gap-4">
199 -
								<div className="grid gap-3">
200 -
									<Label htmlFor="url-input">URL</Label>
201 -
									<Input
202 -
										id="url-input"
203 -
										name="url"
204 -
										value={urlInput}
205 -
										onChange={(e) => setUrlInput(e.target.value)}
206 -
									/>
207 -
								</div>
208 -
								<div className="grid gap-3">
209 -
									<Label htmlFor="category-input">Category</Label>
210 -
									<Input
211 -
										id="category-input"
212 -
										name="category"
213 -
										value={categoryInput}
214 -
										onChange={(e) => setCategoryInput(e.target.value)}
215 -
									/>
216 -
								</div>
217 -
							</div>
218 -
							<DialogFooter>
219 -
								<DialogClose asChild>
220 -
									<Button variant="outline">Cancel</Button>
221 -
								</DialogClose>
222 -
								<Button onClick={addFeed} type="submit">
223 -
									Submit
224 -
								</Button>
225 -
							</DialogFooter>
226 -
						</DialogContent>
227 181
						<SidebarGroup>
228 -
							<SidebarGroupContent className="px-1.5 md:px-0">
182 +
							<SidebarGroupLabel>Actions</SidebarGroupLabel>
183 +
							<SidebarGroupContent>
229 184
								<SidebarMenu>
230 -
									<DialogTrigger>
185 +
									<DialogTrigger asChild>
231 186
										<SidebarMenuItem>
232 -
											<SidebarMenuButton
233 -
												size="lg"
234 -
												asChild
235 -
												className="md:h-8 md:p-0"
236 -
											>
237 -
												<a href="#">
238 -
													<div className="flex aspect-square size-8 items-center justify-center rounded-lg">
239 -
														<Plus className="size-4" />
240 -
													</div>
241 -
													<div className="grid flex-1 text-left text-sm leading-tight">
242 -
														<span className="truncate font-medium">
243 -
															Add Feed
244 -
														</span>
245 -
													</div>
246 -
												</a>
187 +
											<SidebarMenuButton>
188 +
												<Plus className="size-4" />
189 +
												<span>Add Feed</span>
247 190
											</SidebarMenuButton>
248 191
										</SidebarMenuItem>
249 192
									</DialogTrigger>
250 193
									<SidebarMenuItem>
251 194
										<SidebarMenuButton onClick={reset}>
252 195
											<RotateCw className="size-4" />
196 +
											<span>Reset</span>
253 197
										</SidebarMenuButton>
254 198
									</SidebarMenuItem>
255 -
									{data.navMain.map((item) => (
256 -
										<SidebarMenuItem key={item.title}>
257 -
											<SidebarMenuButton
258 -
												tooltip={{
259 -
													children: item.title,
260 -
													hidden: false,
261 -
												}}
262 -
												onClick={() => {
263 -
													setActiveItem(item);
264 -
													const mail = data.mails.sort(
265 -
														() => Math.random() - 0.5,
266 -
													);
267 -
													setMails(
268 -
														mail.slice(
269 -
															0,
270 -
															Math.max(5, Math.floor(Math.random() * 10) + 1),
271 -
														),
272 -
													);
273 -
													setOpen(true);
274 -
												}}
275 -
												isActive={activeItem?.title === item.title}
276 -
												className="px-2.5 md:px-2"
277 -
											>
278 -
												<item.icon />
279 -
												<span>{item.title}</span>
280 -
											</SidebarMenuButton>
281 -
										</SidebarMenuItem>
282 -
									))}
283 199
								</SidebarMenu>
284 200
							</SidebarGroupContent>
285 201
						</SidebarGroup>
202 +
						<NavFeeds
203 +
							feeds={allFeeds}
204 +
							selectedFeedId={selectedFeedId}
205 +
							onFeedSelect={onFeedSelect}
206 +
						/>
286 207
					</SidebarContent>
287 208
					<SidebarFooter>
288 209
						<NavUser user={data.user} />
289 210
					</SidebarFooter>
290 211
				</Sidebar>
212 +
				<DialogContent className="sm:max-w-[425px]">
213 +
					<DialogHeader>
214 +
						<DialogTitle>Add Feed</DialogTitle>
215 +
						<DialogDescription>
216 +
							Add a new feed with the RSS URL
217 +
						</DialogDescription>
218 +
					</DialogHeader>
219 +
					<div className="grid gap-4">
220 +
						<div className="grid gap-3">
221 +
							<Label htmlFor="url-input">URL</Label>
222 +
							<Input
223 +
								id="url-input"
224 +
								name="url"
225 +
								value={urlInput}
226 +
								onChange={(e) => setUrlInput(e.target.value)}
227 +
							/>
228 +
						</div>
229 +
						<div className="grid gap-3">
230 +
							<Label htmlFor="category-input">Category</Label>
231 +
							<Input
232 +
								id="category-input"
233 +
								name="category"
234 +
								value={categoryInput}
235 +
								onChange={(e) => setCategoryInput(e.target.value)}
236 +
								placeholder="e.g., Tech, News, Blogs"
237 +
							/>
238 +
						</div>
239 +
					</div>
240 +
					<DialogFooter>
241 +
						<DialogClose asChild>
242 +
							<Button variant="outline">Cancel</Button>
243 +
						</DialogClose>
244 +
						<Button onClick={addFeed} type="submit">
245 +
							Submit
246 +
						</Button>
247 +
					</DialogFooter>
248 +
				</DialogContent>
249 +
			</Dialog>
291 250
292 -
				{/* This is the second sidebar */}
293 -
				{/* We disable collapsible and let it fill remaining space */}
294 -
				<Sidebar collapsible="none" className="hidden flex-1 md:flex">
295 -
					<SidebarHeader className="gap-3.5 border-b p-4">
296 -
						<div className="flex w-full items-center justify-between">
297 -
							<div className="text-foreground text-base font-medium">
298 -
								{activeItem?.title}
299 -
							</div>
300 -
							<Label className="flex items-center gap-2 text-sm">
301 -
								<span>Unreads</span>
302 -
								<Switch className="shadow-none" />
303 -
							</Label>
251 +
			{/* Posts List Panel - Separate from main sidebar */}
252 +
			<div className="bg-sidebar text-sidebar-foreground hidden md:flex h-full w-[320px] flex-col border-r">
253 +
				<div className="gap-2 border-b p-3 flex flex-col">
254 +
					<div className="flex w-full items-center justify-between">
255 +
						<div className="text-foreground text-sm font-semibold truncate">
256 +
							{selectedFeedId
257 +
								? allFeeds.find((f) => f.id === selectedFeedId)?.title ||
258 +
									"Posts"
259 +
								: "All Posts"}
304 260
						</div>
305 -
						<SidebarInput placeholder="Type to search..." />
306 -
					</SidebarHeader>
307 -
					<SidebarContent>
308 -
						<SidebarGroup className="px-0">
309 -
							<SidebarGroupContent>
310 -
								{allFeeds.map((feed) => (
311 -
									<a
312 -
										href="#"
313 -
										key={feed.id}
314 -
										className="hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex flex-col items-start gap-2 border-b p-4 text-sm leading-tight whitespace-nowrap last:border-b-0"
315 -
									>
316 -
										<div className="flex w-full items-center gap-2">
317 -
											<span>{feed.title}</span>{" "}
318 -
										</div>
319 -
									</a>
320 -
								))}
321 -
							</SidebarGroupContent>
322 -
						</SidebarGroup>
323 -
					</SidebarContent>
324 -
				</Sidebar>
325 -
			</Sidebar>
326 -
		</Dialog>
261 +
						<span className="text-muted-foreground text-xs whitespace-nowrap ml-2">
262 +
							{filteredPosts.length}
263 +
						</span>
264 +
					</div>
265 +
					<Input
266 +
						placeholder="Search..."
267 +
						value={searchQuery}
268 +
						onChange={(e) => setSearchQuery(e.target.value)}
269 +
						className="h-8"
270 +
					/>
271 +
				</div>
272 +
				<div className="flex-1 overflow-y-auto">
273 +
					{filteredPosts.length === 0 ? (
274 +
						<div className="p-4 text-center text-sm text-muted-foreground">
275 +
							No posts found
276 +
						</div>
277 +
					) : (
278 +
						filteredPosts.map((post) => (
279 +
							<button
280 +
								key={post.id}
281 +
								onClick={() => onPostSelect(post.id)}
282 +
								className={`hover:bg-sidebar-accent flex flex-col items-start gap-1.5 border-b px-3 py-3 text-sm text-left w-full last:border-b-0 transition-colors ${
283 +
									selectedPostId === post.id ? "bg-sidebar-accent" : ""
284 +
								}`}
285 +
							>
286 +
								<span className="font-medium line-clamp-2 leading-snug">
287 +
									{post.title}
288 +
								</span>
289 +
								{post.author && (
290 +
									<span className="text-muted-foreground text-xs">
291 +
										{post.author}
292 +
									</span>
293 +
								)}
294 +
							</button>
295 +
						))
296 +
					)}
297 +
				</div>
298 +
			</div>
299 +
		</>
327 300
	);
328 301
}
src/components/dashboard.tsx +100 −14
1 +
"use client";
2 +
3 +
import * as React from "react";
1 4
import { AppSidebar } from "@/components/app-sidebar";
2 5
import {
3 6
	Breadcrumb,
13 16
	SidebarProvider,
14 17
	SidebarTrigger,
15 18
} from "@/components/ui/sidebar";
19 +
import { Button } from "@/components/ui/button";
20 +
import { ExternalLink } from "lucide-react";
21 +
import { useQuery } from "@evolu/react";
22 +
import { allFeedsQuery, allPostsQuery } from "@/lib/evolu";
16 23
17 24
function Dashboard() {
25 +
	const [selectedFeedId, setSelectedFeedId] = React.useState<string | null>(
26 +
		null,
27 +
	);
28 +
	const [selectedPostId, setSelectedPostId] = React.useState<string | null>(
29 +
		null,
30 +
	);
31 +
32 +
	const allFeeds = useQuery(allFeedsQuery);
33 +
	const allPosts = useQuery(allPostsQuery);
34 +
35 +
	const selectedFeed = selectedFeedId
36 +
		? allFeeds.find((f) => f.id === selectedFeedId)
37 +
		: null;
38 +
39 +
	const selectedPost = selectedPostId
40 +
		? allPosts.find((p) => p.id === selectedPostId)
41 +
		: null;
42 +
18 43
	return (
19 -
		<main className="min-h-screen w-full items-center justify-center flex-col flex gap-2">
44 +
		<main className="min-h-screen w-full">
20 45
			<SidebarProvider
21 46
				style={
22 47
					{
23 -
						"--sidebar-width": "350px",
48 +
						"--sidebar-width": "250px",
49 +
						"--sidebar-width-icon": "3rem",
24 50
					} as React.CSSProperties
25 51
				}
26 52
			>
27 -
				<AppSidebar />
53 +
				<AppSidebar
54 +
					selectedFeedId={selectedFeedId}
55 +
					onFeedSelect={setSelectedFeedId}
56 +
					selectedPostId={selectedPostId}
57 +
					onPostSelect={setSelectedPostId}
58 +
				/>
28 59
				<SidebarInset>
29 60
					<header className="bg-background sticky top-0 flex shrink-0 items-center gap-2 border-b p-4">
30 61
						<SidebarTrigger className="-ml-1" />
35 66
						<Breadcrumb>
36 67
							<BreadcrumbList>
37 68
								<BreadcrumbItem className="hidden md:block">
38 -
									<BreadcrumbLink href="#">All Inboxes</BreadcrumbLink>
39 -
								</BreadcrumbItem>
40 -
								<BreadcrumbSeparator className="hidden md:block" />
41 -
								<BreadcrumbItem>
42 -
									<BreadcrumbPage>Inbox</BreadcrumbPage>
69 +
									<BreadcrumbLink
70 +
										href="#"
71 +
										onClick={() => {
72 +
											setSelectedFeedId(null);
73 +
											setSelectedPostId(null);
74 +
										}}
75 +
									>
76 +
										All Feeds
77 +
									</BreadcrumbLink>
43 78
								</BreadcrumbItem>
79 +
								{selectedFeed && (
80 +
									<>
81 +
										<BreadcrumbSeparator className="hidden md:block" />
82 +
										<BreadcrumbItem>
83 +
											<BreadcrumbPage>{selectedFeed.title}</BreadcrumbPage>
84 +
										</BreadcrumbItem>
85 +
									</>
86 +
								)}
44 87
							</BreadcrumbList>
45 88
						</Breadcrumb>
46 89
					</header>
47 90
					<div className="flex flex-1 flex-col gap-4 p-4">
48 -
						{Array.from({ length: 24 }).map((_, index) => (
49 -
							<div
50 -
								key={index}
51 -
								className="bg-muted/50 aspect-video h-12 w-full rounded-lg"
52 -
							/>
53 -
						))}
91 +
						{selectedPost ? (
92 +
							<div className="flex flex-col gap-6 max-w-4xl mx-auto w-full">
93 +
								<div className="flex flex-col gap-3">
94 +
									<h1 className="text-3xl font-bold tracking-tight">
95 +
										{selectedPost.title}
96 +
									</h1>
97 +
									<div className="flex items-center gap-4 text-sm text-muted-foreground">
98 +
										{selectedPost.author && (
99 +
											<span>By {selectedPost.author}</span>
100 +
										)}
101 +
										{selectedPost.link && (
102 +
											<Button variant="outline" size="sm" asChild>
103 +
												<a
104 +
													href={selectedPost.link}
105 +
													target="_blank"
106 +
													rel="noopener noreferrer"
107 +
													className="flex items-center gap-2"
108 +
												>
109 +
													<ExternalLink className="h-3 w-3" />
110 +
													Open Original
111 +
												</a>
112 +
											</Button>
113 +
										)}
114 +
									</div>
115 +
								</div>
116 +
								<Separator />
117 +
								<div className="prose prose-sm dark:prose-invert max-w-none">
118 +
									{selectedPost.content ? (
119 +
										<div
120 +
											dangerouslySetInnerHTML={{ __html: selectedPost.content }}
121 +
										/>
122 +
									) : (
123 +
										<p className="text-muted-foreground">
124 +
											No content available. Click "Open Original" to read the
125 +
											full article.
126 +
										</p>
127 +
									)}
128 +
								</div>
129 +
							</div>
130 +
						) : (
131 +
							<div className="flex flex-col items-center justify-center h-full text-center gap-4">
132 +
								<div className="text-muted-foreground">
133 +
									<p className="text-lg font-medium">No post selected</p>
134 +
									<p className="text-sm">
135 +
										Select a post from the sidebar to read it here
136 +
									</p>
137 +
								</div>
138 +
							</div>
139 +
						)}
54 140
					</div>
55 141
				</SidebarInset>
56 142
			</SidebarProvider>
src/components/nav-feeds.tsx (added) +103 −0
1 +
"use client";
2 +
3 +
import { ChevronRight, Rss } from "lucide-react";
4 +
5 +
import {
6 +
	Collapsible,
7 +
	CollapsibleContent,
8 +
	CollapsibleTrigger,
9 +
} from "@/components/ui/collapsible";
10 +
import {
11 +
	SidebarGroup,
12 +
	SidebarGroupLabel,
13 +
	SidebarMenu,
14 +
	SidebarMenuButton,
15 +
	SidebarMenuItem,
16 +
	SidebarMenuSub,
17 +
	SidebarMenuSubButton,
18 +
	SidebarMenuSubItem,
19 +
} from "@/components/ui/sidebar";
20 +
21 +
interface Feed {
22 +
	id: string;
23 +
	title: string;
24 +
	category: string | null;
25 +
}
26 +
27 +
interface NavFeedsProps {
28 +
	feeds: Feed[];
29 +
	selectedFeedId?: string | null;
30 +
	onFeedSelect: (feedId: string | null) => void;
31 +
}
32 +
33 +
export function NavFeeds({
34 +
	feeds,
35 +
	selectedFeedId,
36 +
	onFeedSelect,
37 +
}: NavFeedsProps) {
38 +
	// Group feeds by category
39 +
	const feedsByCategory = feeds.reduce(
40 +
		(acc, feed) => {
41 +
			const category = feed.category || "Uncategorized";
42 +
			if (!acc[category]) {
43 +
				acc[category] = [];
44 +
			}
45 +
			acc[category].push(feed);
46 +
			return acc;
47 +
		},
48 +
		{} as Record<string, Feed[]>,
49 +
	);
50 +
51 +
	const categories = Object.keys(feedsByCategory).sort();
52 +
53 +
	return (
54 +
		<SidebarGroup>
55 +
			<SidebarGroupLabel>Feeds</SidebarGroupLabel>
56 +
			<SidebarMenu>
57 +
				{/* All Feeds option */}
58 +
				<SidebarMenuItem>
59 +
					<SidebarMenuButton
60 +
						onClick={() => onFeedSelect(null)}
61 +
						isActive={selectedFeedId === null}
62 +
					>
63 +
						<Rss className="h-4 w-4" />
64 +
						<span>All Feeds</span>
65 +
					</SidebarMenuButton>
66 +
				</SidebarMenuItem>
67 +
68 +
				{/* Categories with feeds */}
69 +
				{categories.map((category) => (
70 +
					<Collapsible
71 +
						key={category}
72 +
						asChild
73 +
						defaultOpen={true}
74 +
						className="group/collapsible"
75 +
					>
76 +
						<SidebarMenuItem>
77 +
							<CollapsibleTrigger asChild>
78 +
								<SidebarMenuButton tooltip={category}>
79 +
									<span className="font-medium">{category}</span>
80 +
									<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
81 +
								</SidebarMenuButton>
82 +
							</CollapsibleTrigger>
83 +
							<CollapsibleContent>
84 +
								<SidebarMenuSub>
85 +
									{feedsByCategory[category].map((feed) => (
86 +
										<SidebarMenuSubItem key={feed.id}>
87 +
											<SidebarMenuSubButton
88 +
												onClick={() => onFeedSelect(feed.id)}
89 +
												isActive={selectedFeedId === feed.id}
90 +
											>
91 +
												<span>{feed.title}</span>
92 +
											</SidebarMenuSubButton>
93 +
										</SidebarMenuSubItem>
94 +
									))}
95 +
								</SidebarMenuSub>
96 +
							</CollapsibleContent>
97 +
						</SidebarMenuItem>
98 +
					</Collapsible>
99 +
				))}
100 +
			</SidebarMenu>
101 +
		</SidebarGroup>
102 +
	);
103 +
}
src/lib/evolu.ts +17 −0
15 15
	db.selectFrom("rssFeed").selectAll(),
16 16
);
17 17
18 +
export const postsByFeedQuery = (feedId: string) =>
19 +
	evolu.createQuery((db) =>
20 +
		db
21 +
			.selectFrom("rssPost")
22 +
			.selectAll()
23 +
			.where("feedId", "=", feedId as any)
24 +
			.orderBy("id", "desc"),
25 +
	);
26 +
27 +
export const allPostsQuery = evolu.createQuery((db) =>
28 +
	db.selectFrom("rssPost").selectAll().orderBy("id", "desc"),
29 +
);
30 +
31 +
export const feedsByCategoryQuery = evolu.createQuery((db) =>
32 +
	db.selectFrom("rssFeed").selectAll().orderBy("category", "asc"),
33 +
);
34 +
18 35
export function reset() {
19 36
	evolu.resetAppOwner();
20 37
}