chore: refactored app-sidebar b7ef1b38
Steve · 2025-11-02 15:19 7 file(s) · +613 −537
src/components/add-feed-dialog.tsx (added) +170 −0
1 +
import * as React from "react";
2 +
import { Button } from "@/components/ui/button";
3 +
import {
4 +
	Dialog,
5 +
	DialogClose,
6 +
	DialogContent,
7 +
	DialogDescription,
8 +
	DialogFooter,
9 +
	DialogHeader,
10 +
	DialogTitle,
11 +
} from "@/components/ui/dialog";
12 +
import { Input } from "@/components/ui/input";
13 +
import { Label } from "@/components/ui/label";
14 +
import { toast } from "sonner";
15 +
import { useEvolu } from "@/lib/evolu";
16 +
import {
17 +
	fetchFeedWithFallback,
18 +
	parseFeedXml,
19 +
	discoverFeed,
20 +
	looksLikeFeedUrl,
21 +
	extractPostLink,
22 +
	extractPostAuthor,
23 +
	extractPostContent,
24 +
	extractPostDate,
25 +
} from "@/lib/feed-operations";
26 +
27 +
interface AddFeedDialogProps {
28 +
	open: boolean;
29 +
	onOpenChange: (open: boolean) => void;
30 +
}
31 +
32 +
export function AddFeedDialog({ open, onOpenChange }: AddFeedDialogProps) {
33 +
	const [urlInput, setUrlInput] = React.useState("");
34 +
	const [categoryInput, setCategoryInput] = React.useState("");
35 +
	const [isAddingFeed, setIsAddingFeed] = React.useState(false);
36 +
	const [statusMessage, setStatusMessage] = React.useState("");
37 +
38 +
	const evolu = useEvolu();
39 +
40 +
	async function addFeed() {
41 +
		if (!urlInput.trim()) {
42 +
			setStatusMessage("Please enter a URL");
43 +
			return;
44 +
		}
45 +
46 +
		setIsAddingFeed(true);
47 +
		setStatusMessage("");
48 +
49 +
		try {
50 +
			// Try to discover feeds if the URL doesn't look like a direct feed URL
51 +
			let feedUrl = urlInput;
52 +
			let xmlData: string | null = null;
53 +
54 +
			if (!looksLikeFeedUrl(urlInput)) {
55 +
				const discovered = await discoverFeed(urlInput);
56 +
57 +
				if (!discovered) {
58 +
					setStatusMessage(
59 +
						"Could not find an RSS feed at this URL. Please enter a direct feed URL.",
60 +
					);
61 +
					setIsAddingFeed(false);
62 +
					return;
63 +
				}
64 +
65 +
				feedUrl = discovered.feedUrl;
66 +
				xmlData = discovered.xmlData;
67 +
			} else {
68 +
				// Direct feed URL - try to fetch it
69 +
				xmlData = await fetchFeedWithFallback(feedUrl);
70 +
			}
71 +
72 +
			const { feedData, posts, isAtom } = parseFeedXml(xmlData);
73 +
74 +
			const result = evolu.insert("rssFeed", {
75 +
				feedUrl: feedUrl,
76 +
				title: feedData.title,
77 +
				description: feedData.description || feedData.subtitle || "",
78 +
				category: categoryInput || "Uncategorized",
79 +
				dateUpdated: new Date().toISOString(),
80 +
			});
81 +
82 +
			if (!result.ok) {
83 +
				throw new Error("Failed to insert feed");
84 +
			}
85 +
86 +
			// Process posts/entries
87 +
			for (const post of posts) {
88 +
				evolu.insert("rssPost", {
89 +
					title: post.title,
90 +
					author: extractPostAuthor(post, isAtom, feedData.title),
91 +
					publishedDate: extractPostDate(post),
92 +
					link: extractPostLink(post, isAtom),
93 +
					feedId: result.value.id,
94 +
					content: extractPostContent(post),
95 +
				});
96 +
			}
97 +
98 +
			toast.success(
99 +
				`Successfully added "${feedData.title}" with ${posts.length} post${posts.length !== 1 ? "s" : ""}`,
100 +
			);
101 +
102 +
			setUrlInput("");
103 +
			setCategoryInput("");
104 +
			setStatusMessage("");
105 +
			onOpenChange(false);
106 +
		} catch (error) {
107 +
			console.error("Error adding feed:", error);
108 +
			setStatusMessage(
109 +
				error instanceof Error
110 +
					? error.message
111 +
					: "Failed to add feed. Please check the URL and try again.",
112 +
			);
113 +
		} finally {
114 +
			setIsAddingFeed(false);
115 +
		}
116 +
	}
117 +
118 +
	return (
119 +
		<Dialog open={open} onOpenChange={onOpenChange}>
120 +
			<DialogContent className="sm:max-w-[425px]">
121 +
				<DialogHeader>
122 +
					<DialogTitle>Add Feed</DialogTitle>
123 +
					<DialogDescription>
124 +
						Enter a website URL or direct RSS feed URL
125 +
					</DialogDescription>
126 +
				</DialogHeader>
127 +
				<div className="grid gap-4">
128 +
					<div className="grid gap-3">
129 +
						<Label htmlFor="url-input">URL</Label>
130 +
						<Input
131 +
							id="url-input"
132 +
							name="url"
133 +
							value={urlInput}
134 +
							onChange={(e) => setUrlInput(e.target.value)}
135 +
							placeholder="https://example.com"
136 +
							disabled={isAddingFeed}
137 +
						/>
138 +
						<p className="text-xs text-muted-foreground">
139 +
							We'll automatically discover the RSS feed for you
140 +
						</p>
141 +
					</div>
142 +
					<div className="grid gap-3">
143 +
						<Label htmlFor="category-input">Category</Label>
144 +
						<Input
145 +
							id="category-input"
146 +
							name="category"
147 +
							value={categoryInput}
148 +
							onChange={(e) => setCategoryInput(e.target.value)}
149 +
							placeholder="e.g., Tech, News, Blogs"
150 +
							disabled={isAddingFeed}
151 +
						/>
152 +
					</div>
153 +
					{statusMessage && (
154 +
						<div className="text-sm text-primary">{statusMessage}</div>
155 +
					)}
156 +
				</div>
157 +
				<DialogFooter>
158 +
					<DialogClose asChild>
159 +
						<Button variant="outline" disabled={isAddingFeed}>
160 +
							Cancel
161 +
						</Button>
162 +
					</DialogClose>
163 +
					<Button onClick={addFeed} type="submit" disabled={isAddingFeed}>
164 +
						{isAddingFeed ? "Adding..." : "Submit"}
165 +
					</Button>
166 +
				</DialogFooter>
167 +
			</DialogContent>
168 +
		</Dialog>
169 +
	);
170 +
}
src/components/app-sidebar.tsx +91 −535
1 1
"use client";
2 2
3 3
import * as React from "react";
4 -
import {
5 -
	Plus,
6 -
	RotateCw,
7 -
	MoreVertical,
8 -
	Check,
9 -
	X,
10 -
	ChevronLeft,
11 -
} from "lucide-react";
12 -
13 4
import { NavUser } from "@/components/nav-user";
14 5
import { NavFeeds } from "@/components/nav-feeds";
6 +
import { FeedActions } from "@/components/feed-actions";
7 +
import { AddFeedDialog } from "@/components/add-feed-dialog";
8 +
import { PostsList } from "@/components/posts-list";
9 +
import { MobilePostsHeader } from "@/components/mobile-posts-header";
15 10
import {
16 11
	Sidebar,
17 12
	SidebarContent,
18 13
	SidebarFooter,
19 -
	SidebarGroup,
20 -
	SidebarGroupContent,
21 -
	SidebarGroupLabel,
22 14
	SidebarHeader,
23 15
	SidebarMenu,
24 -
	SidebarMenuButton,
25 16
	SidebarMenuItem,
26 17
	useSidebar,
27 18
} from "@/components/ui/sidebar";
28 -
29 -
import { Button } from "@/components/ui/button";
30 -
import {
31 -
	Dialog,
32 -
	DialogClose,
33 -
	DialogContent,
34 -
	DialogDescription,
35 -
	DialogFooter,
36 -
	DialogHeader,
37 -
	DialogTitle,
38 -
	DialogTrigger,
39 -
} from "@/components/ui/dialog";
40 -
import {
41 -
	DropdownMenu,
42 -
	DropdownMenuContent,
43 -
	DropdownMenuItem,
44 -
	DropdownMenuTrigger,
45 -
} from "@/components/ui/dropdown-menu";
46 -
import { Input } from "@/components/ui/input";
47 -
import { Label } from "@/components/ui/label";
48 19
import { toast } from "sonner";
49 20
import {
50 21
	allFeedsQuery,
55 26
	useEvolu,
56 27
	reset,
57 28
} from "@/lib/evolu";
58 -
import { XMLParser } from "fast-xml-parser";
59 29
import { useQuery } from "@evolu/react";
60 -
import { COMMON_FEED_PATHS } from "@/lib/feed-discovery";
61 -
const parser = new XMLParser();
30 +
import {
31 +
	fetchFeedWithFallback,
32 +
	parseFeedXml,
33 +
	extractPostLink,
34 +
	extractPostAuthor,
35 +
	extractPostContent,
36 +
	extractPostDate,
37 +
} from "@/lib/feed-operations";
62 38
63 39
// This is sample data
64 40
const data = {
83 59
	onPostSelect = () => {},
84 60
	...props
85 61
}: AppSidebarProps) {
86 -
	const [urlInput, setUrlInput] = React.useState("");
87 -
	const [categoryInput, setCategoryInput] = React.useState("");
88 62
	const [dialogOpen, setDialogOpen] = React.useState(false);
89 63
	const [searchQuery, setSearchQuery] = React.useState("");
90 -
	const [isAddingFeed, setIsAddingFeed] = React.useState(false);
91 -
	const [statusMessage, setStatusMessage] = React.useState("");
92 -
	// Mobile navigation state: 'feeds' or 'posts'
93 64
	const [mobileView, setMobileView] = React.useState<"feeds" | "posts">(
94 65
		"feeds",
95 66
	);
269 240
				try {
270 241
					if (!feed.feedUrl) continue;
271 242
272 -
					let xmlData: string;
273 -
274 -
					// Try to fetch directly first
275 -
					try {
276 -
						const xmlFetch = await fetch(feed.feedUrl);
277 -
						xmlData = await xmlFetch.text();
278 -
					} catch (corsError) {
279 -
						// Fall back to corsproxy.io if CORS error occurs
280 -
						const xmlFetch = await fetch(
281 -
							`https://corsproxy.io/?url=${encodeURIComponent(feed.feedUrl)}`,
282 -
						);
283 -
						xmlData = await xmlFetch.text();
284 -
					}
285 -
286 -
					const parsedXmlData = await parser.parse(xmlData);
287 -
288 -
					// Determine if it's RSS or Atom feed
289 -
					let feedData: any;
290 -
					let posts: any[];
291 -
					let isAtom = false;
292 -
293 -
					if (parsedXmlData.rss) {
294 -
						// RSS feed
295 -
						feedData = parsedXmlData.rss.channel;
296 -
						posts = feedData.item || [];
297 -
					} else if (parsedXmlData.feed) {
298 -
						// Atom feed
299 -
						feedData = parsedXmlData.feed;
300 -
						posts = feedData.entry || [];
301 -
						isAtom = true;
302 -
					} else {
303 -
						console.warn(`Unsupported feed format for ${feed.title}`);
304 -
						continue;
305 -
					}
243 +
					const xmlData = await fetchFeedWithFallback(feed.feedUrl);
244 +
					const { feedData, posts, isAtom } = parseFeedXml(xmlData);
306 245
307 246
					// Get existing posts for this feed to avoid duplicates
308 -
					// Use allPosts to ensure we check against all posts in the database
309 247
					const existingPosts = allPosts.filter((p) => p.feedId === feed.id);
310 248
					const existingLinks = new Set(existingPosts.map((p) => p.link));
311 249
312 250
					// Process new posts/entries
313 251
					let newPostsCount = 0;
314 252
					for (const post of posts) {
315 -
						const postLink = isAtom
316 -
							? typeof post.link === "string"
317 -
								? post.link || post.id
318 -
								: post.link?.[0] || post.id
319 -
							: post.link || post.id;
253 +
						const postLink = extractPostLink(post, isAtom);
320 254
321 255
						// Skip if we already have this post
322 -
						if (existingLinks.has(postLink)) {
256 +
						if (existingLinks.has(postLink as any)) {
323 257
							continue;
324 258
						}
325 259
326 260
						evolu.insert("rssPost", {
327 261
							title: post.title,
328 -
							author: isAtom
329 -
								? post.author?.name || feedData.title
330 -
								: post.author || feedData.title,
331 -
							publishedDate: new Date(
332 -
								post.pubDate || post.updated,
333 -
							).toISOString(),
262 +
							author: extractPostAuthor(post, isAtom, feedData.title),
263 +
							publishedDate: extractPostDate(post),
334 264
							link: postLink,
335 265
							feedId: feed.id,
336 -
							content:
337 -
								post["content:encoded"] ||
338 -
								post.content ||
339 -
								"Please open on the web",
266 +
							content: extractPostContent(post),
340 267
						});
341 268
						newPostsCount++;
342 269
					}
376 303
		// eslint-disable-next-line react-hooks/exhaustive-deps
377 304
	}, []); // Only run once on mount
378 305
379 -
	async function addFeed() {
380 -
		if (!urlInput.trim()) {
381 -
			setStatusMessage("Please enter a URL");
382 -
			return;
383 -
		}
384 -
385 -
		setIsAddingFeed(true);
386 -
		setStatusMessage("");
387 -
388 -
		try {
389 -
			// Try to discover feeds if the URL doesn't look like a direct feed URL
390 -
			let feedUrl = urlInput;
391 -
			const looksLikeFeedUrl =
392 -
				urlInput.includes("/feed") ||
393 -
				urlInput.includes("/rss") ||
394 -
				urlInput.includes(".xml") ||
395 -
				urlInput.includes("/atom");
396 -
397 -
			let xmlData: string | null = null;
398 -
399 -
			if (!looksLikeFeedUrl) {
400 -
				// Try common feed paths using CORS proxy
401 -
				const urlObj = new URL(urlInput);
402 -
				const origin = urlObj.origin;
403 -
404 -
				console.log("Trying to discover feed from:", origin);
405 -
406 -
				for (const path of COMMON_FEED_PATHS) {
407 -
					const testUrl = `${origin}${path}`;
408 -
					console.log("Testing:", testUrl);
409 -
410 -
					try {
411 -
						// Use CORS proxy to avoid CORS issues
412 -
						const response = await fetch(
413 -
							`https://corsproxy.io/?url=${encodeURIComponent(testUrl)}`,
414 -
						);
415 -
416 -
						if (response.ok) {
417 -
							const text = await response.text();
418 -
							// Quick check if it looks like XML
419 -
							if (
420 -
								text.trim().startsWith("<?xml") ||
421 -
								text.includes("<rss") ||
422 -
								text.includes("<feed")
423 -
							) {
424 -
								xmlData = text;
425 -
								feedUrl = testUrl;
426 -
								console.log("Found feed at:", testUrl);
427 -
								break;
428 -
							}
429 -
						}
430 -
					} catch (error) {
431 -
						console.log("Failed to fetch:", testUrl, error);
432 -
						continue;
433 -
					}
434 -
				}
435 -
436 -
				if (!xmlData) {
437 -
					setStatusMessage(
438 -
						"Could not find an RSS feed at this URL. Please enter a direct feed URL.",
439 -
					);
440 -
					setIsAddingFeed(false);
441 -
					return;
442 -
				}
443 -
			} else {
444 -
				// Direct feed URL - try to fetch it
445 -
				try {
446 -
					// Try to fetch directly first
447 -
					const xmlFetch = await fetch(feedUrl);
448 -
					console.log("Status code: ", xmlFetch.status);
449 -
					console.log("Request ok: ", xmlFetch.ok);
450 -
					xmlData = await xmlFetch.text();
451 -
				} catch (corsError) {
452 -
					// Fall back to AllOrigins if CORS error occurs
453 -
					console.log(corsError);
454 -
					const xmlFetch = await fetch(
455 -
						`https://api.allorigins.win/raw?url=${feedUrl}`,
456 -
					);
457 -
					xmlData = await xmlFetch.text();
458 -
				}
459 -
			}
460 -
461 -
			const parsedXmlData = await parser.parse(xmlData);
462 -
			console.log(parsedXmlData);
463 -
464 -
			// Determine if it's RSS or Atom feed
465 -
			let feedData: any;
466 -
			let posts: any[];
467 -
			let isAtom = false;
468 -
469 -
			if (parsedXmlData.rss) {
470 -
				// RSS feed
471 -
				feedData = parsedXmlData.rss.channel;
472 -
				posts = feedData.item || [];
473 -
			} else if (parsedXmlData.feed) {
474 -
				// Atom feed
475 -
				feedData = parsedXmlData.feed;
476 -
				posts = feedData.entry || [];
477 -
				isAtom = true;
478 -
			} else {
479 -
				throw new Error("Unsupported feed format");
480 -
			}
481 -
482 -
			const result = evolu.insert("rssFeed", {
483 -
				feedUrl: feedUrl,
484 -
				title: feedData.title,
485 -
				description: feedData.description || feedData.subtitle || "",
486 -
				category: categoryInput || "Uncategorized",
487 -
				dateUpdated: new Date().toISOString(),
488 -
			});
489 -
490 -
			if (!result.ok) {
491 -
				throw new Error("Failed to insert feed");
492 -
			}
493 -
494 -
			// Process posts/entries
495 -
			for (const post of posts) {
496 -
				evolu.insert("rssPost", {
497 -
					title: post.title,
498 -
					author: isAtom
499 -
						? post.author?.name || feedData.title
500 -
						: post.author || feedData.title,
501 -
					publishedDate: new Date(post.pubDate || post.updated).toISOString(),
502 -
					link: isAtom
503 -
						? typeof post.link === "string"
504 -
							? post.link || post.id
505 -
							: post.link?.[0] || post.id
506 -
						: post.link || post.id,
507 -
					feedId: result.value.id,
508 -
					content:
509 -
						post["content:encoded"] || post.content || "Please open on the web",
510 -
				});
511 -
			}
512 -
513 -
			toast.success(
514 -
				`Successfully added "${feedData.title}" with ${posts.length} post${posts.length !== 1 ? "s" : ""}`,
515 -
			);
516 -
517 -
			setUrlInput("");
518 -
			setCategoryInput("");
519 -
			setStatusMessage("");
520 -
			setDialogOpen(false);
521 -
		} catch (error) {
522 -
			console.error("Error adding feed:", error);
523 -
			setStatusMessage(
524 -
				error instanceof Error
525 -
					? error.message
526 -
					: "Failed to add feed. Please check the URL and try again.",
527 -
			);
528 -
		} finally {
529 -
			setIsAddingFeed(false);
530 -
		}
531 -
	}
306 +
	const selectedFeedTitle = selectedFeedId
307 +
		? allFeeds.find((f) => f.id === selectedFeedId)?.title || "Posts"
308 +
		: "All Posts";
532 309
533 310
	return (
534 311
		<>
535 -
			<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
536 -
				<Sidebar collapsible="offcanvas" {...props}>
537 -
					<SidebarHeader>
538 -
						<SidebarMenu>
539 -
							<SidebarMenuItem>
540 -
								{isMobile && mobileView === "posts" ? (
541 -
									<div className="flex items-center gap-2 px-2 pt-2">
542 -
										<Button
543 -
											variant="ghost"
544 -
											size="sm"
545 -
											onClick={handleBackToFeeds}
546 -
											className="h-8 w-8 p-0"
547 -
										>
548 -
											<ChevronLeft className="size-5" />
549 -
										</Button>
550 -
										<div className="flex-1 text-left text-xl">
551 -
											<span className="truncate font-bold">
552 -
												{selectedFeedId
553 -
													? allFeeds.find((f) => f.id === selectedFeedId)
554 -
															?.title || "Posts"
555 -
													: "All Posts"}
556 -
											</span>
557 -
										</div>
558 -
									</div>
559 -
								) : (
560 -
									<a href="#">
561 -
										<div className="grid flex-1 text-left text-xl px-2 pt-2">
562 -
											<span className="truncate font-bold">Alcove</span>
563 -
										</div>
564 -
									</a>
565 -
								)}
566 -
							</SidebarMenuItem>
567 -
						</SidebarMenu>
568 -
					</SidebarHeader>
569 -
					<SidebarContent>
570 -
						{isMobile && mobileView === "posts" ? (
571 -
							// Mobile posts view
572 -
							<div className="flex flex-col h-full">
573 -
								<div className="gap-2 border-b p-3 flex flex-col">
574 -
									<div className="flex w-full items-center justify-between gap-2">
575 -
										<div className="text-foreground text-sm font-semibold truncate">
576 -
											{filteredPosts.length} post
577 -
											{filteredPosts.length !== 1 ? "s" : ""}
578 -
										</div>
579 -
										<DropdownMenu>
580 -
											<DropdownMenuTrigger asChild>
581 -
												<Button
582 -
													variant="ghost"
583 -
													size="sm"
584 -
													className="h-6 w-6 p-0"
585 -
												>
586 -
													<MoreVertical className="h-4 w-4" />
587 -
												</Button>
588 -
											</DropdownMenuTrigger>
589 -
											<DropdownMenuContent align="end">
590 -
												<DropdownMenuItem onClick={handleMarkAllAsRead}>
591 -
													<Check className="h-4 w-4 mr-2" />
592 -
													Mark all as read
593 -
												</DropdownMenuItem>
594 -
												<DropdownMenuItem onClick={handleMarkAllAsUnread}>
595 -
													<X className="h-4 w-4 mr-2" />
596 -
													Mark all as unread
597 -
												</DropdownMenuItem>
598 -
											</DropdownMenuContent>
599 -
										</DropdownMenu>
312 +
			<AddFeedDialog open={dialogOpen} onOpenChange={setDialogOpen} />
313 +
314 +
			<Sidebar collapsible="offcanvas" {...props}>
315 +
				<SidebarHeader>
316 +
					<SidebarMenu>
317 +
						<SidebarMenuItem>
318 +
							{isMobile && mobileView === "posts" ? (
319 +
								<MobilePostsHeader
320 +
									feedTitle={selectedFeedTitle}
321 +
									onBack={handleBackToFeeds}
322 +
								/>
323 +
							) : (
324 +
								<a href="#">
325 +
									<div className="grid flex-1 text-left text-xl px-2 pt-2">
326 +
										<span className="truncate font-bold">Alcove</span>
600 327
									</div>
601 -
									<Input
602 -
										placeholder="Search..."
603 -
										value={searchQuery}
604 -
										onChange={(e) => setSearchQuery(e.target.value)}
605 -
										className="h-8"
606 -
									/>
607 -
								</div>
608 -
								<div className="flex-1 overflow-y-auto">
609 -
									{filteredPosts.length === 0 ? (
610 -
										<div className="p-4 text-center text-sm text-muted-foreground">
611 -
											No posts found
612 -
										</div>
613 -
									) : (
614 -
										filteredPosts.map((post) => {
615 -
											const isRead = isPostRead(post.id);
616 -
											return (
617 -
												<button
618 -
													type="button"
619 -
													key={post.id}
620 -
													onClick={() => handlePostSelect(post.id)}
621 -
													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 ${
622 -
														selectedPostId === post.id
623 -
															? "bg-sidebar-accent"
624 -
															: ""
625 -
													}`}
626 -
												>
627 -
													{/* Unread indicator */}
628 -
													<div className="flex-shrink-0 pt-1">
629 -
														{!isRead && (
630 -
															<div className="size-2 rounded-full bg-primary" />
631 -
														)}
632 -
													</div>
633 -
													{/* Post content */}
634 -
													<div className="flex flex-col gap-1.5 flex-1 min-w-0">
635 -
														<span className="font-medium line-clamp-2 leading-snug">
636 -
															{post.title}
637 -
														</span>
638 -
														{post.author && (
639 -
															<span className="text-muted-foreground text-xs">
640 -
																{post.author}
641 -
															</span>
642 -
														)}
643 -
													</div>
644 -
												</button>
645 -
											);
646 -
										})
647 -
									)}
648 -
								</div>
649 -
							</div>
650 -
						) : (
651 -
							// Feeds view (desktop and mobile default)
652 -
							<>
653 -
								<SidebarGroup>
654 -
									<SidebarGroupLabel>Actions</SidebarGroupLabel>
655 -
									<SidebarGroupContent>
656 -
										<SidebarMenu>
657 -
											<SidebarMenuItem>
658 -
												<DialogTrigger asChild>
659 -
													<SidebarMenuButton
660 -
														onClick={() => {
661 -
															setDialogOpen(true);
662 -
														}}
663 -
													>
664 -
														<Plus className="size-4" />
665 -
														<span>Add Feed</span>
666 -
													</SidebarMenuButton>
667 -
												</DialogTrigger>
668 -
											</SidebarMenuItem>
669 -
											<SidebarMenuItem>
670 -
												<SidebarMenuButton onClick={reset}>
671 -
													<RotateCw className="size-4" />
672 -
													<span>Reset</span>
673 -
												</SidebarMenuButton>
674 -
											</SidebarMenuItem>
675 -
											<SidebarMenuItem>
676 -
												<SidebarMenuButton onClick={refreshFeeds}>
677 -
													<RotateCw className="size-4" />
678 -
													<span>Refresh</span>
679 -
												</SidebarMenuButton>
680 -
											</SidebarMenuItem>
681 -
										</SidebarMenu>
682 -
									</SidebarGroupContent>
683 -
								</SidebarGroup>
684 -
								<NavFeeds
685 -
									feeds={allFeeds}
686 -
									selectedFeedId={selectedFeedId}
687 -
									onFeedSelect={handleFeedSelect}
688 -
								/>
689 -
							</>
690 -
						)}
691 -
					</SidebarContent>
692 -
					<SidebarFooter>
693 -
						{!isMobile || mobileView === "feeds" ? (
694 -
							<NavUser user={data.user} />
695 -
						) : null}
696 -
					</SidebarFooter>
697 -
				</Sidebar>
698 -
				<DialogContent className="sm:max-w-[425px]">
699 -
					<DialogHeader>
700 -
						<DialogTitle>Add Feed</DialogTitle>
701 -
						<DialogDescription>
702 -
							Enter a website URL or direct RSS feed URL
703 -
						</DialogDescription>
704 -
					</DialogHeader>
705 -
					<div className="grid gap-4">
706 -
						<div className="grid gap-3">
707 -
							<Label htmlFor="url-input">URL</Label>
708 -
							<Input
709 -
								id="url-input"
710 -
								name="url"
711 -
								value={urlInput}
712 -
								onChange={(e) => setUrlInput(e.target.value)}
713 -
								placeholder="https://example.com"
714 -
								disabled={isAddingFeed}
715 -
							/>
716 -
							<p className="text-xs text-muted-foreground">
717 -
								We'll automatically discover the RSS feed for you
718 -
							</p>
719 -
						</div>
720 -
						<div className="grid gap-3">
721 -
							<Label htmlFor="category-input">Category</Label>
722 -
							<Input
723 -
								id="category-input"
724 -
								name="category"
725 -
								value={categoryInput}
726 -
								onChange={(e) => setCategoryInput(e.target.value)}
727 -
								placeholder="e.g., Tech, News, Blogs"
728 -
								disabled={isAddingFeed}
328 +
								</a>
329 +
							)}
330 +
						</SidebarMenuItem>
331 +
					</SidebarMenu>
332 +
				</SidebarHeader>
333 +
				<SidebarContent>
334 +
					{isMobile && mobileView === "posts" ? (
335 +
						// Mobile posts view
336 +
						<div className="flex flex-col h-full">
337 +
							<PostsList
338 +
								posts={filteredPosts}
339 +
								selectedPostId={selectedPostId}
340 +
								onPostSelect={handlePostSelect}
341 +
								searchQuery={searchQuery}
342 +
								onSearchChange={setSearchQuery}
343 +
								feedTitle={`${filteredPosts.length} post${filteredPosts.length !== 1 ? "s" : ""}`}
344 +
								isPostRead={isPostRead}
345 +
								onMarkAllAsRead={handleMarkAllAsRead}
346 +
								onMarkAllAsUnread={handleMarkAllAsUnread}
347 +
								className="border-0"
729 348
							/>
730 349
						</div>
731 -
						{statusMessage && (
732 -
							<div className="text-sm text-primary">{statusMessage}</div>
733 -
						)}
734 -
					</div>
735 -
					<DialogFooter>
736 -
						<DialogClose asChild>
737 -
							<Button variant="outline" disabled={isAddingFeed}>
738 -
								Cancel
739 -
							</Button>
740 -
						</DialogClose>
741 -
						<Button onClick={addFeed} type="submit" disabled={isAddingFeed}>
742 -
							{isAddingFeed ? "Adding..." : "Submit"}
743 -
						</Button>
744 -
					</DialogFooter>
745 -
				</DialogContent>
746 -
			</Dialog>
747 -
748 -
			{/* Posts List Panel - Separate from main sidebar */}
749 -
			<div
750 -
				className={`bg-sidebar text-sidebar-foreground hidden md:flex h-screen flex-col border-r ${hidden ? "w-0 min-w-0 border-0 overflow-hidden" : "w-[320px] overflow-y-auto"}`}
751 -
			>
752 -
				<div className="gap-2 border-b p-3 flex flex-col">
753 -
					<div className="flex w-full items-center justify-between gap-2">
754 -
						<div className="text-foreground text-sm font-semibold truncate">
755 -
							{selectedFeedId
756 -
								? allFeeds.find((f) => f.id === selectedFeedId)?.title ||
757 -
									"Posts"
758 -
								: "All Posts"}
759 -
						</div>
760 -
						<div className="flex items-center gap-1">
761 -
							<span className="text-muted-foreground text-xs whitespace-nowrap">
762 -
								{filteredPosts.length}
763 -
							</span>
764 -
							<DropdownMenu>
765 -
								<DropdownMenuTrigger asChild>
766 -
									<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
767 -
										<MoreVertical className="h-4 w-4" />
768 -
									</Button>
769 -
								</DropdownMenuTrigger>
770 -
								<DropdownMenuContent align="end">
771 -
									<DropdownMenuItem onClick={handleMarkAllAsRead}>
772 -
										<Check className="h-4 w-4 mr-2" />
773 -
										Mark all as read
774 -
									</DropdownMenuItem>
775 -
									<DropdownMenuItem onClick={handleMarkAllAsUnread}>
776 -
										<X className="h-4 w-4 mr-2" />
777 -
										Mark all as unread
778 -
									</DropdownMenuItem>
779 -
								</DropdownMenuContent>
780 -
							</DropdownMenu>
781 -
						</div>
782 -
					</div>
783 -
					<Input
784 -
						placeholder="Search..."
785 -
						value={searchQuery}
786 -
						onChange={(e) => setSearchQuery(e.target.value)}
787 -
						className="h-8"
788 -
					/>
789 -
				</div>
790 -
				<div className="flex-1 overflow-y-auto">
791 -
					{filteredPosts.length === 0 ? (
792 -
						<div className="p-4 text-center text-sm text-muted-foreground">
793 -
							No posts found
794 -
						</div>
795 350
					) : (
796 -
						filteredPosts.map((post) => {
797 -
							const isRead = isPostRead(post.id);
798 -
							return (
799 -
								<button
800 -
									type="button"
801 -
									key={post.id}
802 -
									onClick={() => handlePostSelect(post.id)}
803 -
									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 ${
804 -
										selectedPostId === post.id ? "bg-sidebar-accent" : ""
805 -
									}`}
806 -
								>
807 -
									{/* Unread indicator */}
808 -
									<div className="flex-shrink-0 pt-1">
809 -
										{!isRead && (
810 -
											<div className="size-2 rounded-full bg-primary" />
811 -
										)}
812 -
									</div>
813 -
									{/* Post content */}
814 -
									<div className="flex flex-col gap-1.5 flex-1 min-w-0">
815 -
										<span className="font-medium line-clamp-2 leading-snug">
816 -
											{post.title}
817 -
										</span>
818 -
										{post.author && (
819 -
											<span className="text-muted-foreground text-xs">
820 -
												{post.author}
821 -
											</span>
822 -
										)}
823 -
									</div>
824 -
								</button>
825 -
							);
826 -
						})
351 +
						// Feeds view (desktop and mobile default)
352 +
						<>
353 +
							<FeedActions
354 +
								onAddFeed={() => setDialogOpen(true)}
355 +
								onRefresh={refreshFeeds}
356 +
								onReset={reset}
357 +
							/>
358 +
							<NavFeeds
359 +
								feeds={allFeeds}
360 +
								selectedFeedId={selectedFeedId}
361 +
								onFeedSelect={handleFeedSelect}
362 +
							/>
363 +
						</>
827 364
					)}
828 -
				</div>
829 -
			</div>
365 +
				</SidebarContent>
366 +
				<SidebarFooter>
367 +
					{!isMobile || mobileView === "feeds" ? (
368 +
						<NavUser user={data.user} />
369 +
					) : null}
370 +
				</SidebarFooter>
371 +
			</Sidebar>
372 +
373 +
			{/* Posts List Panel - Separate from main sidebar (Desktop only) */}
374 +
			<PostsList
375 +
				posts={filteredPosts}
376 +
				selectedPostId={selectedPostId}
377 +
				onPostSelect={handlePostSelect}
378 +
				searchQuery={searchQuery}
379 +
				onSearchChange={setSearchQuery}
380 +
				feedTitle={selectedFeedTitle}
381 +
				isPostRead={isPostRead}
382 +
				onMarkAllAsRead={handleMarkAllAsRead}
383 +
				onMarkAllAsUnread={handleMarkAllAsUnread}
384 +
				className={`bg-sidebar text-sidebar-foreground hidden md:flex ${hidden ? "w-0 min-w-0 border-0 overflow-hidden" : "w-[320px] overflow-y-auto"}`}
385 +
			/>
830 386
		</>
831 387
	);
832 388
}
src/components/dashboard.tsx +2 −2
59 59
		try {
60 60
			const url = new URL(link);
61 61
			return `${url.protocol}//${url.host}`;
62 -
		} catch (e) {
62 +
		} catch {
63 63
			return "";
64 64
		}
65 65
	}, []);
67 67
	// Custom components for ReactMarkdown to fix image URLs
68 68
	const markdownComponents = React.useMemo(
69 69
		() => ({
70 -
			img: ({ node, src, alt, ...props }: any) => {
70 +
			img: ({ src, alt, ...props }: any) => {
71 71
				let fixedSrc = src;
72 72
73 73
				// If src starts with / and we have a base URL from the post link
src/components/feed-actions.tsx (added) +49 −0
1 +
import { Plus, RotateCw } from "lucide-react";
2 +
import {
3 +
	SidebarGroup,
4 +
	SidebarGroupContent,
5 +
	SidebarGroupLabel,
6 +
	SidebarMenu,
7 +
	SidebarMenuItem,
8 +
	SidebarMenuButton,
9 +
} from "@/components/ui/sidebar";
10 +
11 +
interface FeedActionsProps {
12 +
	onAddFeed: () => void;
13 +
	onRefresh: () => void;
14 +
	onReset: () => void;
15 +
}
16 +
17 +
export function FeedActions({
18 +
	onAddFeed,
19 +
	onRefresh,
20 +
	onReset,
21 +
}: FeedActionsProps) {
22 +
	return (
23 +
		<SidebarGroup>
24 +
			<SidebarGroupLabel>Actions</SidebarGroupLabel>
25 +
			<SidebarGroupContent>
26 +
				<SidebarMenu>
27 +
					<SidebarMenuItem>
28 +
						<SidebarMenuButton onClick={onAddFeed}>
29 +
							<Plus className="size-4" />
30 +
							<span>Add Feed</span>
31 +
						</SidebarMenuButton>
32 +
					</SidebarMenuItem>
33 +
					<SidebarMenuItem>
34 +
						<SidebarMenuButton onClick={onReset}>
35 +
							<RotateCw className="size-4" />
36 +
							<span>Reset</span>
37 +
						</SidebarMenuButton>
38 +
					</SidebarMenuItem>
39 +
					<SidebarMenuItem>
40 +
						<SidebarMenuButton onClick={onRefresh}>
41 +
							<RotateCw className="size-4" />
42 +
							<span>Refresh</span>
43 +
						</SidebarMenuButton>
44 +
					</SidebarMenuItem>
45 +
				</SidebarMenu>
46 +
			</SidebarGroupContent>
47 +
		</SidebarGroup>
48 +
	);
49 +
}
src/components/mobile-posts-header.tsx (added) +28 −0
1 +
import { ChevronLeft } from "lucide-react";
2 +
import { Button } from "@/components/ui/button";
3 +
4 +
interface MobilePostsHeaderProps {
5 +
	feedTitle: string;
6 +
	onBack: () => void;
7 +
}
8 +
9 +
export function MobilePostsHeader({
10 +
	feedTitle,
11 +
	onBack,
12 +
}: MobilePostsHeaderProps) {
13 +
	return (
14 +
		<div className="flex items-center gap-2 px-2 pt-2">
15 +
			<Button
16 +
				variant="ghost"
17 +
				size="sm"
18 +
				onClick={onBack}
19 +
				className="h-8 w-8 p-0"
20 +
			>
21 +
				<ChevronLeft className="size-5" />
22 +
			</Button>
23 +
			<div className="flex-1 text-left text-xl">
24 +
				<span className="truncate font-bold">{feedTitle}</span>
25 +
			</div>
26 +
		</div>
27 +
	);
28 +
}
src/components/posts-list.tsx (added) +124 −0
1 +
import { MoreVertical, Check, X } from "lucide-react";
2 +
import { Button } from "@/components/ui/button";
3 +
import {
4 +
	DropdownMenu,
5 +
	DropdownMenuContent,
6 +
	DropdownMenuItem,
7 +
	DropdownMenuTrigger,
8 +
} from "@/components/ui/dropdown-menu";
9 +
import { Input } from "@/components/ui/input";
10 +
11 +
interface Post {
12 +
	id: string;
13 +
	title: string | null;
14 +
	author: string | null;
15 +
	publishedDate: string | null;
16 +
	link: string | null;
17 +
	feedId: string | null;
18 +
	content: string | null;
19 +
}
20 +
21 +
interface PostsListProps {
22 +
	posts: Post[];
23 +
	selectedPostId: string | null;
24 +
	onPostSelect: (postId: string) => void;
25 +
	searchQuery: string;
26 +
	onSearchChange: (query: string) => void;
27 +
	feedTitle?: string;
28 +
	isPostRead: (postId: string) => boolean;
29 +
	onMarkAllAsRead: () => void;
30 +
	onMarkAllAsUnread: () => void;
31 +
	className?: string;
32 +
}
33 +
34 +
export function PostsList({
35 +
	posts,
36 +
	selectedPostId,
37 +
	onPostSelect,
38 +
	searchQuery,
39 +
	onSearchChange,
40 +
	feedTitle = "All Posts",
41 +
	isPostRead,
42 +
	onMarkAllAsRead,
43 +
	onMarkAllAsUnread,
44 +
	className = "",
45 +
}: PostsListProps) {
46 +
	return (
47 +
		<div className={`flex h-screen flex-col border-r ${className}`}>
48 +
			<div className="gap-2 border-b p-3 flex flex-col">
49 +
				<div className="flex w-full items-center justify-between gap-2">
50 +
					<div className="text-foreground text-sm font-semibold truncate">
51 +
						{feedTitle}
52 +
					</div>
53 +
					<div className="flex items-center gap-1">
54 +
						<span className="text-muted-foreground text-xs whitespace-nowrap">
55 +
							{posts.length}
56 +
						</span>
57 +
						<DropdownMenu>
58 +
							<DropdownMenuTrigger asChild>
59 +
								<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
60 +
									<MoreVertical className="h-4 w-4" />
61 +
								</Button>
62 +
							</DropdownMenuTrigger>
63 +
							<DropdownMenuContent align="end">
64 +
								<DropdownMenuItem onClick={onMarkAllAsRead}>
65 +
									<Check className="h-4 w-4 mr-2" />
66 +
									Mark all as read
67 +
								</DropdownMenuItem>
68 +
								<DropdownMenuItem onClick={onMarkAllAsUnread}>
69 +
									<X className="h-4 w-4 mr-2" />
70 +
									Mark all as unread
71 +
								</DropdownMenuItem>
72 +
							</DropdownMenuContent>
73 +
						</DropdownMenu>
74 +
					</div>
75 +
				</div>
76 +
				<Input
77 +
					placeholder="Search..."
78 +
					value={searchQuery}
79 +
					onChange={(e) => onSearchChange(e.target.value)}
80 +
					className="h-8"
81 +
				/>
82 +
			</div>
83 +
			<div className="flex-1 overflow-y-auto">
84 +
				{posts.length === 0 ? (
85 +
					<div className="p-4 text-center text-sm text-muted-foreground">
86 +
						No posts found
87 +
					</div>
88 +
				) : (
89 +
					posts.map((post) => {
90 +
						const isRead = isPostRead(post.id);
91 +
						return (
92 +
							<button
93 +
								type="button"
94 +
								key={post.id}
95 +
								onClick={() => onPostSelect(post.id)}
96 +
								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 ${
97 +
									selectedPostId === post.id ? "bg-sidebar-accent" : ""
98 +
								}`}
99 +
							>
100 +
								{/* Unread indicator */}
101 +
								<div className="flex-shrink-0 pt-1">
102 +
									{!isRead && (
103 +
										<div className="size-2 rounded-full bg-primary" />
104 +
									)}
105 +
								</div>
106 +
								{/* Post content */}
107 +
								<div className="flex flex-col gap-1.5 flex-1 min-w-0">
108 +
									<span className="font-medium line-clamp-2 leading-snug">
109 +
										{post.title}
110 +
									</span>
111 +
									{post.author && (
112 +
										<span className="text-muted-foreground text-xs">
113 +
											{post.author}
114 +
										</span>
115 +
									)}
116 +
								</div>
117 +
							</button>
118 +
						);
119 +
					})
120 +
				)}
121 +
			</div>
122 +
		</div>
123 +
	);
124 +
}
src/lib/feed-operations.ts (added) +149 −0
1 +
import { XMLParser } from "fast-xml-parser";
2 +
import { COMMON_FEED_PATHS } from "./feed-discovery";
3 +
4 +
const parser = new XMLParser();
5 +
6 +
export interface ParsedFeedData {
7 +
	feedData: any;
8 +
	posts: any[];
9 +
	isAtom: boolean;
10 +
}
11 +
12 +
/**
13 +
 * Fetches XML data from a URL with CORS fallback
14 +
 */
15 +
export async function fetchFeedWithFallback(url: string): Promise<string> {
16 +
	try {
17 +
		// Try to fetch directly first
18 +
		const response = await fetch(url);
19 +
		return await response.text();
20 +
	} catch {
21 +
		// Fall back to CORS proxy if direct fetch fails
22 +
		const response = await fetch(
23 +
			`https://corsproxy.io/?url=${encodeURIComponent(url)}`,
24 +
		);
25 +
		return await response.text();
26 +
	}
27 +
}
28 +
29 +
/**
30 +
 * Parses XML data and determines if it's RSS or Atom feed
31 +
 */
32 +
export function parseFeedXml(xmlData: string): ParsedFeedData {
33 +
	const parsedXmlData = parser.parse(xmlData);
34 +
35 +
	// Determine if it's RSS or Atom feed
36 +
	let feedData: any;
37 +
	let posts: any[];
38 +
	let isAtom = false;
39 +
40 +
	if (parsedXmlData.rss) {
41 +
		// RSS feed
42 +
		feedData = parsedXmlData.rss.channel;
43 +
		posts = feedData.item || [];
44 +
	} else if (parsedXmlData.feed) {
45 +
		// Atom feed
46 +
		feedData = parsedXmlData.feed;
47 +
		posts = feedData.entry || [];
48 +
		isAtom = true;
49 +
	} else {
50 +
		throw new Error("Unsupported feed format");
51 +
	}
52 +
53 +
	return { feedData, posts, isAtom };
54 +
}
55 +
56 +
/**
57 +
 * Discovers RSS/Atom feed URL from a website URL
58 +
 */
59 +
export async function discoverFeed(websiteUrl: string): Promise<{
60 +
	feedUrl: string;
61 +
	xmlData: string;
62 +
} | null> {
63 +
	const urlObj = new URL(websiteUrl);
64 +
	const origin = urlObj.origin;
65 +
66 +
	console.log("Trying to discover feed from:", origin);
67 +
68 +
	for (const path of COMMON_FEED_PATHS) {
69 +
		const testUrl = `${origin}${path}`;
70 +
		console.log("Testing:", testUrl);
71 +
72 +
		try {
73 +
			// Use CORS proxy to avoid CORS issues
74 +
			const response = await fetch(
75 +
				`https://corsproxy.io/?url=${encodeURIComponent(testUrl)}`,
76 +
			);
77 +
78 +
			if (response.ok) {
79 +
				const text = await response.text();
80 +
				// Quick check if it looks like XML
81 +
				if (
82 +
					text.trim().startsWith("<?xml") ||
83 +
					text.includes("<rss") ||
84 +
					text.includes("<feed")
85 +
				) {
86 +
					console.log("Found feed at:", testUrl);
87 +
					return { feedUrl: testUrl, xmlData: text };
88 +
				}
89 +
			}
90 +
		} catch (error) {
91 +
			console.log("Failed to fetch:", testUrl, error);
92 +
			continue;
93 +
		}
94 +
	}
95 +
96 +
	return null;
97 +
}
98 +
99 +
/**
100 +
 * Checks if a URL looks like a direct feed URL
101 +
 */
102 +
export function looksLikeFeedUrl(url: string): boolean {
103 +
	return (
104 +
		url.includes("/feed") ||
105 +
		url.includes("/rss") ||
106 +
		url.includes(".xml") ||
107 +
		url.includes("/atom")
108 +
	);
109 +
}
110 +
111 +
/**
112 +
 * Extracts post link from RSS or Atom post entry
113 +
 */
114 +
export function extractPostLink(post: any, isAtom: boolean): string {
115 +
	if (isAtom) {
116 +
		return typeof post.link === "string"
117 +
			? post.link || post.id
118 +
			: post.link?.[0] || post.id;
119 +
	}
120 +
	return post.link || post.id;
121 +
}
122 +
123 +
/**
124 +
 * Extracts author from RSS or Atom post entry
125 +
 */
126 +
export function extractPostAuthor(
127 +
	post: any,
128 +
	isAtom: boolean,
129 +
	feedTitle: string,
130 +
): string {
131 +
	if (isAtom) {
132 +
		return post.author?.name || feedTitle;
133 +
	}
134 +
	return post.author || feedTitle;
135 +
}
136 +
137 +
/**
138 +
 * Extracts content from RSS or Atom post entry
139 +
 */
140 +
export function extractPostContent(post: any): string {
141 +
	return post["content:encoded"] || post.content || "Please open on the web";
142 +
}
143 +
144 +
/**
145 +
 * Extracts published date from RSS or Atom post entry
146 +
 */
147 +
export function extractPostDate(post: any): string {
148 +
	return new Date(post.pubDate || post.updated).toISOString();
149 +
}