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