Outstanding tag work ready to be moved to feature branch

This commit is contained in:
Jamitz 2025-12-30 22:11:11 +00:00
parent 40f5a3d794
commit a62e2d744d
4 changed files with 201 additions and 19 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ frontend/src/assets/fontawesome/svg/*
frontend/src/assets/fontawesome/svg/0.svg frontend/src/assets/fontawesome/svg/0.svg
*.db *.db
.zed/settings.json .zed/settings.json
**.pyc

View file

@ -49,7 +49,6 @@ const createTag = async (tag: TagCreate): Promise<TagTreeNode> => {
}); });
if (error) throw new Error("Failed to create tag"); if (error) throw new Error("Failed to create tag");
console.log(data);
return data as unknown as TagTreeNode; return data as unknown as TagTreeNode;
}; };

View file

@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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 { useAuthStore } from "@/stores/authStore";
import { DecryptedTagNode } from "@/api/encryption"; 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"] });
},
});
};

View file

@ -7,7 +7,7 @@ import { Login } from "../Login";
import { TiptapEditor } from "../TipTap"; import { TiptapEditor } from "../TipTap";
import { Sidebar } from "./components/sidebar/SideBar"; import { Sidebar } from "./components/sidebar/SideBar";
import { StatusIndicator } from "./components/StatusIndicator"; 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 { useFolderTree, useUpdateNote } from "@/hooks/useFolders";
import { Note, NoteRead } from "@/api/notes"; import { Note, NoteRead } from "@/api/notes";
import { DecryptedTagNode } from "@/api/encryption"; import { DecryptedTagNode } from "@/api/encryption";
@ -138,8 +138,11 @@ function Home() {
onChange={(e) => setTitle(e.target.value)} 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" 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"
/> />
<TagSelector /> <TagSelector
{/*<div className="px-4 py-2 border-ctp-surface2 flex items-center gap-2 flex-wrap"> editingNote={editingNote}
setEditingNote={setEditingNote}
/>
<div className="px-4 py-2 border-ctp-surface2 flex items-center gap-2 flex-wrap">
{editingNote?.tags && {editingNote?.tags &&
editingNote.tags.map((tag) => ( editingNote.tags.map((tag) => (
<button <button
@ -151,7 +154,7 @@ function Home() {
{tag.name} {tag.name}
</button> </button>
))} ))}
</div>*/} </div>
<TiptapEditor <TiptapEditor
key={editingNote?.id} key={editingNote?.id}
@ -200,15 +203,42 @@ const Modal = () => {
); );
}; };
export const TagSelector = () => { export const TagSelector = ({
editingNote,
setEditingNote,
}: {
editingNote: NoteRead | null;
setEditingNote: (note: NoteRead | null) => void;
}) => {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const { data: tagTree, isLoading, error } = useTagTree(); const { data: tagTree, isLoading, error } = useTagTree();
const createTag = useCreateTag(); const createTag = useCreateTag();
const addTagToNote = useAddTagToNote();
const [expanded, setExpanded] = useState(false); 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 // Close when clicking outside the entire component
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -234,13 +264,63 @@ export const TagSelector = () => {
} }
}, [expanded]); }, [expanded]);
const handleEnter = async () => { const handleEnter = () => {
createTag.mutate({ name: value }); 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() { function handleClose() {
// If theres a value, submit it; otherwise just collapse // If there's a value, submit it; otherwise just collapse
if (value.trim()) { if (value.trim()) {
// onsubmit?.(value.trim()); handleEnter();
} }
setValue(""); setValue("");
setExpanded(false); setExpanded(false);
@ -287,14 +367,102 @@ export const TagSelector = () => {
/> />
</div> </div>
<div className="absolute bg-ctp-base z-10"> {/* Dropdown */}
{tagTree && {expanded && value && (
tagTree <div className="absolute top-full left-0 mt-1 bg-ctp-surface0 rounded-lg shadow-lg border border-ctp-surface2 overflow-hidden min-w-[200px] max-w-[300px] z-20">
.filter((tag) => {/* Show hierarchical preview if path is being typed */}
value == "" ? false : (tag.name + tag.parentPath).includes(value), {parsedPath && parsedPath.length > 0 && (
) <div className="p-3 border-b border-ctp-surface2">
.map((tag) => <TagTree tag={tag} />)} <div className="text-xs text-ctp-overlay1 mb-2">
Create tag hierarchy:
</div> </div>
<div className="flex flex-col gap-1">
{parsedPath.map((part, index) => (
<div
key={index}
className="flex items-center text-xs text-ctp-text"
style={{ paddingLeft: `${index * 12}px` }}
>
{index > 0 && (
<span className="text-ctp-overlay0 mr-2"></span>
)}
<span className="bg-ctp-surface1 px-2 py-0.5 rounded">
{part}
</span>
</div>
))}
</div>
<div className="text-xs text-ctp-overlay0 mt-2">
Press Enter to create
</div>
</div>
)}
{/* Show filtered existing tags */}
{!parsedPath && filteredTags.length > 0 && (
<div className="max-h-[200px] overflow-y-auto">
{filteredTags.map((tag) => (
<TagTreeClickable
key={tag.id}
tag={tag}
onSelect={handleSelectExistingTag}
/>
))}
</div>
)}
{/* Show "create new" option for simple tags */}
{!parsedPath && filteredTags.length === 0 && (
<div className="p-2 text-xs text-ctp-overlay1">
Press Enter to create "{value}"
</div>
)}
</div>
)}
</div>
);
};
// 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 (
<div key={tag.id} className="flex flex-col">
<button
onClick={() => onSelect(tag)}
className="flex items-center px-3 py-2 hover:bg-ctp-surface1 transition-colors text-left text-xs"
style={{ paddingLeft: `${12 + depth * 16}px` }}
>
<span className="text-ctp-text">{tag.name}</span>
{tag.parentPath && (
<span className="ml-2 text-ctp-overlay0 text-[10px]">
{tag.parentPath}
</span>
)}
</button>
{/* Show children */}
{tag.children && tag.children.length > 0 && (
<div>
{tag.children.map((child) => (
<TagTreeClickable
key={child.id}
tag={child}
depth={depth + 1}
onSelect={onSelect}
/>
))}
</div>
)}
</div> </div>
); );
}; };