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