src/App.tsx 12.1 K raw
1
import Dashboard from "./components/dashboard";
2
import { useQuery } from "@evolu/react";
3
import { allFeedsQuery, useEvolu } from "@/lib/evolu";
4
import { Button } from "@/components/ui/button";
5
import { Input } from "@/components/ui/input";
6
import * as React from "react";
7
import { toast } from "sonner";
8
import {
9
	fetchFeedWithFallback,
10
	parseFeedXml,
11
	discoverFeed,
12
	extractPostContent,
13
	extractPostDate,
14
	sanitizeFeedData,
15
	sanitizePostData,
16
	isYouTubeUrl,
17
	convertYouTubeUrlToFeed,
18
} from "@/lib/feed-operations";
19
import { parseOPML } from "@/lib/opml";
20
import {
21
	Dialog,
22
	DialogContent,
23
	DialogDescription,
24
	DialogHeader,
25
	DialogTitle,
26
} from "@/components/ui/dialog";
27
import { Upload, FileUp, Info } from "lucide-react";
28
import * as Evolu from "@evolu/common";
29
import { LoadingScreen } from "@/components/loading-screen";
30
import { AboutDialog } from "@/components/about-dialog";
31
import { formatTypeError } from "@/lib/format-error";
32
33
function App() {
34
	const allFeeds = useQuery(allFeedsQuery);
35
	const hasFeeds = allFeeds.length > 0;
36
	const [isInitialLoading, setIsInitialLoading] = React.useState(true);
37
	const [isInitialized, setIsInitialized] = React.useState(() => {
38
		return localStorage.getItem("alcove_isInitialized") === "true";
39
	});
40
	const [urlInput, setUrlInput] = React.useState("");
41
	const [isAddingFeed, setIsAddingFeed] = React.useState(false);
42
	const [errorMessage, setErrorMessage] = React.useState("");
43
	const [isRestoreDialogOpen, setIsRestoreDialogOpen] = React.useState(false);
44
	const [restoreMnemonic, setRestoreMnemonic] = React.useState("");
45
	const [isImportingOPML, setIsImportingOPML] = React.useState(false);
46
	const fileInputRef = React.useRef<HTMLInputElement>(null);
47
48
	const evolu = useEvolu();
49
50
	// Handle initial loading state
51
	React.useEffect(() => {
52
		// Add a small delay to prevent flash, then stop loading
53
		const timer = setTimeout(() => {
54
			setIsInitialLoading(false);
55
		}, 500);
56
		return () => clearTimeout(timer);
57
	}, []);
58
59
	// Mark as initialized when feeds are added
60
	React.useEffect(() => {
61
		if (allFeeds.length > 0 && !isInitialized) {
62
			localStorage.setItem("alcove_isInitialized", "true");
63
			setIsInitialized(true);
64
		}
65
	}, [allFeeds.length, isInitialized]);
66
67
	function handleRestoreDialogOpenChange(open: boolean) {
68
		setIsRestoreDialogOpen(open);
69
		if (!open) {
70
			setRestoreMnemonic("");
71
		}
72
	}
73
74
	function handleRestore() {
75
		if (restoreMnemonic.trim()) {
76
			const result = Evolu.Mnemonic.from(restoreMnemonic.trim());
77
			if (!result.ok) {
78
				toast.error(formatTypeError(result.error));
79
				return;
80
			}
81
82
			void evolu.restoreAppOwner(result.value);
83
			setIsRestoreDialogOpen(false);
84
			setRestoreMnemonic("");
85
			toast.success("Account restored successfully");
86
		}
87
	}
88
89
	async function handleImportOPML(file: File) {
90
		setIsImportingOPML(true);
91
		const importToast = toast.loading("Reading OPML file...");
92
93
		try {
94
			const fileContent = await file.text();
95
			const opmlFeeds = parseOPML(fileContent);
96
97
			toast.loading(`Found ${opmlFeeds.length} feeds. Importing...`, {
98
				id: importToast,
99
			});
100
101
			let successCount = 0;
102
			const failedFeeds: Array<{ title: string; url: string; error: string }> =
103
				[];
104
105
			for (let i = 0; i < opmlFeeds.length; i++) {
106
				const feed = opmlFeeds[i];
107
				toast.loading(
108
					`Importing feed ${i + 1}/${opmlFeeds.length}: ${feed.title}`,
109
					{ id: importToast },
110
				);
111
112
				try {
113
					const xmlData = await fetchFeedWithFallback(feed.feedUrl);
114
					const { feedData, posts, isAtom } = parseFeedXml(xmlData);
115
116
					// Sanitize feed data to meet schema constraints
117
					const sanitizedFeed = sanitizeFeedData(feedData, feed);
118
119
					const result = evolu.insert("rssFeed", {
120
						feedUrl: feed.feedUrl,
121
						title: sanitizedFeed.title,
122
						description: sanitizedFeed.description || null,
123
						category: feed.category || "Uncategorized",
124
						dateUpdated: new Date().toISOString(),
125
					});
126
127
					if (!result.ok) {
128
						throw new Error(formatTypeError(result.error));
129
					}
130
131
					for (const post of posts) {
132
						// Sanitize post data to meet schema constraints
133
						const sanitizedPost = sanitizePostData(
134
							post,
135
							isAtom,
136
							feedData.title,
137
						);
138
139
						const postResult = evolu.insert("rssPost", {
140
							title: sanitizedPost.title,
141
							author: sanitizedPost.author || null,
142
							feedTitle: sanitizedFeed.title,
143
							publishedDate: extractPostDate(post),
144
							link: sanitizedPost.link,
145
							feedId: result.value.id,
146
							content: extractPostContent(post, sanitizedPost.link),
147
						});
148
149
						if (!postResult.ok) {
150
							console.warn(
151
								"Failed to insert post:",
152
								formatTypeError(postResult.error),
153
							);
154
						}
155
					}
156
157
					successCount++;
158
				} catch (error) {
159
					const errorMessage =
160
						error instanceof Error ? error.message : "Unknown error";
161
					failedFeeds.push({
162
						title: feed.title,
163
						url: feed.feedUrl,
164
						error: errorMessage,
165
					});
166
				}
167
			}
168
169
			// Show summary toast
170
			if (failedFeeds.length === 0) {
171
				toast.success(`Successfully imported all ${successCount} feeds!`, {
172
					id: importToast,
173
				});
174
			} else {
175
				toast.warning(
176
					`Import complete! Success: ${successCount}, Failed: ${failedFeeds.length}`,
177
					{
178
						id: importToast,
179
						duration: 5000,
180
					},
181
				);
182
183
				// Show a follow-up toast with details
184
				toast.error(
185
					`${failedFeeds.length} feed${failedFeeds.length > 1 ? "s" : ""} failed to import.`,
186
					{
187
						duration: 8000,
188
					},
189
				);
190
			}
191
		} catch (error) {
192
			toast.error("Failed to import OPML. Please check the file format.", {
193
				id: importToast,
194
			});
195
		} finally {
196
			setIsImportingOPML(false);
197
			if (fileInputRef.current) {
198
				fileInputRef.current.value = "";
199
			}
200
		}
201
	}
202
203
	function handleImportClick() {
204
		fileInputRef.current?.click();
205
	}
206
207
	function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
208
		const file = event.target.files?.[0];
209
		if (file) {
210
			handleImportOPML(file);
211
		}
212
	}
213
214
	async function addFeed() {
215
		if (!urlInput.trim()) {
216
			setErrorMessage("Please enter a URL");
217
			return;
218
		}
219
220
		setIsAddingFeed(true);
221
		setErrorMessage("");
222
223
		try {
224
			let feedUrl = urlInput;
225
			let xmlData: string | null = null;
226
227
			// Check if it's a YouTube URL and convert it
228
			if (isYouTubeUrl(urlInput)) {
229
				setErrorMessage("Detecting YouTube channel...");
230
				const youtubeFeedUrl = await convertYouTubeUrlToFeed(urlInput);
231
232
				if (!youtubeFeedUrl) {
233
					setErrorMessage(
234
						"Could not extract YouTube channel ID. Please try a direct channel URL.",
235
					);
236
					setIsAddingFeed(false);
237
					return;
238
				}
239
240
				feedUrl = youtubeFeedUrl;
241
				xmlData = await fetchFeedWithFallback(feedUrl);
242
			} else {
243
				// First, try to fetch the URL directly as a feed
244
				setErrorMessage("Checking URL...");
245
				try {
246
					if (urlInput.includes("substack.com")) {
247
						const parts = urlInput.split("/");
248
						console.log(parts);
249
						const newUrl = `https://${parts[3].slice(1)}.${parts[2]}/feed`;
250
						console.log(newUrl);
251
						xmlData = await fetchFeedWithFallback(newUrl);
252
						// Try to parse it to see if it's a valid feed
253
						parseFeedXml(xmlData);
254
						// If parsing succeeds, it's a valid feed
255
						feedUrl = urlInput;
256
					} else {
257
						xmlData = await fetchFeedWithFallback(urlInput);
258
						// Try to parse it to see if it's a valid feed
259
						parseFeedXml(xmlData);
260
						// If parsing succeeds, it's a valid feed
261
						feedUrl = urlInput;
262
					}
263
				} catch {
264
					// If direct fetch/parse fails, try feed discovery
265
					setErrorMessage("Discovering RSS feed...");
266
					const discovered = await discoverFeed(urlInput);
267
268
					if (!discovered) {
269
						setErrorMessage(
270
							"Could not find an RSS feed at this URL. Please enter a direct feed URL.",
271
						);
272
						setIsAddingFeed(false);
273
						return;
274
					}
275
276
					feedUrl = discovered.feedUrl;
277
					xmlData = discovered.xmlData;
278
				}
279
			}
280
281
			const { feedData, posts, isAtom } = parseFeedXml(xmlData);
282
283
			// Sanitize feed data to meet schema constraints
284
			const sanitizedFeed = sanitizeFeedData(feedData);
285
286
			const result = evolu.insert("rssFeed", {
287
				feedUrl: feedUrl,
288
				title: sanitizedFeed.title,
289
				description: sanitizedFeed.description || null,
290
				category: "Uncategorized",
291
				dateUpdated: new Date().toISOString(),
292
			});
293
294
			if (!result.ok) {
295
				throw new Error(formatTypeError(result.error));
296
			}
297
298
			for (const post of posts) {
299
				// Sanitize post data to meet schema constraints
300
				const sanitizedPost = sanitizePostData(post, isAtom, feedData.title);
301
302
				const postResult = evolu.insert("rssPost", {
303
					title: sanitizedPost.title,
304
					author: sanitizedPost.author || null,
305
					feedTitle: sanitizedFeed.title,
306
					publishedDate: extractPostDate(post),
307
					link: sanitizedPost.link,
308
					feedId: result.value.id,
309
					content: extractPostContent(post, sanitizedPost.link),
310
				});
311
312
				if (!postResult.ok) {
313
					console.warn(
314
						"Failed to insert post:",
315
						formatTypeError(postResult.error),
316
					);
317
				}
318
			}
319
320
			toast.success(
321
				`Successfully added "${feedData.title}" with ${posts.length} post${posts.length !== 1 ? "s" : ""}`,
322
			);
323
324
			setUrlInput("");
325
			setErrorMessage("");
326
		} catch (error) {
327
			setErrorMessage(
328
				error instanceof Error
329
					? error.message
330
					: "Failed to add feed. Please check the URL and try again.",
331
			);
332
		} finally {
333
			setIsAddingFeed(false);
334
		}
335
	}
336
337
	if (isInitialLoading) {
338
		return <LoadingScreen />;
339
	}
340
341
	return (
342
		<main className="min-h-screen w-full items-center justify-center flex-col flex gap-2">
343
			{hasFeeds ? (
344
				<Dashboard />
345
			) : (
346
				<div className="flex flex-col items-start justify-center gap-6 max-w-md w-full px-4">
347
					<div className="flex flex-col gap-2">
348
						<h1 className="text-4xl font-bold">Alcove</h1>
349
						<div className="flex items-center gap-2">
350
							<h4 className="sm:text-sm text-xs">
351
								A privacy focused RSS reader for the open web
352
							</h4>
353
							<AboutDialog>
354
								<Button variant="ghost" size="icon" className="h-5 w-5">
355
									<Info className="sm:h-4 sm:w-4 h-2 w-2" />
356
								</Button>
357
							</AboutDialog>
358
						</div>
359
					</div>
360
					<div className="flex flex-col gap-3 w-full">
361
						<div className="flex flex-row gap-3 w-full">
362
							<Input
363
								value={urlInput}
364
								onChange={(e) => setUrlInput(e.target.value)}
365
								placeholder="https://example.com"
366
								disabled={isAddingFeed}
367
								onKeyDown={(e) => {
368
									if (e.key === "Enter") {
369
										addFeed();
370
									}
371
								}}
372
							/>
373
							<Button onClick={addFeed} disabled={isAddingFeed} size="lg">
374
								{isAddingFeed ? "Adding..." : "Add Feed"}
375
							</Button>
376
						</div>
377
						{errorMessage && (
378
							<div className="text-sm text-center text-destructive">
379
								{errorMessage}
380
							</div>
381
						)}
382
						<input
383
							ref={fileInputRef}
384
							type="file"
385
							accept=".opml,.xml"
386
							onChange={handleFileSelect}
387
							className="hidden"
388
						/>
389
						<Button
390
							variant="outline"
391
							onClick={handleImportClick}
392
							disabled={isImportingOPML}
393
							className="w-full"
394
						>
395
							<FileUp className="h-4 w-4 mr-2" />
396
							{isImportingOPML ? "Importing OPML..." : "Import OPML"}
397
						</Button>
398
						<Button
399
							variant="outline"
400
							onClick={() => setIsRestoreDialogOpen(true)}
401
							className="w-full"
402
						>
403
							<Upload className="h-4 w-4 mr-2" />
404
							Restore from Backup
405
						</Button>
406
					</div>
407
				</div>
408
			)}
409
			<Dialog
410
				open={isRestoreDialogOpen}
411
				onOpenChange={handleRestoreDialogOpenChange}
412
			>
413
				<DialogContent>
414
					<DialogHeader>
415
						<DialogTitle>Restore from Backup</DialogTitle>
416
						<DialogDescription>
417
							Enter your backup phrase to restore your account and access your
418
							encrypted data.
419
						</DialogDescription>
420
					</DialogHeader>
421
					<div className="space-y-4">
422
						<Input
423
							type="password"
424
							className="w-full p-4 bg-muted rounded-lg font-mono text-sm resize-none"
425
							placeholder="Enter your backup phrase here..."
426
							value={restoreMnemonic}
427
							onChange={(e) => setRestoreMnemonic(e.target.value)}
428
						/>
429
						<Button
430
							onClick={handleRestore}
431
							disabled={!restoreMnemonic.trim()}
432
							className="w-full"
433
						>
434
							<Upload className="h-4 w-4 mr-2" />
435
							Restore Account
436
						</Button>
437
					</div>
438
				</DialogContent>
439
			</Dialog>
440
		</main>
441
	);
442
}
443
444
export default App;