src/components/nav-user.tsx 10.9 K raw
1
import {
2
	BookKey,
3
	Copy,
4
	Eye,
5
	EyeOff,
6
	Info,
7
	Trash2,
8
	Upload,
9
	Download,
10
	FileUp,
11
} from "lucide-react";
12
import {
13
	Dialog,
14
	DialogContent,
15
	DialogDescription,
16
	DialogHeader,
17
	DialogTitle,
18
	DialogTrigger,
19
} from "@/components/ui/dialog";
20
import { Button } from "@/components/ui/button";
21
import { use, useState, useRef } from "react";
22
import { useEvolu, reset, allFeedsQuery } from "@/lib/evolu";
23
import { useQuery } from "@evolu/react";
24
import { generateOPML, parseOPML, downloadOPML } from "@/lib/opml";
25
import {
26
	fetchFeedWithFallback,
27
	parseFeedXml,
28
	extractPostLink,
29
	extractPostAuthor,
30
	extractPostContent,
31
	extractPostDate,
32
} from "@/lib/feed-operations";
33
34
import {
35
	DropdownMenu,
36
	DropdownMenuContent,
37
	DropdownMenuGroup,
38
	DropdownMenuItem,
39
	DropdownMenuSeparator,
40
	DropdownMenuTrigger,
41
} from "@/components/ui/dropdown-menu";
42
import {
43
	SidebarMenu,
44
	SidebarMenuButton,
45
	SidebarMenuItem,
46
	useSidebar,
47
} from "@/components/ui/sidebar";
48
import * as Evolu from "@evolu/common";
49
import { AboutDialog } from "@/components/about-dialog";
50
import { formatTypeError } from "@/lib/format-error";
51
import { Input } from "./ui/input";
52
53
export function NavUser() {
54
	const { isMobile } = useSidebar();
55
	const [isDialogOpen, setIsDialogOpen] = useState(false);
56
	const [isRestoreDialogOpen, setIsRestoreDialogOpen] = useState(false);
57
	const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false);
58
	const [isImportOPMLDialogOpen, setIsImportOPMLDialogOpen] = useState(false);
59
	const [backupPhrase, setBackupPhrase] = useState<Evolu.Mnemonic | null>();
60
	const [isRevealed, setIsRevealed] = useState(false);
61
	const [isCopied, setIsCopied] = useState(false);
62
	const [restoreMnemonic, setRestoreMnemonic] = useState("");
63
	const [isImporting, setIsImporting] = useState(false);
64
	const [importProgress, setImportProgress] = useState("");
65
	const fileInputRef = useRef<HTMLInputElement>(null);
66
67
	function maskPhrase(phrase: string | null | undefined) {
68
		if (!phrase) return "";
69
		const words = phrase
70
			.trim()
71
			.split(/\s+/)
72
			.filter((word) => word.length > 0);
73
		return words.map((word) => "•".repeat(word.length)).join(" ");
74
	}
75
76
	const evolu = useEvolu();
77
	const owner = use(evolu.appOwner);
78
	const feeds = useQuery(allFeedsQuery);
79
80
	function backup() {
81
		setBackupPhrase(owner?.mnemonic);
82
	}
83
84
	async function handleExportOPML() {
85
		try {
86
			const opmlContent = generateOPML(feeds);
87
			downloadOPML(opmlContent);
88
		} catch (error) {
89
			console.error("Failed to export OPML:", error);
90
			alert("Failed to export OPML. Please try again.");
91
		}
92
	}
93
94
	async function handleImportOPML(file: File) {
95
		setIsImporting(true);
96
		setImportProgress("Reading OPML file...");
97
98
		try {
99
			const fileContent = await file.text();
100
			const opmlFeeds = parseOPML(fileContent);
101
102
			setImportProgress(`Found ${opmlFeeds.length} feeds. Importing...`);
103
104
			let successCount = 0;
105
			let failCount = 0;
106
107
			for (let i = 0; i < opmlFeeds.length; i++) {
108
				const feed = opmlFeeds[i];
109
				setImportProgress(
110
					`Importing feed ${i + 1}/${opmlFeeds.length}: ${feed.title}`,
111
				);
112
113
				try {
114
					const xmlData = await fetchFeedWithFallback(feed.feedUrl);
115
					const { feedData, posts, isAtom } = parseFeedXml(xmlData);
116
117
					const result = evolu.insert("rssFeed", {
118
						feedUrl: feed.feedUrl,
119
						title: feed.title,
120
						description:
121
							feed.description ||
122
							feedData.description ||
123
							feedData.subtitle ||
124
							"",
125
						category: feed.category || "Uncategorized",
126
						dateUpdated: new Date().toISOString(),
127
					});
128
129
					if (!result.ok) {
130
						continue;
131
					}
132
133
					for (const post of posts) {
134
						const postLink = extractPostLink(post, isAtom);
135
						const postResult = evolu.insert("rssPost", {
136
							title: post.title,
137
							author: extractPostAuthor(post, isAtom, feedData.title),
138
							feedTitle: feed.title,
139
							publishedDate: extractPostDate(post, isAtom),
140
							link: postLink,
141
							feedId: result.value.id,
142
							content: extractPostContent(post, postLink),
143
						});
144
						if (!postResult.ok) {
145
							console.warn(
146
								"Failed to insert post:",
147
								formatTypeError(postResult.error),
148
							);
149
						}
150
					}
151
152
					successCount++;
153
				} catch (error) {
154
					console.error(`Failed to import feed: ${feed.title}`, error);
155
					failCount++;
156
				}
157
			}
158
159
			setImportProgress(
160
				`Import complete! Success: ${successCount}, Failed: ${failCount}`,
161
			);
162
			setTimeout(() => {
163
				setIsImportOPMLDialogOpen(false);
164
				setIsImporting(false);
165
				setImportProgress("");
166
				if (fileInputRef.current) {
167
					fileInputRef.current.value = "";
168
				}
169
			}, 2000);
170
		} catch (error) {
171
			console.error("Failed to import OPML:", error);
172
			setImportProgress("Failed to import OPML. Please check the file format.");
173
			setIsImporting(false);
174
		}
175
	}
176
177
	function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
178
		const file = event.target.files?.[0];
179
		if (file) {
180
			handleImportOPML(file);
181
		}
182
	}
183
184
	function copyToClipboard() {
185
		if (backupPhrase) {
186
			navigator.clipboard.writeText(backupPhrase);
187
			setIsCopied(true);
188
			setTimeout(() => setIsCopied(false), 2000);
189
		}
190
	}
191
192
	function handleDialogOpenChange(open: boolean) {
193
		setIsDialogOpen(open);
194
		if (open) {
195
			backup();
196
		} else {
197
			// Reset state when dialog closes
198
			setIsRevealed(false);
199
			setIsCopied(false);
200
		}
201
	}
202
203
	function handleRestoreDialogOpenChange(open: boolean) {
204
		setIsRestoreDialogOpen(open);
205
		if (!open) {
206
			setRestoreMnemonic("");
207
		}
208
	}
209
210
	function handleRestore() {
211
		if (restoreMnemonic.trim()) {
212
			const result = Evolu.Mnemonic.from(restoreMnemonic.trim());
213
			if (!result.ok) {
214
				alert(formatTypeError(result.error));
215
				return;
216
			}
217
218
			void evolu.restoreAppOwner(result.value);
219
			setIsRestoreDialogOpen(false);
220
			setRestoreMnemonic("");
221
		}
222
	}
223
224
	return (
225
		<>
226
			<Dialog open={isDialogOpen} onOpenChange={handleDialogOpenChange}>
227
				<SidebarMenu>
228
					<SidebarMenuItem>
229
						<DropdownMenu>
230
							<DropdownMenuTrigger asChild>
231
								<SidebarMenuButton
232
									size="lg"
233
									className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground md:h-8 md:p-0"
234
								>
235
									<div className="grid flex-1 text-center text-sm leading-tight">
236
										<span className="truncate font-medium">Settings</span>
237
									</div>
238
								</SidebarMenuButton>
239
							</DropdownMenuTrigger>
240
							<DropdownMenuContent
241
								className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
242
								side={isMobile ? "bottom" : "right"}
243
								align="end"
244
								sideOffset={4}
245
							>
246
								<DropdownMenuGroup>
247
									<DropdownMenuItem onClick={() => setIsAboutDialogOpen(true)}>
248
										<Info />
249
										About
250
									</DropdownMenuItem>
251
								</DropdownMenuGroup>
252
								<DropdownMenuSeparator />
253
								<DropdownMenuGroup>
254
									<DialogTrigger asChild>
255
										<DropdownMenuItem>
256
											<BookKey />
257
											Backup
258
										</DropdownMenuItem>
259
									</DialogTrigger>
260
									<DropdownMenuItem
261
										onClick={() => setIsRestoreDialogOpen(true)}
262
									>
263
										<Upload />
264
										Restore
265
									</DropdownMenuItem>
266
								</DropdownMenuGroup>
267
								<DropdownMenuSeparator />
268
								<DropdownMenuGroup>
269
									<DropdownMenuItem onClick={handleExportOPML}>
270
										<Download />
271
										Export OPML
272
									</DropdownMenuItem>
273
									<DropdownMenuItem
274
										onClick={() => setIsImportOPMLDialogOpen(true)}
275
									>
276
										<FileUp />
277
										Import OPML
278
									</DropdownMenuItem>
279
								</DropdownMenuGroup>
280
								<DropdownMenuSeparator />
281
								<DropdownMenuItem onClick={reset}>
282
									<Trash2 />
283
									Clear Data
284
								</DropdownMenuItem>
285
							</DropdownMenuContent>
286
						</DropdownMenu>
287
					</SidebarMenuItem>
288
				</SidebarMenu>
289
				<DialogContent>
290
					<DialogHeader>
291
						<DialogTitle>Backup or Export Your Account</DialogTitle>
292
						<DialogDescription>
293
							Alcove does not have access to your data since it's encrypted. In
294
							order to recover it or access it from another device you need to
295
							copy the phrase below somewhere safe.
296
						</DialogDescription>
297
					</DialogHeader>
298
					<div className="space-y-4">
299
						<div className="relative p-4 bg-muted rounded-lg font-mono text-sm break-all">
300
							{isRevealed ? backupPhrase : maskPhrase(backupPhrase)}
301
						</div>
302
						<div className="flex gap-2">
303
							<Button
304
								variant="outline"
305
								size="sm"
306
								onClick={() => setIsRevealed(!isRevealed)}
307
								className="flex-1"
308
							>
309
								{isRevealed ? (
310
									<>
311
										<EyeOff className="h-4 w-4 mr-2" />
312
										Hide
313
									</>
314
								) : (
315
									<>
316
										<Eye className="h-4 w-4 mr-2" />
317
										Reveal
318
									</>
319
								)}
320
							</Button>
321
							<Button
322
								variant="outline"
323
								size="sm"
324
								onClick={copyToClipboard}
325
								className="flex-1"
326
							>
327
								<Copy className="h-4 w-4 mr-2" />
328
								{isCopied ? "Copied!" : "Copy"}
329
							</Button>
330
						</div>
331
					</div>
332
				</DialogContent>
333
			</Dialog>
334
			<Dialog
335
				open={isRestoreDialogOpen}
336
				onOpenChange={handleRestoreDialogOpenChange}
337
			>
338
				<DialogContent>
339
					<DialogHeader>
340
						<DialogTitle>Restore from Backup</DialogTitle>
341
						<DialogDescription>
342
							Enter your backup phrase to restore your account and access your
343
							encrypted data.
344
						</DialogDescription>
345
					</DialogHeader>
346
					<div className="space-y-4">
347
						<Input
348
							type="password"
349
							className="w-full p-4 bg-muted rounded-lg font-mono text-sm resize-none"
350
							placeholder="Enter your backup phrase here..."
351
							value={restoreMnemonic}
352
							onChange={(e) => setRestoreMnemonic(e.target.value)}
353
						/>
354
						<Button
355
							onClick={handleRestore}
356
							disabled={!restoreMnemonic.trim()}
357
							className="w-full"
358
						>
359
							<Upload className="h-4 w-4 mr-2" />
360
							Restore Account
361
						</Button>
362
					</div>
363
				</DialogContent>
364
			</Dialog>
365
			<AboutDialog
366
				open={isAboutDialogOpen}
367
				onOpenChange={setIsAboutDialogOpen}
368
			/>
369
			<Dialog
370
				open={isImportOPMLDialogOpen}
371
				onOpenChange={setIsImportOPMLDialogOpen}
372
			>
373
				<DialogContent>
374
					<DialogHeader>
375
						<DialogTitle>Import OPML</DialogTitle>
376
						<DialogDescription>
377
							Import your RSS feeds from an OPML file. This will add all feeds
378
							from the file to your collection.
379
						</DialogDescription>
380
					</DialogHeader>
381
					<div className="space-y-4">
382
						{isImporting ? (
383
							<div className="p-4 bg-muted rounded-lg">
384
								<p className="text-sm font-mono">{importProgress}</p>
385
							</div>
386
						) : (
387
							<>
388
								<input
389
									ref={fileInputRef}
390
									type="file"
391
									accept=".opml,.xml"
392
									onChange={handleFileSelect}
393
									className="w-full p-4 bg-muted rounded-lg text-sm file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary file:text-primary-foreground hover:file:bg-primary/90"
394
								/>
395
								<p className="text-xs text-muted-foreground">
396
									Select an OPML file (.opml or .xml) to import your feeds.
397
								</p>
398
							</>
399
						)}
400
					</div>
401
				</DialogContent>
402
			</Dialog>
403
		</>
404
	);
405
}