Huge refactor adding in react query and general clean up. not finsihed

yet. more cleanup needs to be done.

- added react query
- moved to openapi instead of axios
- added case translator from frontend to backend
This commit is contained in:
james fitzsimons 2025-12-22 15:23:40 +00:00
parent b6afaf8606
commit 3fe4b9ea88
26 changed files with 12413 additions and 10638 deletions

View file

@ -108,6 +108,8 @@ class TagUpdate(SQLModel):
class TagTreeNode(SQLModel):
id: int
name: str
parent_id: Optional[int] = None
created_at: datetime
children: List["TagTreeNode"] = []

View file

@ -1,12 +1,7 @@
from tkinter.constants import TOP
from app.auth import require_auth
from app.database import get_session
from app.models import (
Note,
NoteCreate,
NoteTag,
NoteUpdate,
Tag,
TagCreate,
TagTreeNode,
@ -19,7 +14,7 @@ from sqlmodel import Session, select
router = APIRouter(prefix="/tags", tags=["tags"])
@router.get("/")
@router.get("/", response_model=list[Tag])
def list_tags(session: Session = Depends(get_session)):
tags = session.exec(select(Tag)).all()
return tags
@ -44,12 +39,14 @@ def build_tag_tree_node(tag: Tag) -> TagTreeNode:
return TagTreeNode(
id= tag.id,
name = tag.name,
parent_id=tag.parent_id,
created_at=tag.created_at,
children = [build_tag_tree_node(child) for child in tag.children]
)
@router.get("/tree")
@router.get("/tree", response_model=TagTreeResponse)
def get_tag_tree(session: Session = Depends(get_session)):
top_level_tags = session.exec(
select(Tag)
@ -61,7 +58,7 @@ def get_tag_tree(session: Session = Depends(get_session)):
return TagTreeResponse(tags=tree)
@router.post("/note/{note_id}/tag/{tag_id}")
@router.post("/note/{note_id}/tag/{tag_id}", response_model=NoteTag)
def add_tag_to_note(
note_id: int,
tag_id: int,

20119
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,49 +1,57 @@
{
"name": "note-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@mdxeditor/editor": "^3.49.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@tiptap/extension-placeholder": "^3.12.1",
"@tiptap/react": "^3.12.1",
"@tiptap/starter-kit": "^3.12.1",
"axios": "^1.13.2",
"framer-motion": "^12.23.25",
"jszip": "^3.10.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.6",
"tailwindcss": "^4.1.17",
"tiptap-markdown": "^0.9.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@catppuccin/tailwindcss": "^1.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/ui": "^4.0.15",
"jsdom": "^27.3.0",
"vite": "^5.4.21",
"vite-plugin-svgr": "^4.5.0",
"vitest": "^4.0.15"
},
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
"name": "note-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"generate-types": "openapi-typescript http://localhost:8000/openapi.json -o src/types/api.d.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@mdxeditor/editor": "^3.49.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1",
"@tiptap/extension-placeholder": "^3.12.1",
"@tiptap/react": "^3.12.1",
"@tiptap/starter-kit": "^3.12.1",
"axios": "^1.13.2",
"framer-motion": "^12.23.25",
"humps": "^2.0.1",
"jszip": "^3.10.1",
"openapi-fetch": "^0.15.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.6",
"tailwindcss": "^4.1.17",
"tiptap-markdown": "^0.9.0",
"uuid": "^13.0.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@catppuccin/tailwindcss": "^1.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/humps": "^2.0.6",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/ui": "^4.0.15",
"jsdom": "^27.3.0",
"openapi-typescript": "^7.10.1",
"type-fest": "^5.3.1",
"typescript": "^5.9.3",
"vite": "^5.4.21",
"vite-plugin-svgr": "^4.5.0",
"vitest": "^4.0.15"
}
}

View file

@ -0,0 +1,72 @@
// frontend/src/api/client.ts
import createClient from "openapi-fetch";
import { camelizeKeys, decamelizeKeys } from "humps";
import type { paths } from "@/types/api";
const API_URL = import.meta.env.PROD ? "/api" : "http://localhost:8000/";
// Create the base client with full type safety
export const client = createClient<paths>({
baseUrl: API_URL,
credentials: "include",
});
// Add middleware to automatically transform requests and responses
client.use({
async onRequest({ request }) {
// Transform request body from camelCase to snake_case
if (request.body) {
try {
const bodyText = await request.text();
if (bodyText) {
const bodyJson = JSON.parse(bodyText);
const transformedBody = decamelizeKeys(bodyJson);
// Preserve headers and ensure Content-Type is set
const headers = new Headers(request.headers);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
return new Request(request.url, {
method: request.method,
headers: headers,
body: JSON.stringify(transformedBody),
credentials: request.credentials,
mode: request.mode,
cache: request.cache,
redirect: request.redirect,
referrer: request.referrer,
integrity: request.integrity,
});
}
} catch (e) {
// If not JSON, pass through unchanged
}
}
return request;
},
async onResponse({ response }) {
// Transform response body from snake_case to camelCase
if (response.body) {
try {
const clonedResponse = response.clone();
const json = await clonedResponse.json();
const transformedData = camelizeKeys(json);
return new Response(JSON.stringify(transformedData), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} catch (e) {
// If not JSON, return original response
return response;
}
}
return response;
},
});
export default client;

View file

@ -1,5 +1,23 @@
import { FolderTreeResponse, FolderTreeNode } from "./folders";
import { Tag } from "./tags";
import { components } from "@/types/api";
// encryption.tsx
import { CamelCasedPropertiesDeep } from "type-fest";
import { FolderTreeResponse } from "./folders";
export type Tag = CamelCasedPropertiesDeep<components["schemas"]["Tag"]>;
export type FolderTreeNode = CamelCasedPropertiesDeep<
components["schemas"]["FolderTreeNode"]
>;
export type TagTreeNode = CamelCasedPropertiesDeep<
components["schemas"]["TagTreeNode"]
>;
export interface DecryptedTagNode {
id?: number | null | undefined;
name: string;
parentId?: number | null;
createdAt?: string;
parentPath: string;
children: DecryptedTagNode[];
}
export async function deriveKey(password: string, salt: string) {
const enc = new TextEncoder();
@ -133,8 +151,8 @@ export async function decryptFolderTree(
folders: await Promise.all(
tree.folders.map((folder) => decryptFolder(folder)),
),
orphaned_notes: await Promise.all(
tree.orphaned_notes.map(async (note) => ({
orphanedNotes: await Promise.all(
tree.orphanedNotes.map(async (note) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
@ -150,10 +168,10 @@ export async function decryptFolderTree(
}
export const decryptTagTree = async (
tags: Tag[],
tags: TagTreeNode[],
key: CryptoKey,
parentPath = "",
): Promise<Tag[]> => {
): Promise<DecryptedTagNode[]> => {
return Promise.all(
tags.map(async (tag) => {
const decryptedName = await decryptString(tag.name, key);
@ -164,7 +182,7 @@ export const decryptTagTree = async (
return {
...tag,
name: decryptedName,
parent_path: parentPath,
parentPath: parentPath,
children: await decryptTagTree(tag.children, key, currentPath),
};
}),

View file

@ -1,62 +1,34 @@
import axios from "axios";
import { decryptFolderTree } from "./encryption";
import { useAuthStore } from "../stores/authStore";
import { Tag } from "./tags";
import { CamelCasedPropertiesDeep } from "type-fest";
import { components } from "@/types/api";
import client from "./client";
axios.defaults.withCredentials = true;
export type Folder = CamelCasedPropertiesDeep<components["schemas"]["Folder"]>;
const API_URL = (import.meta as any).env.PROD
? "/api"
: "http://localhost:8000/api";
export type FolderTreeNode = CamelCasedPropertiesDeep<
components["schemas"]["FolderTreeNode"]
>;
export interface Folder {
id: number;
name: string;
parent_id: number | null;
created_at: string;
}
export interface NoteRead {
id: number;
title: string;
content: string;
folder_id: number | null;
created_at: string;
updated_at: string;
tags: Tag[];
}
export interface FolderTreeNode {
id: number;
name: string;
notes: NoteRead[];
children: FolderTreeNode[];
}
export interface FolderTreeResponse {
folders: FolderTreeNode[];
orphaned_notes: NoteRead[];
}
export interface FolderCreate {
name: string;
parent_id: number | null;
}
export interface FolderUpdate {
name?: string;
parent_id?: number | null;
}
export type FolderTreeResponse = CamelCasedPropertiesDeep<
components["schemas"]["FolderTreeResponse"]
>;
export type FolderCreate = CamelCasedPropertiesDeep<
components["schemas"]["FolderCreate"]
>;
export type FolderUpdate = CamelCasedPropertiesDeep<
components["schemas"]["FolderUpdate"]
>;
const getFolderTree = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get<FolderTreeResponse>(
`${API_URL}/folders/tree`,
);
const { data, error } = await client.GET("/api/folders/tree", {});
const decryptedFolderTree = await decryptFolderTree(data, encryptionKey);
const newData = data as unknown as FolderTreeResponse;
const decryptedFolderTree = await decryptFolderTree(newData, encryptionKey);
return decryptedFolderTree;
};
@ -64,7 +36,10 @@ const getFolderTree = async () => {
const updateFolder = async (id: number, folder: FolderUpdate) => {
console.log(`Updating folder ${id} with:`, folder);
try {
const response = await axios.patch(`${API_URL}/folders/${id}`, folder);
const response = await client.PATCH("/api/folders/{folder_id}", {
params: { path: { folder_id: id } },
body: folder,
});
console.log(`Folder ${id} update response:`, response.data);
return response;
} catch (error) {
@ -75,10 +50,13 @@ const updateFolder = async (id: number, folder: FolderUpdate) => {
export const folderApi = {
tree: () => getFolderTree(),
list: () => axios.get<Folder[]>(`${API_URL}/folders`),
list: () => client.GET("/api/folders/", {}),
create: (folder: FolderCreate) =>
axios.post<Folder>(`${API_URL}/folders/`, folder),
delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`),
client.POST("/api/folders/", { body: folder }),
delete: (id: number) =>
client.DELETE("/api/folders/{folder_id}", {
params: { path: { folder_id: id } },
}),
update: (id: number, updateData: FolderUpdate) =>
updateFolder(id, updateData),
};

View file

@ -1,27 +1,15 @@
import axios from "axios";
import { encryptString, decryptString } from "./encryption";
import { useAuthStore } from "../stores/authStore";
import { Tag } from "./tags";
axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD
? "/api"
: "http://localhost:8000/api";
import { CamelCasedPropertiesDeep } from "type-fest";
import { components } from "@/types/api";
import client from "./client";
export interface Note {
id: number;
title: string;
folder_id?: number;
content: string;
created_at: string;
updated_at: string;
tags: Tag[];
}
export interface NoteCreate {
title: string;
content: string;
folder_id: number | null;
}
export type NoteRead = CamelCasedPropertiesDeep<
components["schemas"]["NoteRead"]
>;
export type NoteCreate = CamelCasedPropertiesDeep<
components["schemas"]["NoteCreate"]
>;
const createNote = async (note: NoteCreate) => {
const encryptionKey = useAuthStore.getState().encryptionKey;
@ -33,21 +21,21 @@ const createNote = async (note: NoteCreate) => {
var encryptedNote = {
title: noteTitle,
content: noteContent,
folder_id: note.folder_id,
folderId: note.folderId,
};
console.log(encryptedNote);
return axios.post(`${API_URL}/notes/`, encryptedNote);
return client.POST(`/api/notes/`, { body: encryptedNote });
};
const fetchNotes = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get(`${API_URL}/notes/`);
const { data } = await client.GET(`/api/notes/`);
console.log(data);
const decryptedNotes = await Promise.all(
data.map(async (note: Note) => ({
data.map(async (note: NoteRead) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
@ -62,28 +50,58 @@ const fetchNotes = async () => {
return decryptedNotes;
};
const updateNote = async (id: number, note: Partial<Note>) => {
const updateNote = async (id: number, note: Partial<NoteRead>) => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
var encryptedNote: Partial<Note> = {};
var encryptedNote: Partial<NoteRead> = {};
if (note.content) {
encryptedNote.content = await encryptString(note.content, encryptionKey);
}
if (note.title) {
encryptedNote.title = await encryptString(note.title, encryptionKey);
}
if (note.folder_id) {
encryptedNote.folder_id = note.folder_id;
if (note.folderId) {
encryptedNote.folderId = note.folderId;
}
// if (!note.folderId){
// throw new Error("Folder id missing from note.")
// }
const { data, error } = await client.PATCH(`/api/notes/{note_id}`, {
body: encryptedNote,
params: {
path: {
note_id: id,
},
},
});
return axios.patch(`${API_URL}/notes/${id}`, encryptedNote);
if (data) {
console.log(data);
}
if (error) {
console.log(error);
}
};
export const notesApi = {
list: () => fetchNotes(),
get: (id: number) => axios.get(`${API_URL}/notes/${id}`),
get: (id: number) =>
client.GET(`/api/notes/{note_id}`, {
params: {
path: {
note_id: id,
},
},
}),
create: (note: NoteCreate) => createNote(note),
update: (id: number, note: Partial<Note>) => updateNote(id, note),
delete: (id: number) => axios.delete(`${API_URL}/notes/${id}`),
update: (id: number, note: Partial<NoteRead>) => updateNote(id, note),
delete: (id: number) =>
client.DELETE(`/api/notes/{note_id}`, {
params: {
path: {
note_id: id,
},
},
}),
};

View file

@ -1,63 +1,90 @@
import axios from "axios";
import { client } from "./client";
import { components } from "@/types/api";
import { encryptString, decryptTagTree } from "./encryption";
import { useAuthStore } from "../stores/authStore";
axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD
? "/api"
: "http://localhost:8000/api";
import { CamelCasedPropertiesDeep } from "type-fest";
export interface Tag {
id: string;
name: string;
parent_id?: number;
created_at: string;
children: Tag[];
parent_path: string;
}
export type Tag = CamelCasedPropertiesDeep<components["schemas"]["Tag"]>;
export interface TagCreate {
name: string;
parent_id?: number;
}
export type TagTreeNode = CamelCasedPropertiesDeep<
components["schemas"]["TagTreeNode"]
>;
export type TagCreate = CamelCasedPropertiesDeep<
components["schemas"]["TagCreate"]
>;
export type TagRead = CamelCasedPropertiesDeep<
components["schemas"]["TagRead"]
>;
export type TagTreeResponse = CamelCasedPropertiesDeep<
components["schemas"]["TagTreeResponse"]
>;
const fetchTags = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get(`${API_URL}/tags/tree`);
const tags = decryptTagTree(data.tags, encryptionKey);
console.log(await tags);
const response = await client.GET("/api/tags/tree", {});
if (response.error) throw new Error("Failed to fetch tags");
if (!response.data) throw new Error("No data returned");
const data = response.data;
const tags = decryptTagTree(data.tags as any, encryptionKey);
return tags;
};
const createTag = async (tag: TagCreate, noteId?: number) => {
const createTag = async (tag: TagCreate): Promise<TagTreeNode> => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const tagName = await encryptString(tag.name, encryptionKey);
const encryptedTag = {
name: tagName,
parent_id: tag.parent_id,
};
const r = await axios.post(`${API_URL}/tags/`, encryptedTag);
console.log(r);
// Use the exact structure from TagCreate schema
const { data, error } = await client.POST("/api/tags/", {
body: {
name: tagName,
parentId: tag.parentId || null,
},
});
if (noteId) {
return await addTagToNote(r.data.id, noteId);
}
if (error) throw new Error("Failed to create tag");
console.log(data);
return data as unknown as TagTreeNode;
};
const addTagToNote = async (tagId: number, noteId: number) => {
return axios.post(`${API_URL}/tags/note/${noteId}/tag/${tagId}`);
const { data, error } = await client.POST(
"/api/tags/note/{note_id}/tag/{tag_id}",
{
params: {
path: {
note_id: noteId,
tag_id: tagId,
},
},
},
);
if (error) throw new Error("Failed to add tag to note");
return data;
};
const deleteTag = async (tagId: number) => {
return axios.delete(`${API_URL}/tags/${tagId}`);
const { error } = await client.DELETE("/api/tags/{tag_id}", {
params: {
path: {
tag_id: tagId,
},
},
});
if (error) throw new Error("Failed to delete tag");
};
export const tagsApi = {
list: async () => await fetchTags(),
create: (tag: TagCreate, noteId?: number) => createTag(tag, noteId),
delete: (tagId: number) => deleteTag(tagId),
list: fetchTags,
create: createTag,
addToNote: addTagToNote,
delete: deleteTag,
};

View file

@ -1,7 +1,10 @@
import React, { useState } from "react";
import { FolderTreeNode } from "../../api/folders";
import { useNoteStore } from "../../stores/notesStore";
import { folderApi } from "../../api/folders";
import {
useCreateFolder,
useUpdateFolder,
useDeleteFolder,
} from "../../hooks/useFolders";
interface FolderContextMenuProps {
x: number;
@ -16,7 +19,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
folder,
onClose,
}) => {
const { loadFolderTree, updateFolder } = useNoteStore();
const createFolderMutation = useCreateFolder();
const updateFolderMutation = useUpdateFolder();
const deleteFolderMutation = useDeleteFolder();
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState(folder.name);
@ -25,8 +31,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
return;
}
try {
await folderApi.delete(folder.id);
await loadFolderTree();
await deleteFolderMutation.mutateAsync(folder.id);
onClose();
} catch (error) {
console.error("Failed to delete folder:", error);
@ -35,7 +40,14 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
const handleRename = async () => {
if (newName.trim() && newName !== folder.name) {
await updateFolder(folder.id, { name: newName });
try {
await updateFolderMutation.mutateAsync({
folderId: folder.id,
folder: { name: newName },
});
} catch (error) {
console.error("Failed to rename folder:", error);
}
}
setIsRenaming(false);
onClose();
@ -43,11 +55,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
const handleCreateSubfolder = async () => {
try {
await folderApi.create({
await createFolderMutation.mutateAsync({
name: "New Folder",
parent_id: folder.id,
});
await loadFolderTree();
onClose();
} catch (error) {
console.error("Failed to create subfolder:", error);

View file

@ -1,12 +1,12 @@
import React from "react";
import { NoteRead } from "../../api/folders";
import { useNoteStore } from "../../stores/notesStore";
import { notesApi } from "../../api/notes";
import { Note } from "../../api/notes";
import { useCreateNote, useDeleteNote } from "../../hooks/useFolders";
import { useUIStore } from "../../stores/uiStore";
interface NoteContextMenuProps {
x: number;
y: number;
note: NoteRead;
note: Note;
onClose: () => void;
}
@ -16,12 +16,15 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
note,
onClose,
}) => {
const { loadFolderTree, setSelectedNote } = useNoteStore();
const { setSelectedNote } = useUIStore();
const deleteNoteMutation = useDeleteNote();
const createNoteMutation = useCreateNote();
const handleDelete = async () => {
try {
await notesApi.delete(note.id);
await loadFolderTree();
await deleteNoteMutation.mutateAsync(note.id);
// Clear selection if this note was selected
setSelectedNote(null);
onClose();
} catch (error) {
console.error("Failed to delete note:", error);
@ -30,12 +33,11 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
const handleDuplicate = async () => {
try {
await notesApi.create({
await createNoteMutation.mutateAsync({
title: `${note.title} (Copy)`,
content: note.content,
folder_id: note.folder_id,
folder_id: note.folder_id || null,
});
await loadFolderTree();
onClose();
} catch (error) {
console.error("Failed to duplicate note:", error);

View file

@ -0,0 +1,306 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
FolderCreate,
FolderTreeNode,
FolderTreeResponse,
FolderUpdate,
folderApi,
} from "@/api/folders";
import { NoteRead, NoteCreate, notesApi } from "@/api/notes";
import { useAuthStore } from "@/stores/authStore";
export const useFolderTree = () => {
const { encryptionKey } = useAuthStore();
return useQuery({
queryKey: ["folders", "tree"],
queryFn: folderApi.tree,
enabled: !!encryptionKey,
});
};
export const useCreateFolder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (folder: FolderCreate) => folderApi.create(folder),
onMutate: async (newFolder) => {
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
queryClient.setQueryData(
["folders", "tree"],
(old: FolderTreeResponse | undefined) => {
const prev = old || { folders: [], orphanedNotes: [] };
const tempFolder: FolderTreeNode = {
id: -Date.now(),
name: newFolder.name,
notes: [],
children: [],
};
if (!newFolder.parentId) {
return {
...prev,
folders: [...prev.folders, tempFolder],
};
}
const addToParent = (folders: FolderTreeNode[]): FolderTreeNode[] => {
return folders.map((folder) => {
if (folder.id === newFolder.parentId) {
return {
...folder,
children: [...folder.children, tempFolder],
};
}
return { ...folder, children: addToParent(folder.children) };
});
};
return { ...prev, folders: addToParent(prev.folders) };
},
);
return { previousFolderTree };
},
onError: (err, newFolder, context) => {
queryClient.setQueryData(
["folders", "tree"],
context?.previousFolderTree,
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
},
});
};
export const useUpdateFolder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
folderId,
folder,
}: {
folderId: number;
folder: FolderUpdate;
}) => folderApi.update(folderId, folder),
onMutate: async ({ folderId, folder }) => {
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
queryClient.setQueryData(
["folders", "tree"],
(old: FolderTreeResponse | undefined) => {
const prev = old || { folders: [], orphanedNotes: [] };
const updateInTree = (
folders: FolderTreeNode[],
): FolderTreeNode[] => {
return folders.map((f) => {
if (f.id == folderId) {
return {
...f,
...folder,
};
}
return {
...f,
children: updateInTree(f.children),
};
});
};
return { ...prev, folders: updateInTree(prev.folders) };
},
);
return { previousFolderTree };
},
onError: (err, variables, context) => {
queryClient.setQueryData(
["folders", "tree"],
context?.previousFolderTree,
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
},
});
};
export const useUpdateNote = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
noteId,
note,
}: {
noteId: number;
note: Partial<NoteRead>;
}) => notesApi.update(noteId, note),
onMutate: async ({ noteId, note }) => {
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
queryClient.setQueryData(
["folders", "tree"],
(old: FolderTreeResponse | undefined) => {
const prev = old || { folders: [], orphanedNotes: [] };
const updateNoteInTree = (
folders: FolderTreeNode[],
): FolderTreeNode[] => {
return folders.map((folder) => ({
...folder,
notes: folder.notes.map((n) =>
n.id === noteId ? { ...n, ...note } : n,
),
children: updateNoteInTree(folder.children),
}));
};
return {
folders: updateNoteInTree(prev.folders),
orphanedNotes: prev.orphanedNotes.map((n) =>
n.id === noteId ? { ...n, ...note } : n,
),
};
},
);
return { previousFolderTree };
},
onError: (err, variables, context) => {
queryClient.setQueryData(
["folders", "tree"],
context?.previousFolderTree,
);
console.log(err);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
},
});
};
export const useCreateNote = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (note: NoteCreate) => notesApi.create(note),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
},
});
};
export const useDeleteNote = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (noteId: number) => notesApi.delete(noteId),
onMutate: async (noteId) => {
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
queryClient.setQueryData(
["folders", "tree"],
(old: FolderTreeResponse | undefined) => {
const prev = old || { folders: [], orphanedNotes: [] };
const removeNoteFromTree = (
folders: FolderTreeNode[],
): FolderTreeNode[] => {
return folders.map((folder) => ({
...folder,
notes: folder.notes.filter((n) => n.id !== noteId),
children: removeNoteFromTree(folder.children),
}));
};
return {
folders: removeNoteFromTree(prev.folders),
orphanedNotes: prev.orphanedNotes.filter((n) => n.id !== noteId),
};
},
);
return { previousFolderTree };
},
onError: (err, variables, context) => {
queryClient.setQueryData(
["folders", "tree"],
context?.previousFolderTree,
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
},
});
};
export const useDeleteFolder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (folderId: number) => folderApi.delete(folderId),
onMutate: async (folderId) => {
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
queryClient.setQueryData(
["folders", "tree"],
(old: FolderTreeResponse | undefined) => {
const prev = old || { folders: [], orphanedNotes: [] };
const removeFolderFromTree = (
folders: FolderTreeNode[],
): FolderTreeNode[] => {
return folders
.filter((folder) => folder.id !== folderId)
.map((folder) => ({
...folder,
children: removeFolderFromTree(folder.children),
}));
};
return {
folders: removeFolderFromTree(prev.folders),
orphanedNotes: prev.orphanedNotes,
};
},
);
return { previousFolderTree };
},
onError: (err, variables, context) => {
queryClient.setQueryData(
["folders", "tree"],
context?.previousFolderTree,
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
},
});
};

View file

@ -0,0 +1,48 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Tag, TagCreate, tagsApi } from "@/api/tags";
import { useAuthStore } from "@/stores/authStore";
import { DecryptedTagNode } from "@/api/encryption";
export const useTagTree = () => {
const { encryptionKey } = useAuthStore();
return useQuery({
queryKey: ["tags", "tree"],
queryFn: tagsApi.list,
enabled: !!encryptionKey,
});
};
export const useCreateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tag: TagCreate) => tagsApi.create(tag),
onMutate: async (newTag) => {
await queryClient.cancelQueries({ queryKey: ["tags", "tree"] });
const previousTags = queryClient.getQueryData(["tags", "tree"]);
queryClient.setQueryData(["tags", "tree"], (old: Tag[] | undefined) => {
const tempTag: DecryptedTagNode = {
id: -Date.now(),
name: newTag.name,
parentId: newTag.parentId,
parentPath: "",
createdAt: new Date().toISOString(),
children: [],
};
return [...(old || []), tempTag];
});
return { previousTags };
},
onError: (err, newTag, context) => {
queryClient.setQueryData(["tags", "tree"], context?.previousTags);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags", "tree"] });
},
});
};

View file

@ -2,11 +2,16 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./main.css";
// import "./assets/fontawesome/js/fontawesome.min.js";
// import "./assets/fontawesome/js/duotone-regular.js";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>,
);

View file

@ -2,64 +2,65 @@ import { useEffect, useRef, useState } from "react";
import "../../main.css";
import { AnimatePresence, motion } from "framer-motion";
import { useAuthStore } from "@/stores/authStore";
import { useNoteStore } from "@/stores/notesStore";
import { useUIStore } from "@/stores/uiStore";
import { Login } from "../Login";
import { TiptapEditor } from "../TipTap";
import { Sidebar } from "./components/sidebar/SideBar";
import { StatusIndicator } from "./components/StatusIndicator";
import { Tag, tagsApi } from "@/api/tags";
import { useTagStore } from "@/stores/tagStore";
import { useCreateTag, useTagTree } from "@/hooks/useTags";
import { useFolderTree, useUpdateNote } from "@/hooks/useFolders";
import { Note } from "@/api/notes";
import { DecryptedTagNode } from "@/api/encryption";
function Home() {
const [newFolder] = useState(false);
// Local state for editing the current note
const [editingNote, setEditingNote] = useState<Note | null>(null);
const [lastSavedNote, setLastSavedNote] = useState<{
id: number;
title: string;
content: string;
} | null>(null);
const { loadFolderTree, updateNote, setContent, selectedNote, setTitle } =
useNoteStore();
const { encryptionKey } = useAuthStore();
const { showModal, setUpdating } = useUIStore();
const { showModal, setUpdating, selectedNote } = useUIStore();
const newFolderRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!encryptionKey) return;
loadFolderTree();
}, []);
const folderTree = useFolderTree();
const updateNoteMutation = useUpdateNote();
// Sync editingNote with selectedNote when selection changes
useEffect(() => {
if (selectedNote) {
setEditingNote(selectedNote);
setLastSavedNote({
id: selectedNote.id,
title: selectedNote.title,
content: selectedNote.content,
});
} else {
setEditingNote(null);
setLastSavedNote(null);
}
}, [selectedNote?.id]);
useEffect(() => {
if (newFolder && newFolderRef.current) {
newFolderRef.current.focus();
}
}, [newFolder]);
// Auto-save effect - watches editingNote for changes
useEffect(() => {
if (!selectedNote) return;
if (!encryptionKey) return; // Don't try to save without encryption key
if (!editingNote) return;
if (!encryptionKey) return;
// Check if content or title actually changed (not just selecting a different note)
// Check if content or title actually changed
const hasChanges =
lastSavedNote &&
lastSavedNote.id === selectedNote.id &&
(lastSavedNote.title !== selectedNote.title ||
lastSavedNote.content !== selectedNote.content);
// If it's a new note selection, just update lastSavedNote without saving
if (!lastSavedNote || lastSavedNote.id !== selectedNote.id) {
setLastSavedNote({
id: selectedNote.id,
title: selectedNote.title,
content: selectedNote.content,
});
return;
}
lastSavedNote.id === editingNote.id &&
(lastSavedNote.title !== editingNote.title ||
lastSavedNote.content !== editingNote.content);
if (!hasChanges) return;
@ -67,25 +68,30 @@ function Home() {
setUpdating(true);
await handleUpdate();
setLastSavedNote({
id: selectedNote.id,
title: selectedNote.title,
content: selectedNote.content,
id: editingNote.id,
title: editingNote.title,
content: editingNote.content,
});
}, 2000);
return () => clearTimeout(timer);
}, [selectedNote, encryptionKey]);
}, [editingNote?.title, editingNote?.content, encryptionKey]);
const handleUpdate = async () => {
if (!selectedNote) return;
if (!editingNote) return;
if (!encryptionKey) {
setUpdating(false);
return;
}
try {
await updateNote(selectedNote.id);
console.log(selectedNote.id);
await updateNoteMutation.mutateAsync({
noteId: editingNote.id,
note: {
title: editingNote.title,
content: editingNote.content,
},
});
} catch (error) {
console.error("Failed to update note:", error);
} finally {
@ -95,17 +101,24 @@ function Home() {
}
};
const { getTagTree, tagTree } = useTagStore();
const getTags = () => {
getTagTree();
const setTitle = (title: string) => {
if (editingNote) {
setEditingNote({ ...editingNote, title });
}
};
const setContent = (content: string) => {
if (editingNote) {
setEditingNote({ ...editingNote, content });
}
};
return (
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden">
{/* Sidebar */}
{showModal && <Modal />}
<Sidebar />
<button onClick={getTags}>create</button>
{/*<div className="flex flex-col">
<input
type="text"
@ -125,27 +138,27 @@ function Home() {
<input
type="text"
placeholder="Untitled note..."
value={selectedNote?.title || ""}
value={editingNote?.title || ""}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-3 text-3xl font-semibold bg-transparentfocus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text"
/>
<div className="px-4 py-2 border-b border-ctp-surface2 flex items-center gap-2 flex-wrap">
{selectedNote?.tags &&
selectedNote.tags.map((tag) => (
{editingNote?.tags &&
editingNote.tags.map((tag) => (
<button
onClick={() => null}
key={tag.id}
className="bg-ctp-surface0 px-1.5 text-sm rounded-full"
>
{tag.parent_id && "..."}
{tag.parentId && "..."}
{tag.name}
</button>
))}
</div>
<TiptapEditor
key={selectedNote?.id}
content={selectedNote?.content || ""}
key={editingNote?.id}
content={editingNote?.content || ""}
onChange={setContent}
/>
</div>
@ -170,29 +183,45 @@ const Modal = () => {
onClick={(e) => e.stopPropagation()}
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5"
>
{/*<Login />*/}
<TagSelector />
<Login />
{/*<TagSelector />*/}
</div>
</motion.div>
);
};
export const TagSelector = () => {
const { tagTree } = useTagStore();
const [value, setValue] = useState("");
const { data: tagTree, isLoading, error } = useTagTree();
const createTag = useCreateTag();
const handleEnter = async () => {
createTag.mutate({ name: value });
};
return (
<div>
{/*<input
<input
type="text"
value={value}
onKeyDown={(e) => {
if (e.key === "Enter") handleEnter();
}}
onChange={(e) => setValue(e.target.value)}
/>*/}
/>
{tagTree && tagTree.map((tag) => <TagTree tag={tag} />)}
</div>
);
};
export const TagTree = ({ tag, depth = 0 }: { tag: Tag; depth?: number }) => {
export const TagTree = ({
tag,
depth = 0,
}: {
tag: DecryptedTagNode;
depth?: number;
}) => {
const [collapse, setCollapse] = useState(false);
return (

View file

@ -19,9 +19,14 @@ import {
import { FolderTree } from "./subcomponents/FolderTree.tsx";
import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
import { useAuthStore } from "@/stores/authStore.ts";
import { useNoteStore } from "@/stores/notesStore.ts";
import { useUIStore } from "@/stores/uiStore.ts";
import { TagSelector } from "../../Home.tsx";
import {
useCreateFolder,
useFolderTree,
useUpdateFolder,
useUpdateNote,
} from "@/hooks/useFolders.ts";
export const Sidebar = () => {
const [newFolder, setNewFolder] = useState(false);
@ -32,13 +37,8 @@ export const Sidebar = () => {
} | null>(null);
const newFolderRef = useRef<HTMLInputElement>(null);
const {
folderTree,
loadFolderTree,
moveNoteToFolder,
moveFolderToFolder,
createFolder,
} = useNoteStore();
const { data: folderTree, isLoading, error } = useFolderTree();
const createFolder = useCreateFolder();
const { encryptionKey } = useAuthStore();
@ -52,17 +52,11 @@ export const Sidebar = () => {
useEffect(() => {
if (!encryptionKey) return;
loadFolderTree();
}, [encryptionKey]);
const handleCreateFolder = async () => {
if (!newFolderText.trim()) return;
await createFolder({
name: newFolderText,
parent_id: null,
});
setNewFolderText("");
setNewFolder(false);
createFolder.mutate({ name: newFolderText, parentId: null });
};
const pointer = useSensor(PointerSensor, {
@ -81,6 +75,9 @@ export const Sidebar = () => {
}
};
const updateNote = useUpdateNote();
const updateFolder = useUpdateFolder();
const handleDragEnd = async (event: DragEndEvent) => {
setActiveItem(null);
const { active, over } = event;
@ -95,8 +92,11 @@ export const Sidebar = () => {
});
if (active.data.current?.type === "note") {
console.log("Updating note", active.id, "to folder", over.id);
await moveNoteToFolder(active.id as number, over.id as number);
console.log("Updating note ", active.id, "to folder", over.id);
updateNote.mutate({
noteId: active.id as number,
note: { folderId: over.id as number },
});
} else if (active.data.current?.type === "folder") {
// Prevent dropping folder into itself
if (active.data.current.folder.id === over.id) {
@ -111,10 +111,10 @@ export const Sidebar = () => {
over.id,
);
try {
await moveFolderToFolder(
active.data.current.folder.id,
over.id as number,
);
updateFolder.mutate({
folderId: active.data.current.folder.id,
folder: { parentId: over.id as number },
});
} catch (error) {
console.error("Failed to update folder:", error);
return;
@ -163,83 +163,107 @@ export const Sidebar = () => {
autoScroll={false}
sensors={sensors}
>
<div className="flex-row-reverse flex">
<div className="flex-row-reverse flex h-screen">
<div
className="h-screen bg-ctp-surface0 w-0.5 hover:cursor-ew-resize hover:bg-ctp-mauve transition-colors"
className="h-full bg-ctp-surface0 w-0.5 hover:cursor-ew-resize hover:bg-ctp-mauve transition-colors"
onMouseDown={handleMouseDown}
></div>
<div
className="flex flex-col min-h-full"
className="flex flex-col h-full"
style={{ width: `${sideBarResize}px` }}
>
<SidebarHeader setNewFolder={setNewFolder} />
{sideBarView == "folders" ? (
<>
<div
className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3"
onDragOver={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()}
>
{/* 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="standard-input"
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) => (
<FolderTree key={folder.id} folder={folder} depth={0} />
))}
</div>
{/* Orphaned notes */}
{folderTree?.orphaned_notes &&
folderTree.orphaned_notes.length > 0 && (
<div className="mt-4 flex flex-col gap-1">
{folderTree.orphaned_notes.map((note) => (
<DraggableNote key={note.id} note={note} />
))}
<div className="flex-1 overflow-y-auto bg-ctp-mantle border-r border-ctp-surface2">
{sideBarView == "folders" ? (
<>
<div
className="w-full p-4 sm:block hidden"
onDragOver={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()}
>
{/* 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="standard-input"
ref={newFolderRef}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateFolder();
}
if (e.key === "Escape") {
setNewFolder(false);
}
}}
/>
</div>
)}
</div>
<DragOverlay>
{activeItem?.type === "note" && (
<div className="bg-ctp-surface0 rounded-md px-2 py-1 shadow-lg border border-ctp-mauve">
{activeItem.data.title}
</div>
)}
{activeItem?.type === "folder" && (
<div className="bg-ctp-surface0 rounded-md px-1 py-0.5 shadow-lg flex items-center gap-1 text-sm">
<FolderIcon className="w-3 h-3 fill-ctp-mauve mr-1" />
{activeItem.data.name}
</div>
)}
</DragOverlay>
</>
) : (
<div className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3">
<TagSelector />
</div>
)}
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-8 text-ctp-subtext0">
<div className="text-sm">Loading folders...</div>
</div>
)}
{/* Error state */}
{error && (
<div className="flex items-center justify-center py-8 text-ctp-red">
<div className="text-sm">Failed to load folders</div>
</div>
)}
{/* Folder tree */}
{!isLoading && !error && (
<>
<div className="flex flex-col gap-1">
{folderTree?.folders.map((folder) => (
<FolderTree
key={folder.id}
folder={folder}
depth={0}
/>
))}
</div>
{/* Orphaned notes */}
{folderTree?.orphanedNotes &&
folderTree.orphanedNotes.length > 0 && (
<div className="mt-4 flex flex-col gap-1">
{folderTree.orphanedNotes.map((note) => (
<DraggableNote key={note.id} note={note} />
))}
</div>
)}
</>
)}
</div>
<DragOverlay>
{activeItem?.type === "note" && (
<div className="bg-ctp-surface0 rounded-md px-2 py-1 shadow-lg border border-ctp-mauve">
{activeItem.data.title}
</div>
)}
{activeItem?.type === "folder" && (
<div className="bg-ctp-surface0 rounded-md px-1 py-0.5 shadow-lg flex items-center gap-1 text-sm">
<FolderIcon className="w-3 h-3 fill-ctp-mauve mr-1" />
{activeItem.data.name}
</div>
)}
</DragOverlay>
</>
) : (
<div className="w-full p-4 sm:block hidden">
<TagSelector />
</div>
)}
</div>
</div>
</div>
</DndContext>

View file

@ -1,10 +1,10 @@
import { useDraggable } from "@dnd-kit/core";
import { useContextMenu } from "@/contexts/ContextMenuContext";
import { useNoteStore } from "@/stores/notesStore";
import { NoteRead } from "@/api/folders";
import { useUIStore } from "@/stores/uiStore";
import { NoteRead } from "@/api/notes";
export const DraggableNote = ({ note }: { note: NoteRead }) => {
const { selectedNote, setSelectedNote } = useNoteStore();
const { selectedNote, setSelectedNote } = useUIStore();
const { openContextMenu } = useContextMenu();
const { attributes, listeners, setNodeRef, transform, isDragging } =

View file

@ -4,7 +4,7 @@ import { useDroppable, useDraggable } from "@dnd-kit/core";
import CaretRightIcon from "@/assets/fontawesome/svg/caret-right.svg?react";
// @ts-ignore
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
import { Folder } from "@/api/folders";
import { FolderTreeNode } from "@/api/folders";
import { useContextMenu } from "@/contexts/ContextMenuContext";
export const DroppableFolder = ({
@ -12,7 +12,7 @@ export const DroppableFolder = ({
setCollapse,
collapse,
}: {
folder: Partial<Folder>;
folder: Partial<FolderTreeNode>;
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
collapse: boolean;
}) => {
@ -63,9 +63,11 @@ export const DroppableFolder = ({
{...listeners}
{...attributes}
>
<CaretRightIcon
className={`w-4 h-4 min-h-4 min-w-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-ctp-mauve`}
/>
{(folder.notes?.length ?? 0) > 0 && (
<CaretRightIcon
className={`w-4 h-4 min-h-4 min-w-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-ctp-mauve`}
/>
)}
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-ctp-mauve mr-1" />
<span className="truncate">{folder.name}</span>
</div>

View file

@ -5,48 +5,51 @@ import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
import TagsIcon from "@assets/fontawesome/svg/tags.svg?react";
// @ts-ignore
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
import { useNoteStore } from "@/stores/notesStore";
import { useUIStore } from "@/stores/uiStore";
import { useCreateNote } from "@/hooks/useFolders";
import { NoteCreate } from "@/api/notes";
export const SidebarHeader = ({
setNewFolder,
}: {
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
}) => {
const { createNote, selectedFolder } = useNoteStore();
const { setSideBarView, sideBarView } = useUIStore();
const { setSideBarView, sideBarView, selectedFolder } = useUIStore();
const createNote = useCreateNote();
const handleCreate = async () => {
await createNote({
createNote.mutate({
title: "Untitled",
content: "",
folder_id: selectedFolder,
});
} as NoteCreate);
};
return (
<div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1">
<button
onClick={() => setNewFolder(true)}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1"
title="New folder"
>
<FolderPlusIcon className="w-5 h-5 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button>
<button
onClick={() =>
setSideBarView(sideBarView == "tags" ? "folders" : "tags")
}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1"
title="Tags"
>
<TagsIcon className="w-5 h-5 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button>
<button
onClick={handleCreate}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1 fill-ctp-mauve hover:fill-ctp-base"
title="New note"
>
<FileCirclePlusIcon className="w-5 h-5 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
</button>
<div className="w-full p-2 border-b border-ctp-surface2 bg-ctp-mantle">
<div className="flex items-center justify-around bg-ctp-surface0 rounded-lg p-0.5">
<button
onClick={() => setNewFolder(true)}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1"
title="New folder"
>
<FolderPlusIcon className="w-5 h-5 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button>
<button
onClick={() =>
setSideBarView(sideBarView == "tags" ? "folders" : "tags")
}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1"
title="Tags"
>
<TagsIcon className="w-5 h-5 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button>
<button
onClick={handleCreate}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1 fill-ctp-mauve hover:fill-ctp-base"
title="New note"
>
<FileCirclePlusIcon className="w-5 h-5 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
</button>
</div>
</div>
);
};

View file

@ -7,7 +7,6 @@ import {
unwrapMasterKey,
wrapMasterKey,
} from "../api/encryption";
import { FolderTree } from "@/pages/Home/components/sidebar/subcomponents/FolderTree";
interface User {
id: number;
@ -147,11 +146,6 @@ export const useAuthStore = create<AuthState>()(
});
localStorage.clear();
useNoteStore.setState({
folderTree: null,
selectedFolder: null,
selectedNote: null,
});
},
}),
{

View file

@ -1,352 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import {
folderApi,
FolderCreate,
FolderTreeNode,
FolderTreeResponse,
FolderUpdate,
NoteRead,
} from "../api/folders";
import { Note, NoteCreate, notesApi } from "../api/notes";
const updateNoteInTree = (
tree: FolderTreeResponse | null,
updatedNote: NoteRead,
): FolderTreeResponse | null => {
if (!tree) return null;
const updateNotesInFolder = (folder: FolderTreeNode): FolderTreeNode => ({
...folder,
notes: folder.notes.map((note) =>
note.id === updatedNote.id ? updatedNote : note,
),
children: folder.children.map(updateNotesInFolder),
});
return {
folders: tree.folders.map(updateNotesInFolder),
orphaned_notes: tree.orphaned_notes.map((note) =>
note.id === updatedNote.id ? updatedNote : note,
),
};
};
const updateFolder = (
id: number,
folder: FolderTreeNode,
newFolder: FolderUpdate,
): FolderTreeNode => {
if (folder.id === id) {
return { ...folder, ...newFolder };
}
if (folder.children) {
return {
...folder,
children: folder.children.map((child) =>
updateFolder(id, child, newFolder),
),
};
}
return folder;
};
interface NoteState {
loadFolderTree: () => Promise<void>;
folderTree: FolderTreeResponse | null;
selectedFolder: number | null;
setSelectedFolder: (id: number | null) => void;
selectedNote: NoteRead | null;
setSelectedNote: (id: NoteRead | null) => void;
setContent: (content: string) => void;
setTitle: (title: string) => void;
createNote: (note: NoteCreate) => Promise<void>;
updateNote: (id: number) => Promise<void>;
createFolder: (folder: FolderCreate) => Promise<void>;
updateFolder: (id: number, newFolder: FolderUpdate) => Promise<void>;
moveNoteToFolder: (noteId: number, folderId: number) => Promise<void>;
moveFolderToFolder: (folderId: number, newParentId: number) => Promise<void>;
}
export const useNoteStore = create<NoteState>()(
persist(
(set, get) => ({
loadFolderTree: async () => {
const data = await folderApi.tree();
// console.log(data);
set({ folderTree: data });
},
folderTree: null,
selectedFolder: null,
setSelectedFolder: (id: number | null) => {
set({ selectedFolder: id });
},
selectedNote: null,
setSelectedNote: (id: NoteRead | null) => {
set({ selectedNote: id });
},
setContent: (content) => {
const currentNote = get().selectedNote;
if (currentNote) {
const updatedNote = { ...currentNote, content: content };
set({
selectedNote: updatedNote,
folderTree: updateNoteInTree(get().folderTree, updatedNote),
});
}
},
setTitle: (title) => {
const currentNote = get().selectedNote;
if (currentNote) {
const updatedNote = { ...currentNote, title: title };
set({
selectedNote: updatedNote,
folderTree: updateNoteInTree(get().folderTree, updatedNote),
});
}
},
createNote: async (note: Partial<NoteRead>) => {
const response = await notesApi.create(note as NoteCreate);
const newNote = response.data as NoteRead;
console.log(newNote.id);
const noteToAppend: NoteRead = {
...newNote,
title: note.title || "Untitled",
content: note.content || "",
};
const tree = get().folderTree;
if (!tree) return;
if (note.folder_id) {
const addNoteToFolder = (folder: FolderTreeNode): FolderTreeNode => {
if (folder.id === note.folder_id) {
return {
...folder,
notes: [...folder.notes, noteToAppend],
children: folder.children.map(addNoteToFolder),
};
}
return {
...folder,
children: folder.children.map(addNoteToFolder),
};
};
set({
folderTree: {
folders: tree.folders.map(addNoteToFolder),
orphaned_notes: tree.orphaned_notes,
},
});
} else {
// Add to orphaned notes
set({
folderTree: {
folders: tree.folders,
orphaned_notes: [...tree.orphaned_notes, noteToAppend],
},
});
}
},
updateNote: async (id: number) => {
const note = get().selectedNote as Partial<Note>;
await notesApi.update(id, note);
},
createFolder: async (folder: FolderCreate) => {
const response = await folderApi.create(folder);
const newFolder = response.data;
const tree = get().folderTree;
if (!tree) return;
const newFolderNode: FolderTreeNode = {
id: newFolder.id,
name: newFolder.name,
notes: [],
children: [],
};
if (folder.parent_id) {
// Add as child of parent folder
const addToParent = (f: FolderTreeNode): FolderTreeNode => {
if (f.id === folder.parent_id) {
return {
...f,
children: [...f.children, newFolderNode],
};
}
return {
...f,
children: f.children.map(addToParent),
};
};
set({
folderTree: {
folders: tree.folders.map(addToParent),
orphaned_notes: tree.orphaned_notes,
},
});
} else {
// Add as top-level folder
set({
folderTree: {
folders: [...tree.folders, newFolderNode],
orphaned_notes: tree.orphaned_notes,
},
});
}
},
updateFolder: async (id: number, newFolder: FolderUpdate) => {
const tree = get().folderTree as FolderTreeResponse;
const newFolders = tree.folders.map((folder) =>
updateFolder(id, folder, newFolder),
);
set({
folderTree: {
folders: newFolders,
orphaned_notes: tree.orphaned_notes,
},
});
await folderApi.update(id, newFolder);
},
moveNoteToFolder: async (noteId: number, folderId: number) => {
const tree = get().folderTree;
if (!tree) return;
// Find and remove the note from its current location
let noteToMove: NoteRead | null = null;
// Check orphaned notes
const orphanedIndex = tree.orphaned_notes.findIndex(
(n) => n.id === noteId,
);
if (orphanedIndex !== -1) {
noteToMove = tree.orphaned_notes[orphanedIndex];
}
// Check folders recursively
const findAndRemoveNote = (folder: FolderTreeNode): FolderTreeNode => {
const noteIndex = folder.notes.findIndex((n) => n.id === noteId);
if (noteIndex !== -1) {
noteToMove = folder.notes[noteIndex];
return {
...folder,
notes: folder.notes.filter((n) => n.id !== noteId),
children: folder.children.map(findAndRemoveNote),
};
}
return {
...folder,
children: folder.children.map(findAndRemoveNote),
};
};
// Add note to target folder
const addNoteToFolder = (folder: FolderTreeNode): FolderTreeNode => {
if (folder.id === folderId && noteToMove) {
return {
...folder,
notes: [...folder.notes, { ...noteToMove, folder_id: folderId }],
children: folder.children.map(addNoteToFolder),
};
}
return {
...folder,
children: folder.children.map(addNoteToFolder),
};
};
// Update local tree
let newFolders = tree.folders.map(findAndRemoveNote);
let newOrphaned = tree.orphaned_notes.filter((n) => n.id !== noteId);
newFolders = newFolders.map(addNoteToFolder);
set({
folderTree: {
folders: newFolders,
orphaned_notes: newOrphaned,
},
});
// Update backend
await notesApi.update(noteId, { folder_id: folderId });
},
moveFolderToFolder: async (folderId: number, newParentId: number) => {
const tree = get().folderTree;
if (!tree) return;
let folderToMove: FolderTreeNode | null = null;
const findAndRemoveFolder = (
folders: FolderTreeNode[],
): FolderTreeNode[] => {
return folders
.filter((f) => {
if (f.id === folderId) {
folderToMove = f;
return false;
}
return true;
})
.map((f) => ({
...f,
children: findAndRemoveFolder(f.children),
}));
};
const addFolderToParent = (
folders: FolderTreeNode[],
): FolderTreeNode[] => {
return folders.map((f) => {
if (f.id === newParentId && folderToMove) {
return {
...f,
children: [...f.children, folderToMove],
};
}
return {
...f,
children: addFolderToParent(f.children),
};
});
};
let newFolders = findAndRemoveFolder(tree.folders);
newFolders = addFolderToParent(newFolders);
set({
folderTree: {
folders: newFolders,
orphaned_notes: tree.orphaned_notes,
},
});
await folderApi.update(folderId, { parent_id: newParentId });
},
}),
{
name: "notes-storage",
partialize: (state) => ({
folderTree: state.folderTree,
}),
},
),
);

View file

@ -1,36 +0,0 @@
import { tagsApi } from "@/api/tags";
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface Tag {
id: string;
name: string;
parent_id?: number;
created_at: string;
parent_path: string;
children: Tag[];
}
interface TagStore {
tagTree: Tag[] | null;
getTagTree: () => void;
}
export const useTagStore = create<TagStore>()(
persist(
(set, get) => ({
tagTree: null,
getTagTree: async () => {
const tags = await tagsApi.list();
set({ tagTree: tags });
},
}),
{
name: "tags-storage",
partialize: (state) => ({
tagTree: state.tagTree,
}),
},
),
);

View file

@ -1,3 +1,4 @@
import { Note } from "@/api/notes";
import { create } from "zustand";
import { persist } from "zustand/middleware";
@ -13,6 +14,12 @@ interface UIState {
sideBarView: string;
setSideBarView: (view: string) => void;
selectedNote: Note | null;
setSelectedNote: (note: Note | null) => void;
selectedFolder: number | null;
setSelectedFolder: (id: number | null) => void;
}
export const useUIStore = create<UIState>()(
@ -34,7 +41,18 @@ export const useUIStore = create<UIState>()(
setSideBarView: (view) => {
set({ sideBarView: view });
},
selectedNote: null,
setSelectedNote: (id: Note | null) => {
set({ selectedNote: id });
},
selectedFolder: null,
setSelectedFolder: (id: number | null) => {
set({ selectedFolder: id });
},
}),
{
name: "ui-store",
partialize: (state) => {

1192
frontend/src/types/api.d.ts vendored Normal file

File diff suppressed because it is too large Load diff

14
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly PROD: boolean;
readonly DEV: boolean;
readonly MODE: string;
readonly BASE_URL: string;
readonly SSR: boolean;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -2,7 +2,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import svgr from "vite-plugin-svgr";
import path from "path";
import * as path from "path";
export default defineConfig({
plugins: [tailwindcss(), react(), svgr()],