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