src/components/add-feed-dialog.tsx 8.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 {
15
	Select,
16
	SelectContent,
17
	SelectItem,
18
	SelectTrigger,
19
	SelectValue,
20
} from "@/components/ui/select";
21
import { toast } from "sonner";
22
import { useEvolu } from "@/lib/evolu";
23
import {
24
	fetchFeedWithFallback,
25
	parseFeedXml,
26
	discoverFeed,
27
	extractPostContent,
28
	extractPostDate,
29
	sanitizeFeedData,
30
	sanitizePostData,
31
	isYouTubeUrl,
32
	convertYouTubeUrlToFeed,
33
} from "@/lib/feed-operations";
34
import { formatTypeError } from "@/lib/format-error";
35
36
interface AddFeedDialogProps {
37
	open: boolean;
38
	onOpenChange: (open: boolean) => void;
39
	existingCategories: string[];
40
}
41
42
export function AddFeedDialog({
43
	open,
44
	onOpenChange,
45
	existingCategories,
46
}: AddFeedDialogProps) {
47
	const [urlInput, setUrlInput] = React.useState("");
48
	const [mode, setMode] = React.useState<"existing" | "new">("existing");
49
	const [selectedCategory, setSelectedCategory] = React.useState<string>("");
50
	const [newCategory, setNewCategory] = React.useState("");
51
	const [isAddingFeed, setIsAddingFeed] = React.useState(false);
52
	const [statusMessage, setStatusMessage] = React.useState("");
53
54
	const evolu = useEvolu();
55
56
	React.useEffect(() => {
57
		if (open) {
58
			setUrlInput("");
59
			setMode("existing");
60
			setSelectedCategory("");
61
			setNewCategory("");
62
			setStatusMessage("");
63
		}
64
	}, [open]);
65
66
	async function addFeed() {
67
		if (!urlInput.trim()) {
68
			setStatusMessage("Please enter a URL");
69
			return;
70
		}
71
72
		setIsAddingFeed(true);
73
		setStatusMessage("");
74
75
		try {
76
			let feedUrl = urlInput;
77
			let xmlData: string | null = null;
78
79
			// Check if it's a YouTube URL and convert it
80
			if (isYouTubeUrl(urlInput)) {
81
				setStatusMessage("Detecting YouTube channel...");
82
				const youtubeFeedUrl = await convertYouTubeUrlToFeed(urlInput);
83
84
				if (!youtubeFeedUrl) {
85
					setStatusMessage(
86
						"Could not extract YouTube channel ID. Please try a direct channel URL.",
87
					);
88
					setIsAddingFeed(false);
89
					return;
90
				}
91
92
				feedUrl = youtubeFeedUrl;
93
				xmlData = await fetchFeedWithFallback(feedUrl);
94
			} else {
95
				// First, try to fetch the URL directly as a feed
96
				setStatusMessage("Checking URL...");
97
				try {
98
					if (urlInput.includes("substack.com")) {
99
						const parts = urlInput.split("/");
100
						console.log(parts);
101
						const newUrl = `https://${parts[3].slice(1)}.${parts[2]}/feed`;
102
						console.log(newUrl);
103
						xmlData = await fetchFeedWithFallback(newUrl);
104
						// Try to parse it to see if it's a valid feed
105
						parseFeedXml(xmlData);
106
						// If parsing succeeds, it's a valid feed
107
						feedUrl = urlInput;
108
					} else {
109
						xmlData = await fetchFeedWithFallback(urlInput);
110
						// Try to parse it to see if it's a valid feed
111
						parseFeedXml(xmlData);
112
						// If parsing succeeds, it's a valid feed
113
						feedUrl = urlInput;
114
					}
115
				} catch {
116
					// If direct fetch/parse fails, try feed discovery
117
					setStatusMessage("Discovering RSS feed...");
118
					const discovered = await discoverFeed(urlInput);
119
120
					if (!discovered) {
121
						setStatusMessage(
122
							"Could not find an RSS feed at this URL. Please enter a direct feed URL.",
123
						);
124
						setIsAddingFeed(false);
125
						return;
126
					}
127
128
					feedUrl = discovered.feedUrl;
129
					xmlData = discovered.xmlData;
130
				}
131
			}
132
133
			const { feedData, posts, isAtom } = parseFeedXml(xmlData);
134
135
			// Sanitize feed data to meet schema constraints
136
			const sanitizedFeed = sanitizeFeedData(feedData);
137
138
			// Determine the final category value
139
			let finalCategory: string | null = null;
140
			if (mode === "new") {
141
				finalCategory = newCategory.trim() || null;
142
			} else {
143
				if (selectedCategory && selectedCategory !== "uncategorized") {
144
					finalCategory = selectedCategory;
145
				}
146
			}
147
148
			const result = evolu.insert("rssFeed", {
149
				feedUrl: feedUrl,
150
				title: sanitizedFeed.title,
151
				description: sanitizedFeed.description || null,
152
				category: finalCategory as any,
153
				dateUpdated: new Date().toISOString(),
154
			});
155
156
			if (!result.ok) {
157
				throw new Error(formatTypeError(result.error));
158
			}
159
160
			// Process posts/entries
161
			for (const post of posts) {
162
				// Sanitize post data to meet schema constraints
163
				const sanitizedPost = sanitizePostData(post, isAtom, feedData.title);
164
165
				const postResult = evolu.insert("rssPost", {
166
					title: sanitizedPost.title,
167
					author: sanitizedPost.author || null,
168
					feedTitle: sanitizedFeed.title,
169
					publishedDate: extractPostDate(post, isAtom),
170
					link: sanitizedPost.link,
171
					feedId: result.value.id,
172
					content: extractPostContent(post, sanitizedPost.link),
173
				});
174
175
				if (!postResult.ok) {
176
					console.warn(
177
						"Failed to insert post:",
178
						formatTypeError(postResult.error),
179
					);
180
				}
181
			}
182
183
			toast.success(
184
				`Successfully added "${feedData.title}" with ${posts.length} post${posts.length !== 1 ? "s" : ""}`,
185
			);
186
187
			onOpenChange(false);
188
		} catch (error) {
189
			setStatusMessage(
190
				error instanceof Error
191
					? error.message
192
					: "Failed to add feed. Please check the URL and try again.",
193
			);
194
		} finally {
195
			setIsAddingFeed(false);
196
		}
197
	}
198
199
	return (
200
		<Dialog open={open} onOpenChange={onOpenChange}>
201
			<DialogContent className="sm:max-w-[425px]">
202
				<DialogHeader>
203
					<DialogTitle>Add Feed</DialogTitle>
204
					<DialogDescription>
205
						Enter a website URL or direct RSS feed URL
206
					</DialogDescription>
207
				</DialogHeader>
208
				<div className="grid gap-4">
209
					<div className="grid gap-3">
210
						<Label htmlFor="url-input">URL</Label>
211
						<Input
212
							id="url-input"
213
							name="url"
214
							value={urlInput}
215
							onChange={(e) => setUrlInput(e.target.value)}
216
							placeholder="https://example.com"
217
							disabled={isAddingFeed}
218
						/>
219
						<p className="text-xs text-muted-foreground">
220
							We'll automatically discover the RSS feed for you
221
						</p>
222
					</div>
223
224
					<div className="grid gap-3">
225
						<Label>Category</Label>
226
						<div className="flex gap-2">
227
							<Button
228
								type="button"
229
								variant={mode === "existing" ? "default" : "outline"}
230
								onClick={() => setMode("existing")}
231
								className="flex-1"
232
								disabled={isAddingFeed}
233
							>
234
								Existing
235
							</Button>
236
							<Button
237
								type="button"
238
								variant={mode === "new" ? "default" : "outline"}
239
								onClick={() => setMode("new")}
240
								className="flex-1"
241
								disabled={isAddingFeed}
242
							>
243
								New
244
							</Button>
245
						</div>
246
					</div>
247
248
					{mode === "existing" ? (
249
						<div className="grid gap-2">
250
							<Label htmlFor="category-select">Select Category</Label>
251
							<Select
252
								value={selectedCategory}
253
								onValueChange={setSelectedCategory}
254
								disabled={isAddingFeed}
255
							>
256
								<SelectTrigger id="category-select">
257
									<SelectValue placeholder="Select a category" />
258
								</SelectTrigger>
259
								<SelectContent>
260
									<SelectItem value="uncategorized">Uncategorized</SelectItem>
261
									{existingCategories.map((cat) => (
262
										<SelectItem key={cat} value={cat}>
263
											{cat}
264
										</SelectItem>
265
									))}
266
								</SelectContent>
267
							</Select>
268
						</div>
269
					) : (
270
						<div className="grid gap-2">
271
							<Label htmlFor="new-category">New Category Name</Label>
272
							<Input
273
								id="new-category"
274
								value={newCategory}
275
								onChange={(e) => setNewCategory(e.target.value)}
276
								placeholder="Enter category name"
277
								maxLength={50}
278
								disabled={isAddingFeed}
279
							/>
280
						</div>
281
					)}
282
283
					{statusMessage && (
284
						<div className="text-sm text-primary">{statusMessage}</div>
285
					)}
286
				</div>
287
				<DialogFooter>
288
					<DialogClose asChild>
289
						<Button variant="outline" disabled={isAddingFeed}>
290
							Cancel
291
						</Button>
292
					</DialogClose>
293
					<Button onClick={addFeed} type="submit" disabled={isAddingFeed}>
294
						{isAddingFeed ? "Adding..." : "Submit"}
295
					</Button>
296
				</DialogFooter>
297
			</DialogContent>
298
		</Dialog>
299
	);
300
}