Merge pull request #11 from jamitz440/feature/add-tags

Feature/add tags
This commit is contained in:
jamitz440 2025-12-18 18:13:21 +00:00 committed by GitHub
commit 03b71c2b64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 493 additions and 96 deletions

2
.gitignore vendored
View file

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

View file

@ -2,7 +2,7 @@ import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
import bcrypt # Use bcrypt directly instead of passlib import bcrypt
from fastapi import Cookie, Depends, HTTPException, Request, status from fastapi import Cookie, Depends, HTTPException, Request, status
from sqlmodel import Session, select from sqlmodel import Session, select
@ -11,7 +11,6 @@ from app.models import Session as SessionModel
from app.models import User from app.models import User
# Password hashing with bcrypt directly
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
password_bytes = password.encode("utf-8") password_bytes = password.encode("utf-8")
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
@ -25,12 +24,11 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(password_bytes, hashed_bytes) return bcrypt.checkpw(password_bytes, hashed_bytes)
# Session management
def create_session( def create_session(
user_id: int, request: Request, db: Session, expires_in_days: int = 30 user_id: int, request: Request, db: Session, expires_in_days: int = 30
) -> str: ) -> str:
session_id = secrets.token_urlsafe(32) session_id = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(days=expires_in_days) expires_at = datetime.now() + timedelta(days=expires_in_days)
db_session = SessionModel( db_session = SessionModel(
session_id=session_id, session_id=session_id,
@ -53,13 +51,12 @@ def get_session_user(session_id: Optional[str], db: Session) -> Optional[User]:
select(SessionModel).where(SessionModel.session_id == session_id) select(SessionModel).where(SessionModel.session_id == session_id)
).first() ).first()
if not session or session.expires_at < datetime.utcnow(): if not session or session.expires_at < datetime.now():
return None return None
return session.user return session.user
# Dependency for protected routes
async def require_auth( async def require_auth(
session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session) session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session)
) -> User: ) -> User:

View file

@ -2,7 +2,7 @@ from fastapi import FastAPI # type: ignore
from fastapi.middleware.cors import CORSMiddleware # type:ignore from fastapi.middleware.cors import CORSMiddleware # type:ignore
from app.database import create_db_and_tables from app.database import create_db_and_tables
from app.routes import auth, folders, notes from app.routes import auth, folders, notes, tags
app = FastAPI(title="Notes API") app = FastAPI(title="Notes API")
@ -24,6 +24,7 @@ def on_startup():
app.include_router(notes.router, prefix="/api") app.include_router(notes.router, prefix="/api")
app.include_router(folders.router, prefix="/api") app.include_router(folders.router, prefix="/api")
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(tags.router, prefix="/api")
@app.get("/") @app.get("/")

View file

@ -4,26 +4,27 @@ from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel # type: ignore from sqlmodel import Field, Relationship, SQLModel # type: ignore
class User(SQLModel, table=True): class User(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True) username: str = Field(unique=True, index=True)
email: str = Field(unique=True, index=True) email: str = Field(unique=True, index=True)
hashed_password: str hashed_password: str
salt: str salt: str
wrapped_master_key: str wrapped_master_key: str
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.now)
# Add relationships to existing models # Add relationships to existing models
notes: List["Note"] = Relationship(back_populates="user") notes: List["Note"] = Relationship(back_populates="user")
folders: List["Folder"] = Relationship(back_populates="user") folders: List["Folder"] = Relationship(back_populates="user")
sessions: List["Session"] = Relationship(back_populates="user") sessions: List["Session"] = Relationship(back_populates="user")
tags: List["Tag"] = Relationship(back_populates="user")
class Session(SQLModel, table=True): class Session(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
session_id: str = Field(unique=True, index=True) session_id: str = Field(unique=True, index=True)
user_id: int = Field(foreign_key="user.id") user_id: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.now)
expires_at: datetime expires_at: datetime
ip_address: Optional[str] = None ip_address: Optional[str] = None
user_agent: Optional[str] = None user_agent: Optional[str] = None
@ -35,7 +36,7 @@ class Folder(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(max_length=255) name: str = Field(max_length=255)
parent_id: Optional[int] = Field(default=None, foreign_key="folder.id") parent_id: Optional[int] = Field(default=None, foreign_key="folder.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.now)
user_id: int = Field(foreign_key="user.id") user_id: int = Field(foreign_key="user.id")
# Relationships # Relationships
@ -47,20 +48,73 @@ class Folder(SQLModel, table=True): # type: ignore
user: User = Relationship(back_populates="folders") user: User = Relationship(back_populates="folders")
class NoteTag(SQLModel, table=True): #type: ignore
note_id: int = Field(foreign_key="note.id", primary_key=True)
tag_id: int = Field(foreign_key="tag.id", primary_key=True)
class Tag(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(max_length=255)
parent_id: Optional[int] = Field(default=None, foreign_key="tag.id")
user_id: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.now)
# Relationships
user: User = Relationship(back_populates="tags")
parent: Optional["Tag"] = Relationship(
back_populates="children",
sa_relationship_kwargs={"remote_side": "Tag.id"}
)
children: List["Tag"] = Relationship(back_populates="parent")
notes: List["Note"] = Relationship(back_populates="tags", link_model=NoteTag)
class Note(SQLModel, table=True): # type: ignore class Note(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
title: str = Field(max_length=255) title: str = Field(max_length=255)
content: str content: str
folder_id: Optional[int] = Field(default=None, foreign_key="folder.id") folder_id: Optional[int] = Field(default=None, foreign_key="folder.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.now)
user_id: int = Field(foreign_key="user.id") user_id: int = Field(foreign_key="user.id")
#Relationships
folder: Optional[Folder] = Relationship(back_populates="notes") folder: Optional[Folder] = Relationship(back_populates="notes")
user: User = Relationship(back_populates="notes") user: User = Relationship(back_populates="notes")
tags: List[Tag] = Relationship(back_populates="notes", link_model=NoteTag)
# API Response models
class TagRead(SQLModel):
id: int
name: str
parent_id: Optional[int] = None
created_at: datetime
class TagCreate(SQLModel):
name: str
parent_id: Optional[int] = None
class TagUpdate(SQLModel):
name: Optional[str] = None
parent_id: Optional[int] = None
class TagTreeNode(SQLModel):
id: int
name: str
children: List["TagTreeNode"] = []
class TagTreeResponse(SQLModel):
tags: List[TagTreeNode]
# API Response models (what gets sent to frontend)
class NoteRead(SQLModel): class NoteRead(SQLModel):
id: int id: int
title: str title: str
@ -68,6 +122,7 @@ class NoteRead(SQLModel):
folder_id: Optional[int] = None folder_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
tags: List[TagRead] = []
class FolderTreeNode(SQLModel): class FolderTreeNode(SQLModel):

Binary file not shown.

View file

@ -1,8 +1,5 @@
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException # type: ignore
from sqlmodel import Session, select # type: ignore
from app.auth import require_auth from app.auth import require_auth
from app.database import get_session from app.database import get_session
from app.models import ( from app.models import (
@ -15,6 +12,9 @@ from app.models import (
NoteRead, NoteRead,
User, User,
) )
from fastapi import APIRouter, Depends, HTTPException # type: ignore
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select # type: ignore
router = APIRouter(prefix="/folders", tags=["folders"]) router = APIRouter(prefix="/folders", tags=["folders"])
@ -35,21 +35,20 @@ def get_folder_tree(
): ):
"""Get complete folder tree with notes""" """Get complete folder tree with notes"""
# Get all top-level folders (parent_id is None) for current user
top_level_folders = session.exec( top_level_folders = session.exec(
select(Folder) select(Folder)
.options(selectinload(Folder.notes).selectinload(Note.tags))
.where(Folder.parent_id == None) .where(Folder.parent_id == None)
.where(Folder.user_id == current_user.id) .where(Folder.user_id == current_user.id)
).all() ).all()
# Get all orphaned notes (folder_id is None) for current user
orphaned_notes = session.exec( orphaned_notes = session.exec(
select(Note) select(Note)
.options(selectinload(Note.tags))
.where(Note.folder_id == None) .where(Note.folder_id == None)
.where(Note.user_id == current_user.id) .where(Note.user_id == current_user.id)
).all() ).all()
# Build tree recursively
tree = [build_folder_tree_node(folder) for folder in top_level_folders] tree = [build_folder_tree_node(folder) for folder in top_level_folders]
return FolderTreeResponse( return FolderTreeResponse(

View file

@ -1,11 +1,10 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.auth import require_auth from app.auth import require_auth
from app.database import get_session from app.database import get_session
from app.models import Note, NoteCreate, NoteUpdate, User from app.models import Note, NoteCreate, NoteUpdate, User
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
router = APIRouter(prefix="/notes", tags=["notes"]) router = APIRouter(prefix="/notes", tags=["notes"])
@ -16,6 +15,7 @@ def list_notes(session: Session = Depends(get_session)):
return notes return notes
@router.post("/", response_model=Note) @router.post("/", response_model=Note)
def create_note( def create_note(
note: NoteCreate, note: NoteCreate,

View file

@ -0,0 +1,94 @@
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,
TagTreeResponse,
User,
)
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select
router = APIRouter(prefix="/tags", tags=["tags"])
@router.get("/")
def list_tags(session: Session = Depends(get_session)):
tags = session.exec(select(Tag)).all()
return tags
@router.post('/', response_model=Tag)
def create_tag(
tag: TagCreate,
current_user: User = Depends(require_auth),
session: Session = Depends(get_session)
):
tag_data = tag.model_dump()
tag_data["user_id"] = current_user.id
db_tag = Tag.model_validate(tag_data)
session.add(db_tag)
session.commit()
session.refresh(db_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,
tag_id: int,
current_user: User = Depends(require_auth),
session: Session = Depends(get_session)
):
existing = session.exec(
select(NoteTag)
.where(NoteTag.note_id == note_id)
.where(NoteTag.tag_id == tag_id)
).first()
if existing:
return {"message": "Tag already added"}
note_tag = NoteTag(note_id=note_id, tag_id=tag_id)
session.add(note_tag)
session.commit()
return note_tag
@router.delete("/{tag_id}")
def delete_note(tag_id: int, session: Session = Depends(get_session)):
tag = session.get(Tag, tag_id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
session.delete(tag)
session.commit()
return {"message": "tag deleted"}

View file

@ -1,4 +1,5 @@
import { FolderTreeResponse, FolderTreeNode } from "./folders"; import { FolderTreeResponse, FolderTreeNode } from "./folders";
import { Tag } from "./tags";
export async function deriveKey(password: string, salt: string) { export async function deriveKey(password: string, salt: string) {
const enc = new TextEncoder(); const enc = new TextEncoder();
@ -114,6 +115,12 @@ export async function decryptFolderTree(
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})), })),
), ),
children: await Promise.all( children: await Promise.all(
@ -131,7 +138,35 @@ export async function decryptFolderTree(
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})), })),
), ),
}; };
} }
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

@ -1,6 +1,7 @@
import axios from "axios"; import axios from "axios";
import { decryptFolderTree } from "./encryption"; import { decryptFolderTree } from "./encryption";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import { Tag } from "./tags";
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
@ -22,6 +23,7 @@ export interface NoteRead {
folder_id: number | null; folder_id: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
tags: Tag[];
} }
export interface FolderTreeNode { export interface FolderTreeNode {

View file

@ -1,7 +1,7 @@
import axios from "axios"; import axios from "axios";
import { NoteRead } from "./folders";
import { encryptString, decryptString } from "./encryption"; import { encryptString, decryptString } from "./encryption";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import { Tag } from "./tags";
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD const API_URL = (import.meta as any).env.PROD
? "/api" ? "/api"
@ -14,6 +14,7 @@ export interface Note {
content: string; content: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
tags: Tag[];
} }
export interface NoteCreate { export interface NoteCreate {
@ -50,9 +51,14 @@ const fetchNotes = async () => {
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})), })),
); );
return decryptedNotes; return decryptedNotes;
}; };

63
frontend/src/api/tags.tsx Normal file
View file

@ -0,0 +1,63 @@
import axios from "axios";
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";
export interface Tag {
id: string;
name: string;
parent_id?: number;
created_at: string;
children: Tag[];
parent_path: string;
}
export interface TagCreate {
name: string;
parent_id?: number;
}
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);
return tags;
};
const createTag = async (tag: TagCreate, noteId?: number) => {
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);
if (noteId) {
return await addTagToNote(r.data.id, noteId);
}
};
const addTagToNote = async (tagId: number, noteId: number) => {
return axios.post(`${API_URL}/tags/note/${noteId}/tag/${tagId}`);
};
const deleteTag = async (tagId: number) => {
return axios.delete(`${API_URL}/tags/${tagId}`);
};
export const tagsApi = {
list: async () => await fetchTags(),
create: (tag: TagCreate, noteId?: number) => createTag(tag, noteId),
delete: (tagId: number) => deleteTag(tagId),
};

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import "../../main.css"; import "../../main.css";
import { motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
import { useNoteStore } from "@/stores/notesStore"; import { useNoteStore } from "@/stores/notesStore";
import { useUIStore } from "@/stores/uiStore"; import { useUIStore } from "@/stores/uiStore";
@ -9,6 +9,9 @@ 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 { Tag, tagsApi } from "@/api/tags";
import { useTagStore } from "@/stores/tagStore";
function Home() { function Home() {
const [newFolder] = useState(false); const [newFolder] = useState(false);
const [lastSavedNote, setLastSavedNote] = useState<{ const [lastSavedNote, setLastSavedNote] = useState<{
@ -92,12 +95,29 @@ function Home() {
} }
}; };
const { getTagTree, tagTree } = useTagStore();
const getTags = () => {
getTagTree();
};
return ( return (
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden"> <div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
{showModal && <Modal />} {showModal && <Modal />}
<Sidebar /> <Sidebar />
<button onClick={getTags}>create</button>
{/*<div className="flex flex-col">
<input
type="text"
value={tagName}
onChange={(e) => setTagName(e.target.value)}
/>
{tags.map((tag) => (
<button onClick={() => deleteTag(tag.id)} key={tag.id}>
{tag.name}
</button>
))}
</div>*/}
{/* Main editor area */} {/* Main editor area */}
<div className="flex flex-col w-full h-screen overflow-hidden"> <div className="flex flex-col w-full h-screen overflow-hidden">
@ -109,6 +129,20 @@ function Home() {
onChange={(e) => setTitle(e.target.value)} 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" 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) => (
<button
onClick={() => null}
key={tag.id}
className="bg-ctp-surface0 px-1.5 text-sm rounded-full"
>
{tag.parent_id && "..."}
{tag.name}
</button>
))}
</div>
<TiptapEditor <TiptapEditor
key={selectedNote?.id} key={selectedNote?.id}
content={selectedNote?.content || ""} content={selectedNote?.content || ""}
@ -136,8 +170,53 @@ const Modal = () => {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5" className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5"
> >
<Login /> {/*<Login />*/}
<TagSelector />
</div> </div>
</motion.div> </motion.div>
); );
}; };
export const TagSelector = () => {
const { tagTree } = useTagStore();
const [value, setValue] = useState("");
return (
<div>
{/*<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>*/}
{tagTree && tagTree.map((tag) => <TagTree tag={tag} />)}
</div>
);
};
export const TagTree = ({ tag, depth = 0 }: { tag: Tag; depth?: number }) => {
const [collapse, setCollapse] = useState(false);
return (
<div key={tag.id} className="flex flex-col relative">
<div onClick={() => setCollapse(!collapse)}>{tag.name}</div>
<AnimatePresence>
{collapse && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden flex flex-col"
>
{/* The line container */}
<div className="ml-2 pl-3 border-l border-ctp-surface2">
{/* Child tags */}
{tag.children.map((child) => (
<TagTree key={child.id} tag={child} depth={depth + 1} />
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View file

@ -2,6 +2,8 @@ import React, { useState, useRef, useEffect } from "react";
// @ts-ignore // @ts-ignore
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react"; 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 { DraggableNote } from "./subcomponents/DraggableNote";
import { import {
@ -19,6 +21,7 @@ import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
import { useAuthStore } from "@/stores/authStore.ts"; import { useAuthStore } from "@/stores/authStore.ts";
import { useNoteStore } from "@/stores/notesStore.ts"; import { useNoteStore } from "@/stores/notesStore.ts";
import { useUIStore } from "@/stores/uiStore.ts"; import { useUIStore } from "@/stores/uiStore.ts";
import { TagSelector } from "../../Home.tsx";
export const Sidebar = () => { export const Sidebar = () => {
const [newFolder, setNewFolder] = useState(false); const [newFolder, setNewFolder] = useState(false);
@ -39,7 +42,8 @@ export const Sidebar = () => {
const { encryptionKey } = useAuthStore(); const { encryptionKey } = useAuthStore();
const { setSideBarResize, sideBarResize } = useUIStore(); const { setSideBarResize, sideBarResize, sideBarView, setSideBarView } =
useUIStore();
useEffect(() => { useEffect(() => {
if (newFolder && newFolderRef.current) { if (newFolder && newFolderRef.current) {
newFolderRef.current.focus(); newFolderRef.current.focus();
@ -169,6 +173,8 @@ export const Sidebar = () => {
style={{ width: `${sideBarResize}px` }} style={{ width: `${sideBarResize}px` }}
> >
<SidebarHeader setNewFolder={setNewFolder} /> <SidebarHeader setNewFolder={setNewFolder} />
{sideBarView == "folders" ? (
<>
<div <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" 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()} onDragOver={(e) => e.preventDefault()}
@ -228,6 +234,12 @@ export const Sidebar = () => {
</div> </div>
)} )}
</DragOverlay> </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>
</div> </div>
</DndContext> </DndContext>

View file

@ -2,8 +2,11 @@ import { SetStateAction } from "react";
// @ts-ignore // @ts-ignore
import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react"; import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
// @ts-ignore // @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 FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
import { useNoteStore } from "@/stores/notesStore"; import { useNoteStore } from "@/stores/notesStore";
import { useUIStore } from "@/stores/uiStore";
export const SidebarHeader = ({ export const SidebarHeader = ({
setNewFolder, setNewFolder,
@ -11,6 +14,7 @@ export const SidebarHeader = ({
setNewFolder: React.Dispatch<SetStateAction<boolean>>; setNewFolder: React.Dispatch<SetStateAction<boolean>>;
}) => { }) => {
const { createNote, selectedFolder } = useNoteStore(); const { createNote, selectedFolder } = useNoteStore();
const { setSideBarView, sideBarView } = useUIStore();
const handleCreate = async () => { const handleCreate = async () => {
await createNote({ await createNote({
title: "Untitled", 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"> <div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1">
<button <button
onClick={() => setNewFolder(true)} 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" 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>
<button <button
onClick={handleCreate} 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" 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> </button>
</div> </div>
); );

View file

@ -13,15 +13,15 @@ interface User {
id: number; id: number;
username: string; username: string;
email: string; email: string;
salt: string; // For key derivation salt: string;
} }
interface AuthState { interface AuthState {
user: User | null; user: User | null;
encryptionKey: CryptoKey | null; // Memory only! encryptionKey: CryptoKey | null;
isAuthenticated: boolean; isAuthenticated: boolean;
rememberMe: boolean; rememberMe: boolean;
setRememberMe: (boolean) => void; setRememberMe: (remember: boolean) => void;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
register: ( register: (
@ -48,7 +48,6 @@ export const useAuthStore = create<AuthState>()(
set({ rememberMe: bool }); set({ rememberMe: bool });
}, },
initEncryptionKey: async (password: string, salt: string) => { initEncryptionKey: async (password: string, salt: string) => {
// Use user-specific salt instead of hardcoded
const key = await deriveKey(password, salt); const key = await deriveKey(password, salt);
set({ encryptionKey: key }); set({ encryptionKey: key });
}, },
@ -79,7 +78,6 @@ export const useAuthStore = create<AuthState>()(
const data = await response.json(); const data = await response.json();
// Store the master key directly (not derived from password)
set({ set({
user: data.user, user: data.user,
isAuthenticated: true, isAuthenticated: true,
@ -102,11 +100,9 @@ export const useAuthStore = create<AuthState>()(
const { user } = await response.json(); const { user } = await response.json();
// Derive KEK and unwrap master key
const kek = await deriveKey(password, user.salt); const kek = await deriveKey(password, user.salt);
const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek); const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek);
// Store master key in memory
set({ encryptionKey: masterKey, user, isAuthenticated: true }); set({ encryptionKey: masterKey, user, isAuthenticated: true });
}, },

View file

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

View file

@ -0,0 +1,36 @@
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

@ -10,6 +10,9 @@ interface UIState {
sideBarResize: number; sideBarResize: number;
setSideBarResize: (size: number) => void; setSideBarResize: (size: number) => void;
sideBarView: string;
setSideBarView: (view: string) => void;
} }
export const useUIStore = create<UIState>()( export const useUIStore = create<UIState>()(
@ -19,7 +22,7 @@ export const useUIStore = create<UIState>()(
setUpdating: (update) => { setUpdating: (update) => {
set({ updating: update }); set({ updating: update });
}, },
showModal: false, showModal: true,
setShowModal: (show) => { setShowModal: (show) => {
set({ showModal: show }); set({ showModal: show });
}, },
@ -27,6 +30,10 @@ export const useUIStore = create<UIState>()(
setSideBarResize: (size) => { setSideBarResize: (size) => {
set({ sideBarResize: size }); set({ sideBarResize: size });
}, },
sideBarView: "folders",
setSideBarView: (view) => {
set({ sideBarView: view });
},
}), }),
{ {
name: "ui-store", name: "ui-store",