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:
parent
c01a1fc908
commit
0abeb90cb0
11 changed files with 165 additions and 103 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ node_modules
|
|||
frontend/src/assets/fontawesome/svg/*
|
||||
frontend/src/assets/fontawesome/svg/0.svg
|
||||
*.db
|
||||
.zed/settings.json
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
tags: await Promise.all(
|
||||
note.tags.map(async (tag) => ({
|
||||
...tag,
|
||||
name: await decryptString(tag.name, encryptionKey),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
);
|
||||
return decryptedNotes;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,6 +173,8 @@ export const Sidebar = () => {
|
|||
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()}
|
||||
|
|
@ -228,6 +234,12 @@ export const Sidebar = () => {
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue