Add Tag Support With Backend Models And UI
This commit is contained in:
parent
b596c9f34d
commit
c01a1fc908
22 changed files with 374 additions and 39 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
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
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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("/")
|
||||||
|
|
|
||||||
|
|
@ -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.
Binary file not shown.
BIN
backend/app/routes/__pycache__/tags.cpython-314.pyc
Normal file
BIN
backend/app/routes/__pycache__/tags.cpython-314.pyc
Normal file
Binary file not shown.
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
60
backend/app/routes/tags.py
Normal file
60
backend/app/routes/tags.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models import Note, NoteCreate, NoteTag, NoteUpdate, Tag, TagCreate, User
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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"}
|
||||||
|
|
@ -114,6 +114,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,6 +137,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),
|
||||||
|
})),
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,12 @@ 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: note.tags.map(async (tag) => ({
|
||||||
|
...tag,
|
||||||
|
name: await decryptString(tag.name, encryptionKey),
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
return decryptedNotes;
|
return decryptedNotes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
92
frontend/src/api/tags.tsx
Normal file
92
frontend/src/api/tags.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import axios from "axios";
|
||||||
|
import { encryptString, decryptString } 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 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);
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
@ -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,30 @@ 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 />
|
||||||
|
{/*<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 */}
|
{/* 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 +130,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 +171,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,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: (
|
||||||
|
|
@ -45,7 +45,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 });
|
||||||
},
|
},
|
||||||
|
|
@ -76,7 +75,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,
|
||||||
|
|
@ -99,11 +97,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 });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -115,7 +111,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
|
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
encryptionKey: null, // Wipe from memory
|
encryptionKey: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ const updateFolder = (
|
||||||
id: number,
|
id: number,
|
||||||
folder: FolderTreeNode,
|
folder: FolderTreeNode,
|
||||||
newFolder: FolderUpdate,
|
newFolder: FolderUpdate,
|
||||||
) => {
|
): FolderTreeNode => {
|
||||||
if (folder.id === id) {
|
if (folder.id === id) {
|
||||||
return { ...folder, ...newFolder };
|
return { ...folder, ...newFolder };
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
36
frontend/src/stores/tagStore.ts
Normal file
36
frontend/src/stores/tagStore.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -19,7 +19,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 });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue