Add tag tree API and frontend tag decryption

- Implement backend tag tree endpoint at /tags/tree with TagTreeNode and
  TagTreeResponse models
- Add frontend tag tree decryption logic and wire it into notes
  decryption flow
- Fetch and decrypt tag tree in tags.tsx; integrate with tag store
- Add UI toggle for folders vs tags and update Sidebar and Header
This commit is contained in:
james fitzsimons 2025-12-18 18:12:23 +00:00
parent c01a1fc908
commit 0abeb90cb0
11 changed files with 165 additions and 103 deletions

1
.gitignore vendored
View file

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

View file

@ -1,7 +1,20 @@
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, User
from app.models import (
Note,
NoteCreate,
NoteTag,
NoteUpdate,
Tag,
TagCreate,
TagTreeNode,
TagTreeResponse,
User,
)
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select
router = APIRouter(prefix="/tags", tags=["tags"])
@ -27,6 +40,27 @@ def create_tag(
return db_tag
def build_tag_tree_node(tag: Tag) -> TagTreeNode:
return TagTreeNode(
id= tag.id,
name = tag.name,
children = [build_tag_tree_node(child) for child in tag.children]
)
@router.get("/tree")
def get_tag_tree(session: Session = Depends(get_session)):
top_level_tags = session.exec(
select(Tag)
.options(selectinload(Tag.children))
.where(Tag.parent_id == None)
).all()
tree = [build_tag_tree_node(tag) for tag in top_level_tags]
return TagTreeResponse(tags=tree)
@router.post("/note/{note_id}/tag/{tag_id}")
def add_tag_to_note(
note_id: int,

View file

@ -1,4 +1,5 @@
import { FolderTreeResponse, FolderTreeNode } from "./folders";
import { Tag } from "./tags";
export async function deriveKey(password: string, salt: string) {
const enc = new TextEncoder();
@ -147,3 +148,25 @@ export async function decryptFolderTree(
),
};
}
export const decryptTagTree = async (
tags: Tag[],
key: CryptoKey,
parentPath = "",
): Promise<Tag[]> => {
return Promise.all(
tags.map(async (tag) => {
const decryptedName = await decryptString(tag.name, key);
const currentPath = parentPath
? `${parentPath} ${decryptedName}`
: decryptedName;
return {
...tag,
name: decryptedName,
parent_path: parentPath,
children: await decryptTagTree(tag.children, key, currentPath),
};
}),
);
};

View file

@ -51,10 +51,12 @@ const fetchNotes = async () => {
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
tags: note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})),
);
return decryptedNotes;

View file

@ -1,5 +1,5 @@
import axios from "axios";
import { encryptString, decryptString } from "./encryption";
import { encryptString, decryptTagTree } from "./encryption";
import { useAuthStore } from "../stores/authStore";
axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD
@ -20,42 +20,13 @@ export interface TagCreate {
parent_id?: number;
}
const buildTagTree = (
tags: Tag[],
parent_id: string | number | null = null,
parentPath = "",
): Tag[] => {
const result: Tag[] = [];
for (const tag of tags) {
if (tag.parent_id == parent_id) {
tag.parent_path = parentPath;
const currentPath = parentPath ? `${parentPath} ${tag.name}` : tag.name;
tag.children = buildTagTree(tags, tag.id, currentPath);
result.push(tag);
}
}
return result;
};
const fetchTags = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get(`${API_URL}/tags/`);
const decryptedTags = await Promise.all(
data.map(async (tag: Tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
);
const tags = buildTagTree(decryptedTags);
console.log(tags);
const { data } = await axios.get(`${API_URL}/tags/tree`);
const tags = decryptTagTree(data.tags, encryptionKey);
console.log(await tags);
return tags;
};

View file

@ -105,20 +105,19 @@ function Home() {
{showModal && <Modal />}
<Sidebar />
<button onClick={getTags}>create</button>
{/*<div className="flex flex-col">
<input
type="text"
value={tagName}
onChange={(e) => setTagName(e.target.value)}
/>
<button onClick={createTag}>create</button>
{tags.map((tag) => (
<button onClick={() => deleteTag(tag.id)} key={tag.id}>
{tag.name}
</button>
))}
</div>*/}
<button onClick={() => getTags()}>Click</button>
{/* Main editor area */}
<div className="flex flex-col w-full h-screen overflow-hidden">
@ -178,16 +177,16 @@ const Modal = () => {
);
};
const TagSelector = () => {
export const TagSelector = () => {
const { tagTree } = useTagStore();
const [value, setValue] = useState("");
return (
<div>
<input
{/*<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
/>*/}
{tagTree && tagTree.map((tag) => <TagTree tag={tag} />)}
</div>
);

View file

@ -2,6 +2,8 @@ import React, { useState, useRef, useEffect, SetStateAction } from "react";
// @ts-ignore
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
// @ts-ignore
import TagsIcon from "@/assets/fontawesome/svg/tags.svg?react";
import { DraggableNote } from "./subcomponents/DraggableNote";
import {
@ -19,6 +21,7 @@ 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";
export const Sidebar = () => {
const [newFolder, setNewFolder] = useState(false);
@ -39,7 +42,8 @@ export const Sidebar = () => {
const { encryptionKey } = useAuthStore();
const { setSideBarResize, sideBarResize } = useUIStore();
const { setSideBarResize, sideBarResize, sideBarView, setSideBarView } =
useUIStore();
useEffect(() => {
if (newFolder && newFolderRef.current) {
newFolderRef.current.focus();
@ -169,65 +173,73 @@ export const Sidebar = () => {
style={{ width: `${sideBarResize}px` }}
>
<SidebarHeader setNewFolder={setNewFolder} />
<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>
)}
{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} />
{/* Folder tree */}
<div className="flex flex-col gap-1">
{folderTree?.folders.map((folder) => (
<FolderTree key={folder.id} folder={folder} depth={0} />
))}
</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}
{/* 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>
)}
</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>
<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>
)}
</div>
</div>
</DndContext>

View file

@ -2,8 +2,11 @@ import { SetStateAction } from "react";
// @ts-ignore
import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
// @ts-ignore
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";
export const SidebarHeader = ({
setNewFolder,
@ -11,6 +14,7 @@ export const SidebarHeader = ({
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
}) => {
const { createNote, selectedFolder } = useNoteStore();
const { setSideBarView, sideBarView } = useUIStore();
const handleCreate = async () => {
await createNote({
title: "Untitled",
@ -22,17 +26,26 @@ export const SidebarHeader = ({
<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-2"
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1"
title="New folder"
>
<FolderPlusIcon className="w-4 h-4 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
<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-2 fill-ctp-mauve hover:fill-ctp-base"
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-4 h-4 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
<FileCirclePlusIcon className="w-5 h-5 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
</button>
</div>
);

View file

@ -78,7 +78,7 @@ export const useNoteStore = create<NoteState>()(
(set, get) => ({
loadFolderTree: async () => {
const data = await folderApi.tree();
console.log(data);
// console.log(data);
set({ folderTree: data });
},
folderTree: null,

View file

@ -10,6 +10,9 @@ interface UIState {
sideBarResize: number;
setSideBarResize: (size: number) => void;
sideBarView: string;
setSideBarView: (view: string) => void;
}
export const useUIStore = create<UIState>()(
@ -27,6 +30,10 @@ export const useUIStore = create<UIState>()(
setSideBarResize: (size) => {
set({ sideBarResize: size });
},
sideBarView: "folders",
setSideBarView: (view) => {
set({ sideBarView: view });
},
}),
{
name: "ui-store",