Add ZIP import page and folder collapse UI

- Add a ZIP-based import page that builds a folder/note tree from a ZIP
  and creates items via the API
- Introduce collapsible folder UI with RenderFolder and updates to
  DroppableFolder to support collapsing
- Wire a new /import route into App and add jszip as a frontend
  dependency
This commit is contained in:
James Fitzsimons 2025-11-26 21:38:58 +00:00
parent 60ddf0520e
commit 45dcbdaee9
14 changed files with 362 additions and 62 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -15,6 +15,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"jszip": "^3.10.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.6",
@ -60,7 +61,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -637,7 +637,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@ -727,7 +726,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@ -737,7 +735,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@ -1274,7 +1271,6 @@
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
@ -1639,7 +1635,6 @@
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@lezer/common": "^1.3.0"
}
@ -3161,7 +3156,6 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@ -3586,7 +3580,6 @@
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -3597,7 +3590,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -3634,7 +3626,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3742,7 +3733,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@ -3961,6 +3951,12 @@
"node": ">=18"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
@ -4636,6 +4632,12 @@
],
"license": "BSD-3-Clause"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -4653,6 +4655,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/intersection-observer": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.10.0.tgz",
@ -4711,11 +4719,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
@ -4781,6 +4796,18 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -4801,6 +4828,7 @@
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz",
"integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"isomorphic.js": "^0.2.4"
},
@ -4817,6 +4845,15 @@
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@ -6187,6 +6224,12 @@
"integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==",
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -6323,6 +6366,12 @@
"node": ">=6"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -6351,7 +6400,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -6373,7 +6421,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -6537,6 +6584,21 @@
}
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -6600,6 +6662,12 @@
"node": ">=6"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -6625,6 +6693,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/snake-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
@ -6663,6 +6737,15 @@
"integrity": "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -6700,8 +6783,7 @@
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
@ -6922,7 +7004,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

View file

@ -15,6 +15,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"jszip": "^3.10.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.6",

View file

@ -2,6 +2,7 @@
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import Home from "./pages/Home"; // existing home page
import { MarkdownPage } from "./pages/Markdown";
import { Import } from "./pages/Import";
const App = () => (
<BrowserRouter>
{/* Simple nav you can replace with your own UI later */}
@ -12,6 +13,7 @@ const App = () => (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/markdown" element={<MarkdownPage />} />
<Route path="/import" element={<Import />} />
</Routes>
</BrowserRouter>
);

View file

@ -7,11 +7,15 @@ export const DroppableFolder = ({
setSelectedFolder,
selectedFolder,
selectedNote,
setCollapse,
collapse,
}: {
folder: Partial<Folder>;
setSelectedFolder: React.Dispatch<React.SetStateAction<number | null>>;
selectedFolder: number | null;
selectedNote: NoteRead | null;
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
collapse: boolean;
}) => {
const { isOver, setNodeRef } = useDroppable({
id: folder.id!,
@ -34,6 +38,9 @@ export const DroppableFolder = ({
>
<i className="fadr fa-folder text-sm"></i>
{folder.name}
<div onClick={() => setCollapse(!collapse)} className="ml-auto">
x
</div>
</div>
</div>
);

View file

@ -7,13 +7,13 @@
._mdxeditor-root-content-editable,
.mdxeditor-root-contenteditable,
div[contenteditable="true"] {
color: var(--ctp-text) !important;
color: var(--color-ctp-text) !important;
}
/* Override prose specifically */
.prose,
.prose * {
color: var(--ctp-text) !important;
color: var(--color-ctp-text) !important;
}
/* Override list markers */
@ -21,21 +21,37 @@ div[contenteditable="true"] {
.prose ol li::marker,
ul li::marker,
ol li::marker {
color: var(--ctp-text) !important;
color: var(--color-ctp-text) !important;
}
.my-class {
background-color: var(--ctp-mantle) !important;
border-bottom: 1px solid var(--ctp-surface2) !important;
background-color: var(--color-ctp-mantle) !important;
border-bottom: 1px solid var(--color-ctp-surface2) !important;
}
.my-class button {
color: var(--ctp-text) !important;
background-color: var(--ctp-surface0) !important;
color: var(--color-ctp-text) !important;
background-color: var(--color-ctp-surface0) !important;
}
_listItemChecked_1tncs_73::before {
background-color: var(--color-ctp-mauve);
}
.mdxeditor-popup-container > * {
background-color: var(--ctp-base) !important;
background-color: var(--color-ctp-base) !important;
}
.toolbar {
background-color: var(--color-ctp-crust) !important;
}
._listItemChecked_1tncs_73::before {
--accentSolid: var(--color-ctp-mauve) !important;
border-color: var(--color-ctp-mauve-900) !important;
border: 2px;
}
._listItemChecked_1tncs_73::after {
border-color: var(--color-ctp-mauve-900) !important;
}

View file

@ -1,4 +1,5 @@
import {
BoldItalicUnderlineToggles,
codeBlockPlugin,
codeMirrorPlugin,
diffSourcePlugin,
@ -13,8 +14,11 @@ import {
sandpackPlugin,
tablePlugin,
thematicBreakPlugin,
toolbarPlugin,
UndoRedo,
DiffSourceToggleWrapper,
} from "@mdxeditor/editor";
import { useEffect, useRef, useState } from "react";
import { SetStateAction, useEffect, useRef, useState } from "react";
import {
folderApi,
FolderCreate,
@ -155,32 +159,6 @@ function Home() {
setContent("");
};
const renderFolder = (folder: FolderTreeNode, depth: number = 0) => (
<div
key={folder.id}
className="flex flex-col"
style={{ marginLeft: depth > 0 ? "1.5rem" : "0" }}
>
<DroppableFolder
folder={folder}
setSelectedFolder={setSelectedFolder}
selectedFolder={selectedFolder}
selectedNote={selectedNote}
/>
<div className="flex flex-col gap-0.5 ml-6">
{folder.notes.map((note) => (
<DraggableNote
key={note.id}
note={note}
selectNote={selectNote}
selectedNote={selectedNote}
/>
))}
</div>
{folder.children.map((child) => renderFolder(child, depth + 1))}
</div>
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
@ -247,7 +225,17 @@ function Home() {
{/* Folder tree */}
<div className="flex flex-col gap-1">
{folderTree?.folders.map((folder) => renderFolder(folder))}
{folderTree?.folders.map((folder) => (
<RenderFolder
key={folder.id}
folder={folder}
depth={0}
setSelectedFolder={setSelectedFolder}
selectedFolder={selectedFolder}
selectedNote={selectedNote}
selectNote={selectNote}
/>
))}
</div>
{/* Orphaned notes */}
@ -291,9 +279,20 @@ function Home() {
markdown={content}
key={selectedNote?.id || "new"}
onChange={setContent}
className="prose prose-invert max-w-none text-ctp-text h-full"
className="prose prose-invert max-w-none text-ctp-text h-full dark-editor dark-mode"
plugins={[
headingsPlugin(),
toolbarPlugin({
toolbarClassName: "toolbar",
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<DiffSourceToggleWrapper />
</>
),
}),
tablePlugin(),
listsPlugin(),
quotePlugin(),
@ -307,12 +306,14 @@ function Home() {
css: "CSS",
python: "Python",
typescript: "TypeScript",
html: "HTML",
},
}),
imagePlugin(),
markdownShortcutPlugin(),
diffSourcePlugin({
viewMode: "rich-text",
diffMarkdown: "boo",
}),
]}
/>
@ -390,3 +391,65 @@ function Home() {
}
export default Home;
interface RenderFolderProps {
folder: FolderTreeNode;
depth?: number;
setSelectedFolder: React.Dispatch<SetStateAction<number | null>>;
selectedFolder: number | null;
selectedNote: NoteRead | null;
selectNote: (note: NoteRead) => void;
}
const RenderFolder = ({
folder,
depth = 0,
setSelectedFolder,
selectedFolder,
selectedNote,
selectNote,
}: RenderFolderProps) => {
const [collapse, setCollapse] = useState(false);
return (
<div
key={folder.id}
className="flex flex-col"
style={{ marginLeft: depth > 0 ? "1.5rem" : "0" }}
>
<DroppableFolder
folder={folder}
setSelectedFolder={setSelectedFolder}
selectedFolder={selectedFolder}
selectedNote={selectedNote}
setCollapse={setCollapse}
collapse={collapse}
/>
{collapse && (
<>
<div className="flex flex-col gap-0.5 ml-6">
{folder.notes.map((note) => (
<DraggableNote
key={note.id}
note={note}
selectNote={selectNote}
selectedNote={selectedNote}
/>
))}
</div>
{folder.children.map((child) => (
<RenderFolder
key={child.id}
folder={child}
depth={depth + 1}
setSelectedFolder={setSelectedFolder}
selectedFolder={selectedFolder}
selectedNote={selectedNote}
selectNote={selectNote}
/>
))}
</>
)}
</div>
);
};

View file

@ -0,0 +1,130 @@
import { useState } from "react";
import JSZip from "jszip";
import { folderApi } from "../api/folders";
import { notesApi } from "../api/notes";
export const Import = () => {
const [file, setFile] = useState<File | null>(null);
// Recursive function to create folders and notes
const createFromStructure = async (
structure: any,
parentFolderId: number | null = null,
): Promise<void> => {
for (const [name, item] of Object.entries(structure)) {
if (item.type === "folder") {
// Create the folder
const { data: newFolder } = await folderApi.create({
name: name,
parent_id: parentFolderId,
});
console.log(`Created folder: ${name} (id: ${newFolder.id})`);
// Recursively process children
if (item.children && Object.keys(item.children).length > 0) {
await createFromStructure(item.children, newFolder.id);
}
} else if (item.type === "file") {
// Parse the markdown file
const fileName = name.replace(".md", "");
const { title, content } = parseFrontmatter(item.content, fileName);
// Create the note
await notesApi.create({
title,
content,
folder_id: parentFolderId,
encrypted: false,
});
console.log(`Created note: ${title} in folder ${parentFolderId}`);
}
}
};
// Helper to parse frontmatter (if you used it in export)
const parseFrontmatter = (markdown: string, defaultTitle: string) => {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
const match = markdown.match(frontmatterRegex);
if (match) {
const frontmatter = match[1];
const content = match[2].trim();
const titleMatch = frontmatter.match(/title:\s*(.+)/);
const title = titleMatch ? titleMatch[1].trim() : defaultTitle;
return { title, content };
}
return { title: defaultTitle, content: markdown };
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
setFile(selectedFile);
try {
const zip = new JSZip();
const contents = await zip.loadAsync(selectedFile);
const structure: any = {};
console.log("Files in zip:");
// Build structure (your existing code)
for (const [relativePath, file] of Object.entries(contents.files)) {
if (relativePath.includes(".md")) {
const splitPath = relativePath.split("/");
let current = structure;
for (let i = 0; i < splitPath.length; i++) {
const part = splitPath[i];
if (i === splitPath.length - 1) {
if (part.includes(".md")) {
const content = await file.async("string");
current[part] = {
type: "file",
content: content,
};
}
} else {
if (!current[part]) {
current[part] = {
type: "folder",
children: {},
};
}
current = current[part].children;
}
}
}
}
console.log("Structure:", structure);
// Create folders and notes from structure
await createFromStructure(structure);
console.log("Import complete!");
// Optional: Show success message or redirect
alert("Import successful!");
} catch (error) {
console.error("Error processing zip:", error);
alert("Import failed: " + error.message);
}
};
return (
<>
{file && <div>Selected: {file.name}</div>}
<input
type="file"
accept=".zip"
onChange={handleFileChange} // Remove value prop
/>
</>
);
};