Skip to content

Commit 1c08d3c

Browse files
committed
feat: export bookmarks
1 parent 2b62cc4 commit 1c08d3c

18 files changed

+599
-16
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ My new tab page, featuring a clean and minimalistic design with [Catppuccin](htt
88
- [x] Add & remove bookmarks
99
- [x] Organize bookmarks in folders
1010
- [x] Search bookmarks
11+
- [ ] Import / export bookmarks
1112
- [ ] Move bookmarks between folders
1213
- [ ] Tooltips for:
1314
- [ ] Buttons (to clarify actions)
@@ -33,6 +34,7 @@ My new tab page, featuring a clean and minimalistic design with [Catppuccin](htt
3334
![screenshot](https://i-have-a.degradationk.ink/Lizzy68d239e8jRWRHYEkp84G.png)
3435
![screenshot](https://i-have-a.degradationk.ink/Lizzy68d23a171ochIzYsHwmD.png)
3536
![screenshot](https://i-have-a.degradationk.ink/Lizzy68d23a28FgcReIXlkj03.png)
37+
![screenshot](https://i-have-a.degradationk.ink/Lizzy68d4e4624c9hAgyyf4qd.png)
3638

3739
## License
3840
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
"@fortawesome/free-regular-svg-icons": "^7.0.1",
1818
"@fortawesome/free-solid-svg-icons": "^7.0.1",
1919
"@fortawesome/react-fontawesome": "^3.0.2",
20+
"@popperjs/core": "^2.11.8",
2021
"@types/react": "^19.1.13",
2122
"@types/react-dom": "^19.1.9",
2223
"astro": "^5.13.10",
2324
"fast-deep-equal": "^3.1.3",
2425
"react": "^19.1.1",
2526
"react-dom": "^19.1.1",
27+
"react-popper": "^2.3.0",
2628
"sass": "^1.93.0",
2729
"zustand": "^5.0.8",
2830
"zustand-computed": "^2.1.0"

pnpm-lock.yaml

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/Shortcuts.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ const Shortcuts: FC = () => {
6464
key={name}
6565
href={url}
6666
rel="noopener noreferrer"
67-
target="_blank"
6867
className={`shortcut ${name}`}
6968
>
7069
<FontAwesomeIcon icon={icon} />

src/components/bookmarks/Bookmark.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import {
1414
faFolderOpen,
1515
faLink,
1616
faPlus,
17-
faPencil,
18-
faTrash,
1917
} from "@fortawesome/free-solid-svg-icons";
2018
import { CommonBookmarkActions } from "./CommonBookmarkActions";
2119

@@ -114,7 +112,6 @@ export const UrlBookmark: FC<UrlBookmarkProps> = ({
114112
return (
115113
<a
116114
href={bookmark.url}
117-
target="_blank"
118115
rel="noopener noreferrer"
119116
className="bookmark_line"
120117
>

src/components/bookmarks/Bookmarks.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { BookmarkCreateDialog } from "./dialogs/BookmarkCreateDialog";
99
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
1010
import {
1111
faBookmark,
12+
faFileExport,
1213
faFolder,
1314
faPlus,
1415
} from "@fortawesome/free-solid-svg-icons";
@@ -22,6 +23,7 @@ import { BookmarkDeleteDialog } from "./dialogs/BookmarkDeleteDialog";
2223
import { BookmarkEditDialog } from "./dialogs/BookmarkEditDialog";
2324
import { FolderEditDialog } from "./dialogs/FolderEditDialog";
2425
import { BookmarkSearchResults } from "./search/BookmarkSearchResults";
26+
import { ExportBookmarksDialog } from "./dialogs/ExportBookmarksDialog";
2527

2628
const Bookmarks: FC = () => {
2729
const bookmarks = useBookmarksStore((store) => store.bookmarks);
@@ -115,6 +117,16 @@ const Bookmarks: FC = () => {
115117
setFolderEditDialogFolder(undefined);
116118
}
117119

120+
const [exportBookmarksDialogOpen, setExportBookmarksDialogOpen] = useState(false);
121+
122+
function openExportBookmarksDialog() {
123+
setExportBookmarksDialogOpen(true);
124+
}
125+
126+
function closeExportBookmarksDialog() {
127+
setExportBookmarksDialogOpen(false);
128+
}
129+
118130
return (
119131
<div className="bookmarks">
120132
<div className="header">
@@ -162,6 +174,12 @@ const Bookmarks: FC = () => {
162174
mask={faFolder}
163175
/>
164176
</button>
177+
178+
<button onClick={() => openExportBookmarksDialog()}>
179+
<FontAwesomeIcon
180+
icon={faFileExport}
181+
/>
182+
</button>
165183
</div>
166184

167185
<BookmarkCreateDialog
@@ -193,6 +211,12 @@ const Bookmarks: FC = () => {
193211
onClose={closeFolderEditDialog}
194212
folder={folderEditDialogFolder}
195213
/>
214+
215+
<ExportBookmarksDialog
216+
open={exportBookmarksDialogOpen}
217+
onClose={closeExportBookmarksDialog}
218+
bookmarks={bookmarks}
219+
/>
196220
</div>
197221
);
198222
};

src/components/bookmarks/dialogs/BookmarkCreateDialog.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import { useBookmarksStore } from "../../../store/bookmarks";
1313
import { isUrl } from "../../../util/url";
1414
import { FormErrors } from "../../form/FormErrors";
1515
import type { BookmarkItemFolder } from "../../../types/bookmarks";
16-
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
17-
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
1816

1917
export type BookmarkCreateDialogProps = DialogPropsBase & {
2018
onClose: () => void;
@@ -78,7 +76,7 @@ export const BookmarkCreateDialog: FC<BookmarkCreateDialogProps> = ({
7876
<Dialog open={open}>
7977
<DialogHeader title={dialogTitle} onClose={onClose} />
8078
{errors.length > 0 && (
81-
<DialogError heading="Form errors:">
79+
<DialogError heading="Errors:">
8280
<FormErrors errors={errors} />
8381
</DialogError>
8482
)}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useEffect, useState, type FC } from "react";
2+
3+
import {
4+
Dialog,
5+
DialogFooter,
6+
DialogActionButton,
7+
DialogHeader,
8+
DialogContent,
9+
type DialogPropsBase,
10+
DialogError,
11+
} from "../../dialog";
12+
import { BookmarkExportSelection, filterExports, makeExportable, walkAndSetAllChildrenToSelection, walkAndUpdateBookmarkExportSelection, type BookmarkItem, type ExportableBookmarkItem } from "../../../types/bookmarks";
13+
import { BookmarkExportList } from "../export/BookmarkExportList";
14+
15+
export type ExportBookmarksDialogProps = {
16+
onClose: () => void;
17+
bookmarks: BookmarkItem[];
18+
} & DialogPropsBase;
19+
20+
export const ExportBookmarksDialog: FC<ExportBookmarksDialogProps> = ({
21+
open,
22+
onClose,
23+
bookmarks,
24+
}) => {
25+
const [exportList, setExportList] = useState<ExportableBookmarkItem[]>([]);
26+
const [error, setError] = useState<string | null>(null);
27+
28+
useEffect(() => {
29+
setExportList(bookmarks.map(makeExportable));
30+
}, [bookmarks]);
31+
32+
useEffect(() => {
33+
const timeoutId = setTimeout(() => {
34+
if (error) {
35+
setError(null);
36+
}
37+
}, 3000);
38+
return () => clearTimeout(timeoutId);
39+
}, [error]);
40+
41+
const close = () => {
42+
setExportList(prev => walkAndSetAllChildrenToSelection(prev, BookmarkExportSelection.DONT_EXPORT));
43+
onClose();
44+
};
45+
46+
const doExport = () => {
47+
const exported = filterExports(exportList);
48+
49+
if (exported.length === 0) {
50+
setError("No bookmarks selected for export.");
51+
return;
52+
}
53+
54+
const blob = new Blob([JSON.stringify(exported)], { type: "application/json" });
55+
const url = URL.createObjectURL(blob);
56+
const link = document.createElement('a');
57+
link.href = url;
58+
link.download = "bookmarks.json"; // Forces download instead of navigation
59+
document.body.appendChild(link);
60+
link.click();
61+
document.body.removeChild(link);
62+
URL.revokeObjectURL(url); // Clean up the URL
63+
64+
close();
65+
};
66+
67+
const handleBookmarkExportSelectionChange = (id: string, state: BookmarkExportSelection) => {
68+
setExportList(prev => walkAndUpdateBookmarkExportSelection(prev, id, state));
69+
};
70+
71+
const selectAll = () => {
72+
setExportList(prev => walkAndSetAllChildrenToSelection(prev, BookmarkExportSelection.EXPORT));
73+
};
74+
75+
const deselectAll = () => {
76+
setExportList(prev => walkAndSetAllChildrenToSelection(prev, BookmarkExportSelection.DONT_EXPORT));
77+
};
78+
79+
return (
80+
<Dialog open={open} width="70%">
81+
<DialogHeader title="Export Bookmarks" onClose={close} />
82+
{error && (
83+
<DialogError heading="Error">
84+
<p>{error}</p>
85+
</DialogError>
86+
)}
87+
<DialogContent>
88+
<div className="buttonBar full" style={{ marginBottom: ".5rem" }}>
89+
<button className="button small outline-green" onClick={selectAll}>Select All</button>
90+
<button className="button small outline-red" onClick={deselectAll}>Deselect All</button>
91+
</div>
92+
<BookmarkExportList
93+
bookmarks={exportList}
94+
changeBookmarkExportSelection={handleBookmarkExportSelectionChange}
95+
/>
96+
</DialogContent>
97+
<DialogFooter>
98+
<DialogActionButton type="confirm" onClick={doExport}>Export</DialogActionButton>
99+
<DialogActionButton type="cancel" onClick={close}>Cancel</DialogActionButton>
100+
</DialogFooter>
101+
</Dialog>
102+
);
103+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { FC } from "react";
2+
import type { BookmarkExportSelection, ExportableBookmarkItem } from "../../../types/bookmarks";
3+
import { ExportListBookmark } from "./ExportListBookmarks";
4+
5+
export type BookmarkExportListProps = {
6+
bookmarks: ExportableBookmarkItem[];
7+
changeBookmarkExportSelection: (bookmarkId: string, exportState: BookmarkExportSelection) => void;
8+
};
9+
10+
export const BookmarkExportList: FC<BookmarkExportListProps> = ({
11+
bookmarks,
12+
changeBookmarkExportSelection
13+
}) => {
14+
return (
15+
<ul className="bookmark_list">
16+
{bookmarks.map(bookmark =>
17+
<ExportListBookmark
18+
key={bookmark.id}
19+
bookmark={bookmark}
20+
changeBookmarkExportSelection={changeBookmarkExportSelection}
21+
/>
22+
)}
23+
</ul>
24+
)
25+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { FC } from "react";
2+
import { BookmarkExportSelection } from "../../../types/bookmarks";
3+
import { faCheckSquare, faMinusSquare, type IconDefinition } from "@fortawesome/free-solid-svg-icons";
4+
import { faSquare } from "@fortawesome/free-regular-svg-icons";
5+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
6+
7+
export type ExportCheckboxProps = {
8+
exportSelection: BookmarkExportSelection;
9+
onChange: (exportState: BookmarkExportSelection) => void;
10+
};
11+
12+
const ICONS: Record<BookmarkExportSelection, IconDefinition> = {
13+
[BookmarkExportSelection.EXPORT]: faCheckSquare,
14+
[BookmarkExportSelection.DONT_EXPORT]: faSquare,
15+
[BookmarkExportSelection.PARTIAL]: faMinusSquare,
16+
};
17+
18+
export const ExportCheckbox: FC<ExportCheckboxProps> = ({
19+
exportSelection,
20+
onChange,
21+
}) => {
22+
return (
23+
<FontAwesomeIcon
24+
icon={ICONS[exportSelection]}
25+
className="mr-2"
26+
onClick={(e) => {
27+
e.stopPropagation();
28+
e.preventDefault();
29+
30+
if (exportSelection === BookmarkExportSelection.EXPORT) {
31+
onChange(BookmarkExportSelection.DONT_EXPORT);
32+
} else if (exportSelection === BookmarkExportSelection.DONT_EXPORT) {
33+
onChange(BookmarkExportSelection.EXPORT); // don't manually change to PARTIAL.
34+
} else if (exportSelection === BookmarkExportSelection.PARTIAL) {
35+
onChange(BookmarkExportSelection.EXPORT);
36+
}
37+
}}
38+
/>
39+
);
40+
};

0 commit comments

Comments
 (0)