src/components/add-feed-dialog.tsx 5.4 K raw
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
	extractPostContent,
22
	extractPostDate,
23
	sanitizeFeedData,
24
	sanitizePostData,
25
	isYouTubeUrl,
26
	convertYouTubeUrlToFeed,
27
} from "@/lib/feed-operations";
28
import { formatTypeError } from "@/lib/format-error";
29
30
interface AddFeedDialogProps {
31
	open: boolean;
32
	onOpenChange: (open: boolean) => void;
33
}
34
35
export function AddFeedDialog({ open, onOpenChange }: AddFeedDialogProps) {
36
	const [urlInput, setUrlInput] = React.useState("");
37
	const [categoryInput, setCategoryInput] = React.useState("");
38
	const [isAddingFeed, setIsAddingFeed] = React.useState(false);
39
	const [statusMessage, setStatusMessage] = React.useState("");
40
41
	const evolu = useEvolu();
42
43
	async function addFeed() {
44
		if (!urlInput.trim()) {
45
			setStatusMessage("Please enter a URL");
46
			return;
47
		}
48
49
		setIsAddingFeed(true);
50
		setStatusMessage("");
51
52
		try {
53
			// Try to discover feeds if the URL doesn't look like a direct feed URL
54
			let feedUrl = urlInput;
55
			let xmlData: string | null = null;
56
57
			// Check if it's a YouTube URL and convert it
58
			if (isYouTubeUrl(urlInput)) {
59
				setStatusMessage("Detecting YouTube channel...");
60
				const youtubeFeedUrl = await convertYouTubeUrlToFeed(urlInput);
61
62
				if (!youtubeFeedUrl) {
63
					setStatusMessage(
64
						"Could not extract YouTube channel ID. Please try a direct channel URL.",
65
					);
66
					setIsAddingFeed(false);
67
					return;
68
				}
69
70
				feedUrl = youtubeFeedUrl;
71
				xmlData = await fetchFeedWithFallback(feedUrl);
72
			} else if (!looksLikeFeedUrl(urlInput)) {
73
				setStatusMessage("Discovering RSS feed...");
74
				const discovered = await discoverFeed(urlInput);
75
76
				if (!discovered) {
77
					setStatusMessage(
78
						"Could not find an RSS feed at this URL. Please enter a direct feed URL.",
79
					);
80
					setIsAddingFeed(false);
81
					return;
82
				}
83
84
				feedUrl = discovered.feedUrl;
85
				xmlData = discovered.xmlData;
86
			} else {
87
				// Direct feed URL - try to fetch it
88
				xmlData = await fetchFeedWithFallback(feedUrl);
89
			}
90
91
			const { feedData, posts, isAtom } = parseFeedXml(xmlData);
92
93
			// Sanitize feed data to meet schema constraints
94
			const sanitizedFeed = sanitizeFeedData(feedData);
95
96
			const result = evolu.insert("rssFeed", {
97
				feedUrl: feedUrl,
98
				title: sanitizedFeed.title,
99
				description: sanitizedFeed.description || null,
100
				category: categoryInput || "Uncategorized",
101
				dateUpdated: new Date().toISOString(),
102
			});
103
104
			if (!result.ok) {
105
				throw new Error(formatTypeError(result.error));
106
			}
107
108
			// Process posts/entries
109
			for (const post of posts) {
110
				// Sanitize post data to meet schema constraints
111
				const sanitizedPost = sanitizePostData(post, isAtom, feedData.title);
112
113
				const postResult = evolu.insert("rssPost", {
114
					title: sanitizedPost.title,
115
					author: sanitizedPost.author || null,
116
					feedTitle: sanitizedFeed.title,
117
					publishedDate: extractPostDate(post),
118
					link: sanitizedPost.link,
119
					feedId: result.value.id,
120
					content: extractPostContent(post, sanitizedPost.link),
121
				});
122
123
				if (!postResult.ok) {
124
					console.warn(
125
						"Failed to insert post:",
126
						formatTypeError(postResult.error),
127
					);
128
				}
129
			}
130
131
			toast.success(
132
				`Successfully added "${feedData.title}" with ${posts.length} post${posts.length !== 1 ? "s" : ""}`,
133
			);
134
135
			setUrlInput("");
136
			setCategoryInput("");
137
			setStatusMessage("");
138
			onOpenChange(false);
139
		} catch (error) {
140
			setStatusMessage(
141
				error instanceof Error
142
					? error.message
143
					: "Failed to add feed. Please check the URL and try again.",
144
			);
145
		} finally {
146
			setIsAddingFeed(false);
147
		}
148
	}
149
150
	return (
151
		<Dialog open={open} onOpenChange={onOpenChange}>
152
			<DialogContent className="sm:max-w-[425px]">
153
				<DialogHeader>
154
					<DialogTitle>Add Feed</DialogTitle>
155
					<DialogDescription>
156
						Enter a website URL or direct RSS feed URL
157
					</DialogDescription>
158
				</DialogHeader>
159
				<div className="grid gap-4">
160
					<div className="grid gap-3">
161
						<Label htmlFor="url-input">URL</Label>
162
						<Input
163
							id="url-input"
164
							name="url"
165
							value={urlInput}
166
							onChange={(e) => setUrlInput(e.target.value)}
167
							placeholder="https://example.com"
168
							disabled={isAddingFeed}
169
						/>
170
						<p className="text-xs text-muted-foreground">
171
							We'll automatically discover the RSS feed for you
172
						</p>
173
					</div>
174
					<div className="grid gap-3">
175
						<Label htmlFor="category-input">Category</Label>
176
						<Input
177
							id="category-input"
178
							name="category"
179
							value={categoryInput}
180
							onChange={(e) => setCategoryInput(e.target.value)}
181
							placeholder="e.g., Tech, News, Blogs"
182
							disabled={isAddingFeed}
183
						/>
184
					</div>
185
					{statusMessage && (
186
						<div className="text-sm text-primary">{statusMessage}</div>
187
					)}
188
				</div>
189
				<DialogFooter>
190
					<DialogClose asChild>
191
						<Button variant="outline" disabled={isAddingFeed}>
192
							Cancel
193
						</Button>
194
					</DialogClose>
195
					<Button onClick={addFeed} type="submit" disabled={isAddingFeed}>
196
						{isAddingFeed ? "Adding..." : "Submit"}
197
					</Button>
198
				</DialogFooter>
199
			</DialogContent>
200
		</Dialog>
201
	);
202
}