Introduce zustand store for notes

- Migrate Home to use store actions for creating notes and folders -
Remove encrypted flag from NoteCreate interface - Add zustand to
frontend dependencies and update package-lock Add Zustand store for
notes
This commit is contained in:
james fitzsimons 2025-11-29 12:45:41 +00:00
parent 45dcbdaee9
commit 16313b961b
7 changed files with 223 additions and 31 deletions

Binary file not shown.

View file

@ -19,7 +19,8 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.6",
"tailwindcss": "^4.1.17"
"tailwindcss": "^4.1.17",
"zustand": "^5.0.8"
},
"devDependencies": {
"@catppuccin/tailwindcss": "^1.0.0",
@ -61,6 +62,7 @@
"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,6 +639,7 @@
"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",
@ -726,6 +729,7 @@
"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"
}
@ -735,6 +739,7 @@
"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",
@ -1271,6 +1276,7 @@
"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"
},
@ -1635,6 +1641,7 @@
"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"
}
@ -3156,6 +3163,7 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@ -3580,6 +3588,7 @@
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -3590,6 +3599,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -3626,6 +3636,7 @@
"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"
},
@ -3733,6 +3744,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@ -4730,7 +4742,6 @@
"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"
@ -4828,7 +4839,6 @@
"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"
},
@ -6400,6 +6410,7 @@
"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"
},
@ -6421,6 +6432,7 @@
"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"
@ -6783,7 +6795,8 @@
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@ -7004,6 +7017,7 @@
"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",
@ -7104,6 +7118,35 @@
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View file

@ -19,7 +19,8 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.6",
"tailwindcss": "^4.1.17"
"tailwindcss": "^4.1.17",
"zustand": "^5.0.8"
},
"devDependencies": {
"@catppuccin/tailwindcss": "^1.0.0",

View file

@ -17,7 +17,6 @@ export interface NoteCreate {
title: string;
content: string;
folder_id: number | null;
encrypted: boolean;
}
const createNote = async (note: NoteCreate) => {

View file

@ -0,0 +1,125 @@
import { useState, useRef, useEffect } from "react";
import { FolderCreate, FolderTreeResponse, folderApi } from "../../api/folders";
import { DraggableNote } from "./DraggableNote";
export const Sidebar = () => {
const [folderTree, setFolderTree] = useState<FolderTreeResponse | null>(null);
const [newFolder, setNewFolder] = useState(false);
const [newFolderText, setNewFolderText] = useState("");
const newFolderRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (newFolder && newFolderRef.current) {
newFolderRef.current.focus();
}
}, [newFolder]);
useEffect(() => {
loadFolderTree();
}, []);
const handleCreateFolder = async () => {
if (!newFolderText.trim()) return;
const newFolderData: FolderCreate = {
name: newFolderText,
parent_id: null,
};
await folderApi.create(newFolderData);
setNewFolderText("");
loadFolderTree();
setNewFolder(false);
};
const loadFolderTree = async () => {
const data = await folderApi.tree();
setFolderTree(data);
};
return (
<div
className="bg-ctp-mantle border-r border-ctp-surface2 w-[300px] p-4 overflow-y-auto sm:block hidden flex-col gap-3"
onDragOver={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()}
>
<SidebarHeader />
{/* New folder input */}
{newFolder && (
<div className="mb-2">
<input
onBlur={() => setNewFolder(false)}
onChange={(e) => setNewFolderText(e.target.value)}
value={newFolderText}
type="text"
placeholder="Folder name..."
className="border border-ctp-mauve rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-ctp-mauve bg-ctp-base text-ctp-text placeholder:text-ctp-overlay0"
ref={newFolderRef}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateFolder();
}
if (e.key === "Escape") {
setNewFolder(false);
}
}}
/>
</div>
)}
{/* Folder tree */}
<div className="flex flex-col gap-1">
{folderTree?.folders.map((folder) => (
<RenderFolder
key={folder.id}
folder={folder}
depth={0}
setSelectedFolder={setSelectedFolder}
selectedFolder={selectedFolder}
selectedNote={selectedNote}
selectNote={selectNote}
/>
))}
</div>
{/* Orphaned notes */}
{folderTree?.orphaned_notes && folderTree.orphaned_notes.length > 0 && (
<div className="mt-4 flex flex-col gap-1">
{/*<div className="text-ctp-subtext0 text-sm font-medium mb-1 px-2">
Unsorted
</div>*/}
{folderTree.orphaned_notes.map((note) => (
<DraggableNote
key={note.id}
note={note}
selectNote={selectNote}
selectedNote={selectedNote}
/>
))}
</div>
)}
</div>
);
};
export const SidebarHeader = () => {
return (
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-ctp-text">FastNotes</h2>
<div className="flex gap-2">
<button
onClick={() => setNewFolder(true)}
className="hover:bg-ctp-mauve group transition-colors rounded-md p-1.5"
title="New folder"
>
<i className="fadr fa-folder-plus text-base text-ctp-mauve group-hover:text-ctp-base transition-colors"></i>
</button>
<button
onClick={clearSelection}
className="hover:bg-ctp-mauve group transition-colors rounded-md p-1.5"
title="New note"
>
<i className="fadr fa-file-circle-plus text-base text-ctp-mauve group-hover:text-ctp-base transition-colors"></i>
</button>
</div>
</div>
);
};

View file

@ -41,6 +41,8 @@ import { DroppableFolder } from "../components/sidebar/DroppableFolder";
import { DraggableNote } from "../components/sidebar/DraggableNote";
import CheckIcon from "../assets/fontawesome/svg/circle-check.svg?react";
import SpinnerIcon from "../assets/fontawesome/svg/rotate.svg?react";
import { useNoteStore } from "../stores/notesStore";
import { create } from "zustand";
const simpleSandpackConfig: SandpackConfig = {
defaultPreset: "react",
@ -58,7 +60,7 @@ const simpleSandpackConfig: SandpackConfig = {
};
function Home() {
const [folderTree, setFolderTree] = useState<FolderTreeResponse | null>(null);
// const [folderTree, setFolderTree] = useState<FolderTreeResponse | null>(null);
const [selectedNote, setSelectedNote] = useState<NoteRead | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
@ -68,6 +70,9 @@ function Home() {
const [encrypted, setEncrypted] = useState(false);
const [updating, setUpdating] = useState(false);
const { folderTree, loadFolderTree, createNote, createFolder, updateNote } =
useNoteStore();
const pointer = useSensor(PointerSensor, {
activationConstraint: {
distance: 30,
@ -98,41 +103,21 @@ function Home() {
return () => clearTimeout(timer);
}, [content, title]);
const loadFolderTree = async () => {
const data = await folderApi.tree();
setFolderTree(data);
};
const handleCreate = async () => {
if (!title.trim()) return;
const newNote: NoteCreate = {
title,
content,
folder_id: selectedFolder,
encrypted,
};
await notesApi.create(newNote);
setTitle("");
setContent("");
loadFolderTree();
await createNote({ title, content, folder_id: null });
};
const handleCreateFolder = async () => {
if (!newFolderText.trim()) return;
const newFolderData: FolderCreate = {
name: newFolderText,
parent_id: null,
};
await folderApi.create(newFolderData);
setNewFolderText("");
loadFolderTree();
setNewFolder(false);
await createFolder({ name: newFolderText, parent_id: null });
};
const handleUpdate = async () => {
if (!selectedNote) return;
await notesApi.update(selectedNote.id, { title, content });
loadFolderTree();
await updateNote(selectedNote.id, { title, content });
setTimeout(() => {
setUpdating(false);
}, 1000);

View file

@ -0,0 +1,39 @@
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { folderApi, FolderCreate, FolderTreeResponse } from "../api/folders";
import { Note, NoteCreate, notesApi } from "../api/notes";
interface NoteState {
folderTree: FolderTreeResponse | null;
selectedFolder: number | null;
loadFolderTree: () => Promise<void>;
createNote: (note: NoteCreate) => Promise<void>;
createFolder: (folder: FolderCreate) => Promise<void>;
updateNote: (id: number, note: Partial<Note>) => Promise<void>;
}
export const useNoteStore = create<NoteState>()((set, get) => ({
folderTree: null,
selectedFolder: null,
loadFolderTree: async () => {
const data = await folderApi.tree();
set({ folderTree: data });
},
createNote: async (note: NoteCreate) => {
await notesApi.create(note);
await get().loadFolderTree();
},
createFolder: async (folder: FolderCreate) => {
await folderApi.create(folder);
await get().loadFolderTree();
},
updateNote: async (id: number, note: Partial<Note>) => {
await notesApi.update(id, note);
await get().loadFolderTree();
},
}));