src/components/add-feed-dialog.tsx 5.6 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
					xmlData = await fetchFeedWithFallback(urlInput);
75
					// Try to parse it to see if it's a valid feed
76
					parseFeedXml(xmlData);
77
					// If parsing succeeds, it's a valid feed
78
					feedUrl = urlInput;
79
				} catch {
80
					// If direct fetch/parse fails, try feed discovery
81
					setStatusMessage("Discovering RSS feed...");
82
					const discovered = await discoverFeed(urlInput);
83
84
					if (!discovered) {
85
						setStatusMessage(
86
							"Could not find an RSS feed at this URL. Please enter a direct feed URL.",
87
						);
88
						setIsAddingFeed(false);
89
						return;
90
					}
91
92
					feedUrl = discovered.feedUrl;
93
					xmlData = discovered.xmlData;
94
				}
95
			}
96
97
			const { feedData, posts, isAtom } = parseFeedXml(xmlData);
98
99
			// Sanitize feed data to meet schema constraints
100
			const sanitizedFeed = sanitizeFeedData(feedData);
101
102
			const result = evolu.insert("rssFeed", {
103
				feedUrl: feedUrl,
104
				title: sanitizedFeed.title,
105
				description: sanitizedFeed.description || null,
106
				category: categoryInput || "Uncategorized",
107
				dateUpdated: new Date().toISOString(),
108
			});
109
110
			if (!result.ok) {
111
				throw new Error(formatTypeError(result.error));
112
			}
113
114
			// Process posts/entries
115
			for (const post of posts) {
116
				// Sanitize post data to meet schema constraints
117
				const sanitizedPost = sanitizePostData(post, isAtom, feedData.title);
118
119
				const postResult = evolu.insert("rssPost", {
120
					title: sanitizedPost.title,
121
					author: sanitizedPost.author || null,
122
					feedTitle: sanitizedFeed.title,
123
					publishedDate: extractPostDate(post),
124
					link: sanitizedPost.link,
125
					feedId: result.value.id,
126
					content: extractPostContent(post, sanitizedPost.link),
127
				});
128
129
				if (!postResult.ok) {
130
					console.warn(
131
						"Failed to insert post:",
132
						formatTypeError(postResult.error),
133
					);
134
				}
135
			}
136
137
			toast.success(
138
				`Successfully added "${feedData.title}" with ${posts.length} post${posts.length !== 1 ? "s" : ""}`,
139
			);
140
141
			setUrlInput("");
142
			setCategoryInput("");
143
			setStatusMessage("");
144
			onOpenChange(false);
145
		} catch (error) {
146
			setStatusMessage(
147
				error instanceof Error
148
					? error.message
149
					: "Failed to add feed. Please check the URL and try again.",
150
			);
151
		} finally {
152
			setIsAddingFeed(false);
153
		}
154
	}
155
156
	return (
157
		<Dialog open={open} onOpenChange={onOpenChange}>
158
			<DialogContent className="sm:max-w-[425px]">
159
				<DialogHeader>
160
					<DialogTitle>Add Feed</DialogTitle>
161
					<DialogDescription>
162
						Enter a website URL or direct RSS feed URL
163
					</DialogDescription>
164
				</DialogHeader>
165
				<div className="grid gap-4">
166
					<div className="grid gap-3">
167
						<Label htmlFor="url-input">URL</Label>
168
						<Input
169
							id="url-input"
170
							name="url"
171
							value={urlInput}
172
							onChange={(e) => setUrlInput(e.target.value)}
173
							placeholder="https://example.com"
174
							disabled={isAddingFeed}
175
						/>
176
						<p className="text-xs text-muted-foreground">
177
							We'll automatically discover the RSS feed for you
178
						</p>
179
					</div>
180
					<div className="grid gap-3">
181
						<Label htmlFor="category-input">Category</Label>
182
						<Input
183
							id="category-input"
184
							name="category"
185
							value={categoryInput}
186
							onChange={(e) => setCategoryInput(e.target.value)}
187
							placeholder="e.g., Tech, News, Blogs"
188
							disabled={isAddingFeed}
189
						/>
190
					</div>
191
					{statusMessage && (
192
						<div className="text-sm text-primary">{statusMessage}</div>
193
					)}
194
				</div>
195
				<DialogFooter>
196
					<DialogClose asChild>
197
						<Button variant="outline" disabled={isAddingFeed}>
198
							Cancel
199
						</Button>
200
					</DialogClose>
201
					<Button onClick={addFeed} type="submit" disabled={isAddingFeed}>
202
						{isAddingFeed ? "Adding..." : "Submit"}
203
					</Button>
204
				</DialogFooter>
205
			</DialogContent>
206
		</Dialog>
207
	);
208
}