src/App.tsx 11.7 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
					xmlData = await fetchFeedWithFallback(urlInput);
247
					// Try to parse it to see if it's a valid feed
248
					parseFeedXml(xmlData);
249
					// If parsing succeeds, it's a valid feed
250
					feedUrl = urlInput;
251
				} catch {
252
					// If direct fetch/parse fails, try feed discovery
253
					setErrorMessage("Discovering RSS feed...");
254
					const discovered = await discoverFeed(urlInput);
255
256
					if (!discovered) {
257
						setErrorMessage(
258
							"Could not find an RSS feed at this URL. Please enter a direct feed URL.",
259
						);
260
						setIsAddingFeed(false);
261
						return;
262
					}
263
264
					feedUrl = discovered.feedUrl;
265
					xmlData = discovered.xmlData;
266
				}
267
			}
268
269
			const { feedData, posts, isAtom } = parseFeedXml(xmlData);
270
271
			// Sanitize feed data to meet schema constraints
272
			const sanitizedFeed = sanitizeFeedData(feedData);
273
274
			const result = evolu.insert("rssFeed", {
275
				feedUrl: feedUrl,
276
				title: sanitizedFeed.title,
277
				description: sanitizedFeed.description || null,
278
				category: "Uncategorized",
279
				dateUpdated: new Date().toISOString(),
280
			});
281
282
			if (!result.ok) {
283
				throw new Error(formatTypeError(result.error));
284
			}
285
286
			for (const post of posts) {
287
				// Sanitize post data to meet schema constraints
288
				const sanitizedPost = sanitizePostData(post, isAtom, feedData.title);
289
290
				const postResult = evolu.insert("rssPost", {
291
					title: sanitizedPost.title,
292
					author: sanitizedPost.author || null,
293
					feedTitle: sanitizedFeed.title,
294
					publishedDate: extractPostDate(post),
295
					link: sanitizedPost.link,
296
					feedId: result.value.id,
297
					content: extractPostContent(post, sanitizedPost.link),
298
				});
299
300
				if (!postResult.ok) {
301
					console.warn(
302
						"Failed to insert post:",
303
						formatTypeError(postResult.error),
304
					);
305
				}
306
			}
307
308
			toast.success(
309
				`Successfully added "${feedData.title}" with ${posts.length} post${posts.length !== 1 ? "s" : ""}`,
310
			);
311
312
			setUrlInput("");
313
			setErrorMessage("");
314
		} catch (error) {
315
			setErrorMessage(
316
				error instanceof Error
317
					? error.message
318
					: "Failed to add feed. Please check the URL and try again.",
319
			);
320
		} finally {
321
			setIsAddingFeed(false);
322
		}
323
	}
324
325
	if (isInitialLoading) {
326
		return <LoadingScreen />;
327
	}
328
329
	return (
330
		<main className="min-h-screen w-full items-center justify-center flex-col flex gap-2">
331
			{hasFeeds ? (
332
				<Dashboard />
333
			) : (
334
				<div className="flex flex-col items-start justify-center gap-6 max-w-md w-full px-4">
335
					<div className="flex flex-col gap-2">
336
						<h1 className="text-4xl font-bold">Alcove</h1>
337
						<div className="flex items-center gap-2">
338
							<h4 className="sm:text-sm text-xs">
339
								A privacy focused RSS reader for the open web
340
							</h4>
341
							<AboutDialog>
342
								<Button variant="ghost" size="icon" className="h-5 w-5">
343
									<Info className="sm:h-4 sm:w-4 h-2 w-2" />
344
								</Button>
345
							</AboutDialog>
346
						</div>
347
					</div>
348
					<div className="flex flex-col gap-3 w-full">
349
						<div className="flex flex-row gap-3 w-full">
350
							<Input
351
								value={urlInput}
352
								onChange={(e) => setUrlInput(e.target.value)}
353
								placeholder="https://example.com"
354
								disabled={isAddingFeed}
355
								onKeyDown={(e) => {
356
									if (e.key === "Enter") {
357
										addFeed();
358
									}
359
								}}
360
							/>
361
							<Button onClick={addFeed} disabled={isAddingFeed} size="lg">
362
								{isAddingFeed ? "Adding..." : "Add Feed"}
363
							</Button>
364
						</div>
365
						{errorMessage && (
366
							<div className="text-sm text-center text-destructive">
367
								{errorMessage}
368
							</div>
369
						)}
370
						<input
371
							ref={fileInputRef}
372
							type="file"
373
							accept=".opml,.xml"
374
							onChange={handleFileSelect}
375
							className="hidden"
376
						/>
377
						<Button
378
							variant="outline"
379
							onClick={handleImportClick}
380
							disabled={isImportingOPML}
381
							className="w-full"
382
						>
383
							<FileUp className="h-4 w-4 mr-2" />
384
							{isImportingOPML ? "Importing OPML..." : "Import OPML"}
385
						</Button>
386
						<Button
387
							variant="outline"
388
							onClick={() => setIsRestoreDialogOpen(true)}
389
							className="w-full"
390
						>
391
							<Upload className="h-4 w-4 mr-2" />
392
							Restore from Backup
393
						</Button>
394
					</div>
395
				</div>
396
			)}
397
			<Dialog
398
				open={isRestoreDialogOpen}
399
				onOpenChange={handleRestoreDialogOpenChange}
400
			>
401
				<DialogContent>
402
					<DialogHeader>
403
						<DialogTitle>Restore from Backup</DialogTitle>
404
						<DialogDescription>
405
							Enter your backup phrase to restore your account and access your
406
							encrypted data.
407
						</DialogDescription>
408
					</DialogHeader>
409
					<div className="space-y-4">
410
						<Input
411
							type="password"
412
							className="w-full p-4 bg-muted rounded-lg font-mono text-sm resize-none"
413
							placeholder="Enter your backup phrase here..."
414
							value={restoreMnemonic}
415
							onChange={(e) => setRestoreMnemonic(e.target.value)}
416
						/>
417
						<Button
418
							onClick={handleRestore}
419
							disabled={!restoreMnemonic.trim()}
420
							className="w-full"
421
						>
422
							<Upload className="h-4 w-4 mr-2" />
423
							Restore Account
424
						</Button>
425
					</div>
426
				</DialogContent>
427
			</Dialog>
428
		</main>
429
	);
430
}
431
432
export default App;