aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/dashboard/settings/page.tsx4
-rw-r--r--apps/web/components/dashboard/UploadDropzone.tsx29
-rw-r--r--apps/web/components/dashboard/settings/ImportExport.tsx108
-rw-r--r--apps/web/components/ui/file-picker-button.tsx51
4 files changed, 165 insertions, 27 deletions
diff --git a/apps/web/app/dashboard/settings/page.tsx b/apps/web/app/dashboard/settings/page.tsx
index bab76794..e33a57ab 100644
--- a/apps/web/app/dashboard/settings/page.tsx
+++ b/apps/web/app/dashboard/settings/page.tsx
@@ -1,5 +1,6 @@
import ApiKeySettings from "@/components/dashboard/settings/ApiKeySettings";
import { ChangePassword } from "@/components/dashboard/settings/ChangePassword";
+import ImportExport from "@/components/dashboard/settings/ImportExport";
import UserDetails from "@/components/dashboard/settings/UserDetails";
export default async function Settings() {
@@ -10,6 +11,9 @@ export default async function Settings() {
<ChangePassword />
</div>
<div className="mt-4 rounded-md border bg-background p-4">
+ <ImportExport />
+ </div>
+ <div className="mt-4 rounded-md border bg-background p-4">
<ApiKeySettings />
</div>
</>
diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx
index e57f9294..335ac72a 100644
--- a/apps/web/components/dashboard/UploadDropzone.tsx
+++ b/apps/web/components/dashboard/UploadDropzone.tsx
@@ -2,9 +2,7 @@
import React, { useCallback, useState } from "react";
import useUpload from "@/lib/hooks/upload-file";
-import { parseNetscapeBookmarkFile } from "@/lib/netscapeBookmarkParser";
import { cn } from "@/lib/utils";
-import { useMutation } from "@tanstack/react-query";
import { TRPCClientError } from "@trpc/client";
import DropZone from "react-dropzone";
@@ -46,34 +44,11 @@ export function useUploadAsset() {
},
});
- const { mutateAsync: runUploadBookmarkFile } = useMutation({
- mutationFn: async (file: File) => {
- return await parseNetscapeBookmarkFile(file);
- },
- onSuccess: async (resp) => {
- return Promise.all(
- resp.map((url) =>
- createBookmark({ type: BookmarkTypes.LINK, url: url.toString() }),
- ),
- );
- },
- onError: (error) => {
- toast({
- description: error.message,
- variant: "destructive",
- });
- },
- });
-
return useCallback(
(file: File) => {
- if (file.type === "text/html") {
- return runUploadBookmarkFile(file);
- } else {
- return runUploadAsset(file);
- }
+ return runUploadAsset(file);
},
- [runUploadAsset, runUploadBookmarkFile],
+ [runUploadAsset],
);
}
diff --git a/apps/web/components/dashboard/settings/ImportExport.tsx b/apps/web/components/dashboard/settings/ImportExport.tsx
new file mode 100644
index 00000000..75de14ac
--- /dev/null
+++ b/apps/web/components/dashboard/settings/ImportExport.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import assert from "assert";
+import { useRouter } from "next/navigation";
+import FilePickerButton from "@/components/ui/file-picker-button";
+import { toast } from "@/components/ui/use-toast";
+import { parseNetscapeBookmarkFile } from "@/lib/netscapeBookmarkParser";
+import { useMutation } from "@tanstack/react-query";
+import { Upload } from "lucide-react";
+
+import { useCreateBookmarkWithPostHook } from "@hoarder/shared-react/hooks/bookmarks";
+import {
+ useAddBookmarkToList,
+ useCreateBookmarkList,
+} from "@hoarder/shared-react/hooks/lists";
+import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
+
+export function Import() {
+ const router = useRouter();
+ const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook();
+
+ const { mutateAsync: createList } = useCreateBookmarkList();
+ const { mutateAsync: addToList } = useAddBookmarkToList();
+
+ const { mutateAsync: runUploadBookmarkFile } = useMutation({
+ mutationFn: async (file: File) => {
+ return await parseNetscapeBookmarkFile(file);
+ },
+ onSuccess: async (resp) => {
+ const results = await Promise.allSettled(
+ resp.map((url) =>
+ createBookmark({ type: BookmarkTypes.LINK, url: url.toString() }),
+ ),
+ );
+
+ const failed = results.filter((r) => r.status == "rejected");
+ const successes = results.filter(
+ (r) => r.status == "fulfilled" && !r.value.alreadyExists,
+ );
+ const alreadyExisted = results.filter(
+ (r) => r.status == "fulfilled" && r.value.alreadyExists,
+ );
+
+ if (successes.length > 0 || alreadyExisted.length > 0) {
+ toast({
+ description: `Imported ${successes.length} bookmarks and skipped ${alreadyExisted.length} bookmarks that already existed`,
+ variant: "default",
+ });
+ }
+
+ if (failed.length > 0) {
+ toast({
+ description: `Failed to import ${failed.length} bookmarks`,
+ variant: "destructive",
+ });
+ }
+
+ const importList = await createList({
+ name: `Imported Bookmarks`,
+ icon: "⬆️",
+ });
+
+ if (successes.length > 0) {
+ await Promise.allSettled(
+ successes.map((r) => {
+ assert(r.status == "fulfilled");
+ addToList({ bookmarkId: r.value.id, listId: importList.id });
+ }),
+ );
+ }
+
+ router.push(`/dashboard/lists/${importList.id}`);
+ },
+ onError: (error) => {
+ toast({
+ description: error.message,
+ variant: "destructive",
+ });
+ },
+ });
+
+ return (
+ <div>
+ <FilePickerButton
+ accept=".html"
+ multiple={false}
+ className="flex items-center gap-2"
+ onFileSelect={runUploadBookmarkFile}
+ >
+ <Upload />
+ <p>Import Bookmarks from HTML file</p>
+ </FilePickerButton>
+ </div>
+ );
+}
+
+export default function ImportExport() {
+ return (
+ <div>
+ <div className="flex items-center justify-between">
+ <div className="mb-4 text-lg font-medium">Import Bookmarks</div>
+ </div>
+ <div className="mt-2">
+ <Import />
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/components/ui/file-picker-button.tsx b/apps/web/components/ui/file-picker-button.tsx
new file mode 100644
index 00000000..ccac1643
--- /dev/null
+++ b/apps/web/components/ui/file-picker-button.tsx
@@ -0,0 +1,51 @@
+import React, { ChangeEvent, useRef } from "react";
+
+import { Button, ButtonProps } from "./button";
+
+interface FilePickerButtonProps extends Omit<ButtonProps, "onClick"> {
+ onFileSelect?: (file: File) => void;
+ accept?: string;
+ multiple?: boolean;
+}
+
+const FilePickerButton: React.FC<FilePickerButtonProps> = ({
+ onFileSelect,
+ accept,
+ multiple = false,
+ ...buttonProps
+}) => {
+ const fileInputRef = useRef<HTMLInputElement>(null);
+
+ const handleButtonClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files;
+ if (files && files.length > 0) {
+ if (onFileSelect) {
+ if (multiple) {
+ Array.from(files).forEach(onFileSelect);
+ } else {
+ onFileSelect(files[0]);
+ }
+ }
+ }
+ };
+
+ return (
+ <div>
+ <Button onClick={handleButtonClick} {...buttonProps} />
+ <input
+ type="file"
+ ref={fileInputRef}
+ onChange={handleFileChange}
+ className="hidden"
+ accept={accept}
+ multiple={multiple}
+ />
+ </div>
+ );
+};
+
+export default FilePickerButton;