diff --git a/.gitignore b/.gitignore index 58926dc..aef0a45 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ frontend/src/assets/fontawesome/svg/* frontend/src/assets/fontawesome/svg/0.svg *.db .zed/settings.json +**.pyc diff --git a/frontend/src/api/tags.tsx b/frontend/src/api/tags.tsx index 7e5fb39..7dddf64 100644 --- a/frontend/src/api/tags.tsx +++ b/frontend/src/api/tags.tsx @@ -49,7 +49,6 @@ const createTag = async (tag: TagCreate): Promise => { }); if (error) throw new Error("Failed to create tag"); - console.log(data); return data as unknown as TagTreeNode; }; diff --git a/frontend/src/hooks/useTags.ts b/frontend/src/hooks/useTags.ts index 8e98c0f..64bbc46 100644 --- a/frontend/src/hooks/useTags.ts +++ b/frontend/src/hooks/useTags.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Tag, TagCreate, tagsApi } from "@/api/tags"; +import { Tag, TagCreate, TagRead, tagsApi } from "@/api/tags"; import { useAuthStore } from "@/stores/authStore"; import { DecryptedTagNode } from "@/api/encryption"; @@ -46,3 +46,17 @@ export const useCreateTag = () => { }, }); }; + +export const useAddTagToNote = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ tagId, noteId }: { tagId: number; noteId: number }) => + tagsApi.addToNote(tagId, noteId), + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tags", "tree"] }); + queryClient.invalidateQueries({ queryKey: ["folders", "tree"] }); + }, + }); +}; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index d47cade..46a25d5 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -7,7 +7,7 @@ import { Login } from "../Login"; import { TiptapEditor } from "../TipTap"; import { Sidebar } from "./components/sidebar/SideBar"; import { StatusIndicator } from "./components/StatusIndicator"; -import { useCreateTag, useTagTree } from "@/hooks/useTags"; +import { useAddTagToNote, useCreateTag, useTagTree } from "@/hooks/useTags"; import { useFolderTree, useUpdateNote } from "@/hooks/useFolders"; import { Note, NoteRead } from "@/api/notes"; import { DecryptedTagNode } from "@/api/encryption"; @@ -138,8 +138,11 @@ function Home() { onChange={(e) => setTitle(e.target.value)} className="w-full p-4 pb-0 text-3xl font-semibold bg-transparent focus:outline-none border-transparent focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text" /> - - {/*
+ +
{editingNote?.tags && editingNote.tags.map((tag) => (
*/} +
{ ); }; -export const TagSelector = () => { +export const TagSelector = ({ + editingNote, + setEditingNote, +}: { + editingNote: NoteRead | null; + setEditingNote: (note: NoteRead | null) => void; +}) => { const [value, setValue] = useState(""); const containerRef = useRef(null); const inputRef = useRef(null); const { data: tagTree, isLoading, error } = useTagTree(); const createTag = useCreateTag(); + const addTagToNote = useAddTagToNote(); const [expanded, setExpanded] = useState(false); + // Parse path from input (using > as separator) + const parsedPath = value.includes(">") + ? value + .split(">") + .map((part) => part.trim()) + .filter(Boolean) + : null; + + // Filter existing tags based on search + const filteredTags = tagTree + ? tagTree.filter((tag) => { + if (value === "") return false; + // Don't show filtered tags if user is typing a path + if (parsedPath) return false; + return (tag.name + tag.parentPath) + .toLowerCase() + .includes(value.toLowerCase()); + }) + : []; + // Close when clicking outside the entire component useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -234,13 +264,63 @@ export const TagSelector = () => { } }, [expanded]); - const handleEnter = async () => { - createTag.mutate({ name: value }); + const handleEnter = () => { + createTag.mutate( + { name: value }, + { + onSuccess: (createdTag) => { + if (editingNote && createdTag.id) { + addTagToNote.mutate( + { + tagId: createdTag.id, + noteId: editingNote.id, + }, + { + onSuccess: () => { + setEditingNote({ + ...editingNote, + tags: [ + ...(editingNote.tags || []), + { ...createdTag, name: value }, + ], + }); + }, + }, + ); + } + }, + }, + ); }; + + const handleSelectExistingTag = (tag: DecryptedTagNode) => { + if (editingNote && tag.id) { + addTagToNote.mutate( + { + tagId: tag.id, + noteId: editingNote.id, + }, + { + onSuccess: () => { + setEditingNote({ + ...editingNote, + tags: [ + ...(editingNote.tags || []), + { id: tag.id, name: tag.name, parentId: tag.parentId }, + ], + }); + setValue(""); + setExpanded(false); + }, + }, + ); + } + }; + function handleClose() { - // If there’s a value, submit it; otherwise just collapse + // If there's a value, submit it; otherwise just collapse if (value.trim()) { - // onsubmit?.(value.trim()); + handleEnter(); } setValue(""); setExpanded(false); @@ -287,14 +367,102 @@ export const TagSelector = () => { /> -
- {tagTree && - tagTree - .filter((tag) => - value == "" ? false : (tag.name + tag.parentPath).includes(value), - ) - .map((tag) => )} -
+ {/* Dropdown */} + {expanded && value && ( +
+ {/* Show hierarchical preview if path is being typed */} + {parsedPath && parsedPath.length > 0 && ( +
+
+ Create tag hierarchy: +
+
+ {parsedPath.map((part, index) => ( +
+ {index > 0 && ( + └─ + )} + + {part} + +
+ ))} +
+
+ Press Enter to create +
+
+ )} + + {/* Show filtered existing tags */} + {!parsedPath && filteredTags.length > 0 && ( +
+ {filteredTags.map((tag) => ( + + ))} +
+ )} + + {/* Show "create new" option for simple tags */} + {!parsedPath && filteredTags.length === 0 && ( +
+ Press Enter to create "{value}" +
+ )} +
+ )} + + ); +}; + +// Clickable version of TagTree for selection +export const TagTreeClickable = ({ + tag, + depth = 0, + onSelect, +}: { + tag: DecryptedTagNode; + depth?: number; + onSelect: (tag: DecryptedTagNode) => void; +}) => { + const [collapse, setCollapse] = useState(false); + + return ( +
+ + + {/* Show children */} + {tag.children && tag.children.length > 0 && ( +
+ {tag.children.map((child) => ( + + ))} +
+ )}
); };