Implement user auth and session management
This commit is contained in:
parent
89fecc5c08
commit
5e6764b026
43 changed files with 3047 additions and 584 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1 +1,3 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
frontend/src/assets/fontawesome/svg/*
|
||||||
|
frontend/src/assets/fontawesome/svg/0.svg
|
||||||
|
|
|
||||||
BIN
backend/app/__pycache__/auth.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/auth.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
71
backend/app/auth.py
Normal file
71
backend/app/auth.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import bcrypt # Use bcrypt directly instead of passlib
|
||||||
|
from fastapi import Cookie, Depends, HTTPException, Request, status
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models import Session as SessionModel
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
# Password hashing with bcrypt directly
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
password_bytes = password.encode("utf-8")
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||||
|
return hashed.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
password_bytes = plain_password.encode("utf-8")
|
||||||
|
hashed_bytes = hashed_password.encode("utf-8")
|
||||||
|
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
# Session management
|
||||||
|
def create_session(
|
||||||
|
user_id: int, request: Request, db: Session, expires_in_days: int = 30
|
||||||
|
) -> str:
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
|
||||||
|
|
||||||
|
db_session = SessionModel(
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
ip_address=request.client.host,
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
db.add(db_session)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_user(session_id: Optional[str], db: Session) -> Optional[User]:
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session = db.exec(
|
||||||
|
select(SessionModel).where(SessionModel.session_id == session_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not session or session.expires_at < datetime.utcnow():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return session.user
|
||||||
|
|
||||||
|
|
||||||
|
# Dependency for protected routes
|
||||||
|
async def require_auth(
|
||||||
|
session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session)
|
||||||
|
) -> User:
|
||||||
|
user = get_session_user(session_id, db)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
|
||||||
|
)
|
||||||
|
return 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 folders, notes
|
from app.routes import auth, folders, notes
|
||||||
|
|
||||||
app = FastAPI(title="Notes API")
|
app = FastAPI(title="Notes API")
|
||||||
|
|
||||||
|
|
@ -23,6 +23,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.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,39 @@ 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):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
username: str = Field(unique=True, index=True)
|
||||||
|
email: str = Field(unique=True, index=True)
|
||||||
|
hashed_password: str
|
||||||
|
salt: str
|
||||||
|
wrapped_master_key: str
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Add relationships to existing models
|
||||||
|
notes: List["Note"] = Relationship(back_populates="user")
|
||||||
|
folders: List["Folder"] = Relationship(back_populates="user")
|
||||||
|
sessions: List["Session"] = Relationship(back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
|
class Session(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
session_id: str = Field(unique=True, index=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
expires_at: datetime
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
user_agent: Optional[str] = None
|
||||||
|
|
||||||
|
user: User = Relationship(back_populates="sessions")
|
||||||
|
|
||||||
|
|
||||||
class Folder(SQLModel, table=True): # type: ignore
|
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.utcnow)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
parent: Optional["Folder"] = Relationship(
|
parent: Optional["Folder"] = Relationship(
|
||||||
|
|
@ -16,6 +44,7 @@ class Folder(SQLModel, table=True): # type: ignore
|
||||||
)
|
)
|
||||||
children: List["Folder"] = Relationship(back_populates="parent")
|
children: List["Folder"] = Relationship(back_populates="parent")
|
||||||
notes: List["Note"] = Relationship(back_populates="folder")
|
notes: List["Note"] = Relationship(back_populates="folder")
|
||||||
|
user: User = Relationship(back_populates="folders")
|
||||||
|
|
||||||
|
|
||||||
class Note(SQLModel, table=True): # type: ignore
|
class Note(SQLModel, table=True): # type: ignore
|
||||||
|
|
@ -25,8 +54,10 @@ class Note(SQLModel, table=True): # type: ignore
|
||||||
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.utcnow)
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
|
||||||
folder: Optional[Folder] = Relationship(back_populates="notes")
|
folder: Optional[Folder] = Relationship(back_populates="notes")
|
||||||
|
user: User = Relationship(back_populates="notes")
|
||||||
|
|
||||||
|
|
||||||
# API Response models (what gets sent to frontend)
|
# API Response models (what gets sent to frontend)
|
||||||
|
|
|
||||||
BIN
backend/app/routes/__pycache__/auth.cpython-314.pyc
Normal file
BIN
backend/app/routes/__pycache__/auth.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
166
backend/app/routes/auth.py
Normal file
166
backend/app/routes/auth.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
|
||||||
|
from sqlmodel import Session, SQLModel, select
|
||||||
|
|
||||||
|
from app.auth import create_session, hash_password, require_auth, verify_password
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models import Session as SessionModel
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response models
|
||||||
|
class RegisterRequest(SQLModel):
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
salt: str
|
||||||
|
wrappedMasterKey: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(SQLModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(SQLModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
salt: str # Client needs this for key derivation
|
||||||
|
wrapped_master_key: str # Client needs this to unwrap the master key
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
def register(
|
||||||
|
data: RegisterRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
# Check existing user
|
||||||
|
existing = db.exec(
|
||||||
|
select(User).where(
|
||||||
|
(User.username == data.username) | (User.email == data.email)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="User already exists")
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
user = User(
|
||||||
|
username=data.username,
|
||||||
|
email=data.email,
|
||||||
|
hashed_password=hash_password(data.password),
|
||||||
|
salt=data.salt,
|
||||||
|
wrapped_master_key=data.wrappedMasterKey,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
assert user.id is not None
|
||||||
|
session_id = create_session(user.id, request, db)
|
||||||
|
|
||||||
|
# Set cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key="session_id",
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
secure=True, # HTTPS only in production
|
||||||
|
samesite="lax",
|
||||||
|
max_age=30 * 24 * 60 * 60, # 30 days
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"user": UserResponse.model_validate(user)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(
|
||||||
|
data: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
# Find user
|
||||||
|
user = db.exec(select(User).where(User.username == data.username)).first()
|
||||||
|
|
||||||
|
if not user or not verify_password(data.password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
assert user.id is not None
|
||||||
|
session_id = create_session(user.id, request, db)
|
||||||
|
|
||||||
|
# Set cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key="session_id",
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=30 * 24 * 60 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"user": UserResponse.model_validate(user)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(
|
||||||
|
response: Response,
|
||||||
|
session_id: Optional[str] = Cookie(None),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
# Delete session from database
|
||||||
|
if session_id:
|
||||||
|
session = db.exec(
|
||||||
|
select(SessionModel).where(SessionModel.session_id == session_id)
|
||||||
|
).first()
|
||||||
|
if session:
|
||||||
|
db.delete(session)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Clear cookie
|
||||||
|
response.delete_cookie("session_id")
|
||||||
|
return {"message": "Logged out"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def get_current_user(current_user: User = Depends(require_auth)):
|
||||||
|
return {"user": UserResponse.from_orm(current_user)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions")
|
||||||
|
def list_sessions(
|
||||||
|
current_user: User = Depends(require_auth), db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
sessions = db.exec(
|
||||||
|
select(SessionModel)
|
||||||
|
.where(SessionModel.user_id == current_user.id)
|
||||||
|
.where(SessionModel.expires_at > datetime.utcnow())
|
||||||
|
).all()
|
||||||
|
return {"sessions": sessions}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{session_token}") # Renamed from session_id
|
||||||
|
def revoke_session(
|
||||||
|
session_token: str, # Renamed to avoid conflict with Cookie parameter
|
||||||
|
current_user: User = Depends(require_auth),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
session = db.exec(
|
||||||
|
select(SessionModel)
|
||||||
|
.where(SessionModel.session_id == session_token) # Use renamed variable
|
||||||
|
.where(SessionModel.user_id == current_user.id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if session:
|
||||||
|
db.delete(session)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Session revoked"}
|
||||||
|
|
@ -3,6 +3,7 @@ from typing import List
|
||||||
from fastapi import APIRouter, Depends, HTTPException # type: ignore
|
from fastapi import APIRouter, Depends, HTTPException # type: ignore
|
||||||
from sqlmodel import Session, select # type: ignore
|
from sqlmodel import Session, select # type: ignore
|
||||||
|
|
||||||
|
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 (
|
||||||
Folder,
|
Folder,
|
||||||
|
|
@ -12,6 +13,7 @@ from app.models import (
|
||||||
FolderUpdate,
|
FolderUpdate,
|
||||||
Note,
|
Note,
|
||||||
NoteRead,
|
NoteRead,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/folders", tags=["folders"])
|
router = APIRouter(prefix="/folders", tags=["folders"])
|
||||||
|
|
@ -20,7 +22,7 @@ router = APIRouter(prefix="/folders", tags=["folders"])
|
||||||
def build_folder_tree_node(folder: Folder) -> FolderTreeNode:
|
def build_folder_tree_node(folder: Folder) -> FolderTreeNode:
|
||||||
"""Recursively build a folder tree node with notes and children"""
|
"""Recursively build a folder tree node with notes and children"""
|
||||||
return FolderTreeNode(
|
return FolderTreeNode(
|
||||||
id=folder.id,
|
id=folder.id, # pyright: ignore[reportArgumentType]
|
||||||
name=folder.name,
|
name=folder.name,
|
||||||
notes=[NoteRead.model_validate(note) for note in folder.notes],
|
notes=[NoteRead.model_validate(note) for note in folder.notes],
|
||||||
children=[build_folder_tree_node(child) for child in folder.children],
|
children=[build_folder_tree_node(child) for child in folder.children],
|
||||||
|
|
@ -28,16 +30,24 @@ def build_folder_tree_node(folder: Folder) -> FolderTreeNode:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tree", response_model=FolderTreeResponse)
|
@router.get("/tree", response_model=FolderTreeResponse)
|
||||||
def get_folder_tree(session: Session = Depends(get_session)):
|
def get_folder_tree(
|
||||||
|
current_user: User = Depends(require_auth), session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
"""Get complete folder tree with notes"""
|
"""Get complete folder tree with notes"""
|
||||||
|
|
||||||
# Get all top-level folders (parent_id is None)
|
# Get all top-level folders (parent_id is None) for current user
|
||||||
top_level_folders = session.exec(
|
top_level_folders = session.exec(
|
||||||
select(Folder).where(Folder.parent_id == None)
|
select(Folder)
|
||||||
|
.where(Folder.parent_id == None)
|
||||||
|
.where(Folder.user_id == current_user.id)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Get all orphaned notes (folder_id is None)
|
# Get all orphaned notes (folder_id is None) for current user
|
||||||
orphaned_notes = session.exec(select(Note).where(Note.folder_id == None)).all()
|
orphaned_notes = session.exec(
|
||||||
|
select(Note)
|
||||||
|
.where(Note.folder_id == None)
|
||||||
|
.where(Note.user_id == current_user.id)
|
||||||
|
).all()
|
||||||
|
|
||||||
# Build tree recursively
|
# 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]
|
||||||
|
|
@ -56,9 +66,15 @@ def list_folders(session: Session = Depends(get_session)):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=Folder)
|
@router.post("/", response_model=Folder)
|
||||||
def create_folder(folder: FolderCreate, session: Session = Depends(get_session)):
|
def create_folder(
|
||||||
|
folder: FolderCreate,
|
||||||
|
current_user: User = Depends(require_auth),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
"""Create a new folder"""
|
"""Create a new folder"""
|
||||||
db_folder = Folder.model_validate(folder)
|
folder_data = folder.model_dump()
|
||||||
|
folder_data["user_id"] = current_user.id
|
||||||
|
db_folder = Folder.model_validate(folder_data)
|
||||||
session.add(db_folder)
|
session.add(db_folder)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_folder)
|
session.refresh(db_folder)
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,30 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.database import get_session
|
|
||||||
from app.models import Note, NoteCreate, NoteUpdate
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models import Note, NoteCreate, NoteUpdate, User
|
||||||
|
|
||||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
router = APIRouter(prefix="/notes", tags=["notes"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
def list_notes(session: Session = Depends(get_session)):
|
def list_notes(session: Session = Depends(get_session)):
|
||||||
notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all()
|
notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() # pyright: ignore[reportAttributeAccessIssue]
|
||||||
return notes
|
return notes
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=Note)
|
@router.post("/", response_model=Note)
|
||||||
def create_note(note: NoteCreate, session: Session = Depends(get_session)):
|
def create_note(
|
||||||
db_note = Note.model_validate(note)
|
note: NoteCreate,
|
||||||
|
current_user: User = Depends(require_auth),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
note_data = note.model_dump()
|
||||||
|
note_data["user_id"] = current_user.id
|
||||||
|
db_note = Note.model_validate(note_data)
|
||||||
session.add(db_note)
|
session.add(db_note)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_note)
|
session.refresh(db_note)
|
||||||
|
|
|
||||||
BIN
backend/notes.db
BIN
backend/notes.db
Binary file not shown.
1
backend/notes.sqbpro
Normal file
1
backend/notes.sqbpro
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="notes.db" readonly="0" foreign_keys="1" case_sensitive_like="0" temp_store="0" wal_autocheckpoint="1000" synchronous="2"/><attached/><window><main_tabs open="structure browser pragmas query" current="1"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="2087"/><column_width id="4" width="0"/><expanded_item id="0" parent="1"/><expanded_item id="1" parent="1"/><expanded_item id="2" parent="1"/><expanded_item id="3" parent="1"/></tab_structure><tab_browse><table title="user" custom_title="0" dock_id="1" table="4,4:mainuser"/><dock_state state="000000ff00000000fd00000001000000020000030e000004effc0100000001fb000000160064006f0063006b00420072006f007700730065003101000000000000030e0000012000ffffff000002a80000000000000004000000040000000800000008fc00000000"/><default_encoding codec=""/><browse_table_settings><table schema="main" name="folder" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="23"/><column index="2" value="187"/><column index="3" value="64"/><column index="4" value="210"/><column index="5" value="53"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="session" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="23"/><column index="2" value="300"/><column index="3" value="53"/><column index="4" value="210"/><column index="5" value="210"/><column index="6" value="73"/><column index="7" value="300"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="user" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="23"/><column index="2" value="67"/><column index="3" value="124"/><column index="4" value="300"/><column index="5" value="179"/><column index="6" value="210"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table></browse_table_settings></tab_browse><tab_sql><sql name="SQL 1"></sql><current_tab id="0"/></tab_sql></sqlb_project>
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"reportGeneralTypeIssues": "warning"
|
|
||||||
}
|
|
||||||
895
frontend/package-lock.json
generated
895
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,12 +14,17 @@
|
||||||
"@mdxeditor/editor": "^3.49.3",
|
"@mdxeditor/editor": "^3.49.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@tiptap/extension-placeholder": "^3.12.1",
|
||||||
|
"@tiptap/react": "^3.12.1",
|
||||||
|
"@tiptap/starter-kit": "^3.12.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"framer-motion": "^12.23.25",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
// src/App.tsx
|
// src/App.tsx
|
||||||
|
import { useEffect } from "react";
|
||||||
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
|
||||||
import Home from "./pages/Home"; // existing home page
|
import Home from "./pages/Home"; // existing home page
|
||||||
import { MarkdownPage } from "./pages/Markdown";
|
|
||||||
import { Import } from "./pages/Import";
|
import { Import } from "./pages/Import";
|
||||||
const App = () => (
|
import { Login } from "./pages/Login";
|
||||||
|
import { Register } from "./pages/Register";
|
||||||
|
import { Test } from "./pages/Test";
|
||||||
|
import { useAuthStore } from "./stores/authStore";
|
||||||
|
import { ContextMenuProvider } from "./contexts/ContextMenuContext";
|
||||||
|
import { ContextMenuRenderer } from "./components/contextMenus/ContextMenuRenderer";
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const { checkAuth } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenuProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
{/* Simple nav – you can replace with your own UI later */}
|
{/* Simple nav – you can replace with your own UI later */}
|
||||||
{/*<nav style={{ marginBottom: "1rem" }}>
|
{/*<nav style={{ marginBottom: "1rem" }}>
|
||||||
|
|
@ -12,10 +27,15 @@ const App = () => (
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/markdown" element={<MarkdownPage />} />
|
|
||||||
<Route path="/import" element={<Import />} />
|
<Route path="/import" element={<Import />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/test" element={<Test />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<ContextMenuRenderer />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
</ContextMenuProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { FolderTreeResponse, FolderTreeNode } from "./folders";
|
import { FolderTreeResponse, FolderTreeNode } from "./folders";
|
||||||
|
|
||||||
export async function deriveKey(password: string) {
|
export async function deriveKey(password: string, salt: string) {
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
|
|
@ -13,13 +13,55 @@ export async function deriveKey(password: string) {
|
||||||
return crypto.subtle.deriveKey(
|
return crypto.subtle.deriveKey(
|
||||||
{
|
{
|
||||||
name: "PBKDF2",
|
name: "PBKDF2",
|
||||||
salt: enc.encode("your-app-salt"), // Store this somewhere consistent
|
salt: enc.encode(salt),
|
||||||
iterations: 100000,
|
iterations: 100000,
|
||||||
hash: "SHA-256",
|
hash: "SHA-256",
|
||||||
},
|
},
|
||||||
keyMaterial,
|
keyMaterial,
|
||||||
{ name: "AES-GCM", length: 256 },
|
{ name: "AES-GCM", length: 256 },
|
||||||
false,
|
false,
|
||||||
|
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMasterKey(): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
|
||||||
|
"encrypt",
|
||||||
|
"decrypt",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wrapMasterKey(
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
kek: CryptoKey,
|
||||||
|
): Promise<string> {
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const wrapped = await crypto.subtle.wrapKey("raw", masterKey, kek, {
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
});
|
||||||
|
const combined = new Uint8Array(iv.length + wrapped.byteLength);
|
||||||
|
combined.set(iv);
|
||||||
|
combined.set(new Uint8Array(wrapped), iv.length);
|
||||||
|
|
||||||
|
return btoa(String.fromCharCode(...combined));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unwrapMasterKey(
|
||||||
|
wrappedKey: string,
|
||||||
|
kek: CryptoKey,
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
const combined = Uint8Array.from(atob(wrappedKey), (c) => c.charCodeAt(0));
|
||||||
|
const iv = combined.slice(0, 12);
|
||||||
|
const wrapped = combined.slice(12);
|
||||||
|
|
||||||
|
return crypto.subtle.unwrapKey(
|
||||||
|
"raw",
|
||||||
|
wrapped,
|
||||||
|
kek,
|
||||||
|
{ name: "AES-GCM", iv },
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
["encrypt", "decrypt"],
|
["encrypt", "decrypt"],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { decryptFolderTree, deriveKey } from "./encryption";
|
import { decryptFolderTree } from "./encryption";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
|
||||||
const API_URL = import.meta.env.PROD ? "/api" : "http://localhost:8000/api";
|
axios.defaults.withCredentials = true;
|
||||||
|
|
||||||
|
const API_URL = (import.meta as any).env.PROD
|
||||||
|
? "/api"
|
||||||
|
: "http://localhost:8000/api";
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -42,11 +47,14 @@ export interface FolderUpdate {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFolderTree = async () => {
|
const getFolderTree = async () => {
|
||||||
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const { data } = await axios.get<FolderTreeResponse>(
|
const { data } = await axios.get<FolderTreeResponse>(
|
||||||
`${API_URL}/folders/tree`,
|
`${API_URL}/folders/tree`,
|
||||||
);
|
);
|
||||||
var key = await deriveKey("Test");
|
|
||||||
const decryptedFolderTree = await decryptFolderTree(data, key);
|
const decryptedFolderTree = await decryptFolderTree(data, encryptionKey);
|
||||||
|
|
||||||
return decryptedFolderTree;
|
return decryptedFolderTree;
|
||||||
};
|
};
|
||||||
|
|
@ -67,7 +75,7 @@ export const folderApi = {
|
||||||
tree: () => getFolderTree(),
|
tree: () => getFolderTree(),
|
||||||
list: () => axios.get<Folder[]>(`${API_URL}/folders`),
|
list: () => axios.get<Folder[]>(`${API_URL}/folders`),
|
||||||
create: (folder: FolderCreate) =>
|
create: (folder: FolderCreate) =>
|
||||||
axios.post<Folder>(`${API_URL}/folders`, folder),
|
axios.post<Folder>(`${API_URL}/folders/`, folder),
|
||||||
delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`),
|
delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`),
|
||||||
update: (id: number, updateData: FolderUpdate) =>
|
update: (id: number, updateData: FolderUpdate) =>
|
||||||
updateFolder(id, updateData),
|
updateFolder(id, updateData),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { NoteRead } from "./folders";
|
import { NoteRead } from "./folders";
|
||||||
import { deriveKey, encryptString, decryptString } from "./encryption";
|
import { encryptString, decryptString } from "./encryption";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
const API_URL = import.meta.env.PROD ? "/api" : "http://localhost:8000/api";
|
axios.defaults.withCredentials = true;
|
||||||
|
const API_URL = (import.meta as any).env.PROD
|
||||||
|
? "/api"
|
||||||
|
: "http://localhost:8000/api";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -20,9 +23,11 @@ export interface NoteCreate {
|
||||||
}
|
}
|
||||||
|
|
||||||
const createNote = async (note: NoteCreate) => {
|
const createNote = async (note: NoteCreate) => {
|
||||||
var key = await deriveKey("Test");
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
var noteContent = await encryptString(note.content, key);
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
var noteTitle = await encryptString(note.title, key);
|
|
||||||
|
var noteContent = await encryptString(note.content, encryptionKey);
|
||||||
|
var noteTitle = await encryptString(note.title, encryptionKey);
|
||||||
|
|
||||||
var encryptedNote = {
|
var encryptedNote = {
|
||||||
title: noteTitle,
|
title: noteTitle,
|
||||||
|
|
@ -31,19 +36,20 @@ const createNote = async (note: NoteCreate) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(encryptedNote);
|
console.log(encryptedNote);
|
||||||
return axios.post(`${API_URL}/notes`, encryptedNote);
|
return axios.post(`${API_URL}/notes/`, encryptedNote);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchNotes = async () => {
|
const fetchNotes = async () => {
|
||||||
const { data } = await axios.get(`${API_URL}/notes`);
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
|
const { data } = await axios.get(`${API_URL}/notes/`);
|
||||||
|
|
||||||
console.log(data);
|
console.log(data);
|
||||||
var key = await deriveKey("Test");
|
|
||||||
const decryptedNotes = await Promise.all(
|
const decryptedNotes = await Promise.all(
|
||||||
data.map(async (note: Note) => ({
|
data.map(async (note: Note) => ({
|
||||||
...note,
|
...note,
|
||||||
title: await decryptString(note.title, key),
|
title: await decryptString(note.title, encryptionKey),
|
||||||
content: await decryptString(note.content, key),
|
content: await decryptString(note.content, encryptionKey),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -51,13 +57,15 @@ const fetchNotes = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateNote = async (id: number, note: Partial<Note>) => {
|
const updateNote = async (id: number, note: Partial<Note>) => {
|
||||||
var key = await deriveKey("Test");
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
var encryptedNote: Partial<Note> = {};
|
var encryptedNote: Partial<Note> = {};
|
||||||
if (note.content) {
|
if (note.content) {
|
||||||
encryptedNote.content = await encryptString(note.content, key);
|
encryptedNote.content = await encryptString(note.content, encryptionKey);
|
||||||
}
|
}
|
||||||
if (note.title) {
|
if (note.title) {
|
||||||
encryptedNote.title = await encryptString(note.title, key);
|
encryptedNote.title = await encryptString(note.title, encryptionKey);
|
||||||
}
|
}
|
||||||
if (note.folder_id) {
|
if (note.folder_id) {
|
||||||
encryptedNote.folder_id = note.folder_id;
|
encryptedNote.folder_id = note.folder_id;
|
||||||
|
|
|
||||||
31
frontend/src/components/contextMenus/ContextMenuRenderer.tsx
Normal file
31
frontend/src/components/contextMenus/ContextMenuRenderer.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useContextMenu } from "../../contexts/ContextMenuContext";
|
||||||
|
import { NoteContextMenu } from "./NoteContextMenu";
|
||||||
|
import { FolderContextMenu } from "./FolderContextMenu";
|
||||||
|
|
||||||
|
export const ContextMenuRenderer: React.FC = () => {
|
||||||
|
const { contextMenu, closeContextMenu } = useContextMenu();
|
||||||
|
|
||||||
|
if (!contextMenu) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{contextMenu.type === "note" && (
|
||||||
|
<NoteContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
note={contextMenu.data}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{contextMenu.type === "folder" && (
|
||||||
|
<FolderContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
folder={contextMenu.data}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
123
frontend/src/components/contextMenus/FolderContextMenu.tsx
Normal file
123
frontend/src/components/contextMenus/FolderContextMenu.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { FolderTreeNode } from "../../api/folders";
|
||||||
|
import { useNoteStore } from "../../stores/notesStore";
|
||||||
|
import { folderApi } from "../../api/folders";
|
||||||
|
|
||||||
|
interface FolderContextMenuProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
folder: FolderTreeNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
folder,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { loadFolderTree } = useNoteStore();
|
||||||
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
|
const [newName, setNewName] = useState(folder.name);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm(`Delete "${folder.name}" and all its contents?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await folderApi.delete(folder.id);
|
||||||
|
await loadFolderTree();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete folder:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (newName.trim() && newName !== folder.name) {
|
||||||
|
try {
|
||||||
|
await folderApi.update(folder.id, { name: newName.trim() });
|
||||||
|
await loadFolderTree();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to rename folder:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsRenaming(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSubfolder = async () => {
|
||||||
|
try {
|
||||||
|
await folderApi.create({
|
||||||
|
name: "New Folder",
|
||||||
|
parent_id: folder.id,
|
||||||
|
});
|
||||||
|
await loadFolderTree();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create subfolder:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isRenaming) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
}}
|
||||||
|
className="bg-ctp-surface0 border border-ctp-surface2 rounded-md shadow-lg p-2 min-w-[200px] z-50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleRename();
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setIsRenaming(false);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={handleRename}
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-2 py-1 bg-ctp-surface1 border border-ctp-surface2 rounded text-sm text-ctp-text focus:outline-none focus:border-ctp-mauve"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
}}
|
||||||
|
className="bg-ctp-surface0 border border-ctp-surface2 rounded-md shadow-lg py-1 min-w-[160px] z-50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsRenaming(true)}
|
||||||
|
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateSubfolder}
|
||||||
|
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors"
|
||||||
|
>
|
||||||
|
New Subfolder
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-ctp-surface2 my-1" />
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="w-full text-left px-3 py-1.5 hover:bg-ctp-red hover:text-ctp-base text-sm text-ctp-red transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
82
frontend/src/components/contextMenus/NoteContextMenu.tsx
Normal file
82
frontend/src/components/contextMenus/NoteContextMenu.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import React from "react";
|
||||||
|
import { NoteRead } from "../../api/folders";
|
||||||
|
import { useNoteStore } from "../../stores/notesStore";
|
||||||
|
import { notesApi } from "../../api/notes";
|
||||||
|
|
||||||
|
interface NoteContextMenuProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
note: NoteRead;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
note,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { loadFolderTree, setSelectedNote } = useNoteStore();
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await notesApi.delete(note.id);
|
||||||
|
await loadFolderTree();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete note:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async () => {
|
||||||
|
try {
|
||||||
|
await notesApi.create({
|
||||||
|
title: `${note.title} (Copy)`,
|
||||||
|
content: note.content,
|
||||||
|
folder_id: note.folder_id,
|
||||||
|
});
|
||||||
|
await loadFolderTree();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to duplicate note:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
setSelectedNote(note);
|
||||||
|
onClose();
|
||||||
|
// Focus will be handled by the editor
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
}}
|
||||||
|
className="bg-ctp-surface0 border border-ctp-surface2 rounded-md shadow-lg py-1 min-w-[160px] z-50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleRename}
|
||||||
|
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors"
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-ctp-surface2 my-1" />
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="w-full text-left px-3 py-1.5 hover:bg-ctp-red hover:text-ctp-base text-sm text-ctp-red transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import {
|
|
||||||
BoldItalicUnderlineToggles,
|
|
||||||
codeBlockPlugin,
|
|
||||||
codeMirrorPlugin,
|
|
||||||
diffSourcePlugin,
|
|
||||||
headingsPlugin,
|
|
||||||
imagePlugin,
|
|
||||||
linkPlugin,
|
|
||||||
listsPlugin,
|
|
||||||
markdownShortcutPlugin,
|
|
||||||
MDXEditor,
|
|
||||||
quotePlugin,
|
|
||||||
SandpackConfig,
|
|
||||||
sandpackPlugin,
|
|
||||||
tablePlugin,
|
|
||||||
thematicBreakPlugin,
|
|
||||||
toolbarPlugin,
|
|
||||||
UndoRedo,
|
|
||||||
} from "@mdxeditor/editor";
|
|
||||||
import { useNoteStore } from "../../stores/notesStore";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
|
||||||
|
|
||||||
const simpleSandpackConfig: SandpackConfig = {
|
|
||||||
defaultPreset: "react",
|
|
||||||
presets: [
|
|
||||||
{
|
|
||||||
label: "React",
|
|
||||||
name: "react",
|
|
||||||
meta: "live react",
|
|
||||||
sandpackTemplate: "react",
|
|
||||||
sandpackTheme: "dark",
|
|
||||||
snippetFileName: "/App.js",
|
|
||||||
snippetLanguage: "jsx",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Editor = () => {
|
|
||||||
const { selectedNote, setContent, setTitle, updateNote } = useNoteStore();
|
|
||||||
const { updating, setUpdating } = useUIStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedNote) return;
|
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
setUpdating(true);
|
|
||||||
handleUpdate();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [selectedNote]);
|
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
|
||||||
if (!selectedNote) return;
|
|
||||||
await updateNote(selectedNote.id);
|
|
||||||
console.log(selectedNote.id);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setUpdating(false);
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col overflow-y-auto px-8 py-6">
|
|
||||||
{/* Title input */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Untitled note..."
|
|
||||||
value={selectedNote?.title || ""}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
className="w-full px-0 py-3 mb-4 text-3xl font-semibold bg-transparent border-b border-ctp-surface2 focus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<MDXEditor
|
|
||||||
markdown={selectedNote?.content || ""}
|
|
||||||
key={selectedNote?.id || "new"}
|
|
||||||
onChange={setContent}
|
|
||||||
className="prose prose-invert max-w-none text-ctp-text h-full dark-editor dark-mode"
|
|
||||||
plugins={[
|
|
||||||
headingsPlugin(),
|
|
||||||
toolbarPlugin({
|
|
||||||
toolbarClassName: "toolbar",
|
|
||||||
toolbarContents: () => (
|
|
||||||
<>
|
|
||||||
<UndoRedo />
|
|
||||||
<BoldItalicUnderlineToggles />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
|
|
||||||
tablePlugin(),
|
|
||||||
listsPlugin(),
|
|
||||||
quotePlugin(),
|
|
||||||
thematicBreakPlugin(),
|
|
||||||
linkPlugin(),
|
|
||||||
codeBlockPlugin({ defaultCodeBlockLanguage: "js" }),
|
|
||||||
sandpackPlugin({ sandpackConfig: simpleSandpackConfig }),
|
|
||||||
codeMirrorPlugin({
|
|
||||||
codeBlockLanguages: {
|
|
||||||
js: "JavaScript",
|
|
||||||
css: "CSS",
|
|
||||||
python: "Python",
|
|
||||||
typescript: "TypeScript",
|
|
||||||
html: "HTML",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
imagePlugin(),
|
|
||||||
markdownShortcutPlugin(),
|
|
||||||
diffSourcePlugin({
|
|
||||||
viewMode: "rich-text",
|
|
||||||
diffMarkdown: "boo",
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,28 +1,44 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
import { Note } from "../../api/notes";
|
|
||||||
import { NoteRead } from "../../api/folders";
|
import { NoteRead } from "../../api/folders";
|
||||||
import { useNoteStore } from "../../stores/notesStore";
|
import { useNoteStore } from "../../stores/notesStore";
|
||||||
|
import { useContextMenu } from "../../contexts/ContextMenuContext";
|
||||||
|
|
||||||
export const DraggableNote = ({ note }: { note: NoteRead }) => {
|
export const DraggableNote = ({ note }: { note: NoteRead }) => {
|
||||||
const { selectedNote, setSelectedNote } = useNoteStore();
|
const { selectedNote, setSelectedNote } = useNoteStore();
|
||||||
|
const { openContextMenu } = useContextMenu();
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||||
|
useDraggable({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
data: { type: "note", note },
|
data: { type: "note", note },
|
||||||
});
|
});
|
||||||
const style = transform
|
|
||||||
? {
|
const style = {
|
||||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
transform: transform
|
||||||
}
|
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||||
: undefined;
|
: undefined,
|
||||||
|
opacity: isDragging ? 0 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
<button
|
||||||
|
className="z-20"
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openContextMenu(e.clientX, e.clientY, "note", note);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
key={note.id}
|
key={note.id}
|
||||||
onClick={() => setSelectedNote(note)}
|
onClick={(e) => {
|
||||||
className={` rounded-md px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${
|
setSelectedNote(note);
|
||||||
|
}}
|
||||||
|
className={` rounded-sm px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${
|
||||||
selectedNote?.id === note.id
|
selectedNote?.id === note.id
|
||||||
? "bg-ctp-mauve text-ctp-base"
|
? "bg-ctp-mauve text-ctp-base"
|
||||||
: "hover:bg-ctp-surface1"
|
: "hover:bg-ctp-surface1"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useDroppable, useDraggable } from "@dnd-kit/core";
|
import { useDroppable, useDraggable } from "@dnd-kit/core";
|
||||||
import { Folder, NoteRead } from "../../api/folders";
|
import { Folder } from "../../api/folders";
|
||||||
import { useNoteStore } from "../../stores/notesStore";
|
import { useContextMenu } from "../../contexts/ContextMenuContext";
|
||||||
|
// @ts-ignore
|
||||||
|
import CaretRightIcon from "../../assets/fontawesome/svg/caret-right.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import FolderIcon from "../../assets/fontawesome/svg/folder.svg?react";
|
||||||
|
|
||||||
export const DroppableFolder = ({
|
export const DroppableFolder = ({
|
||||||
folder,
|
folder,
|
||||||
|
|
@ -12,7 +16,7 @@ export const DroppableFolder = ({
|
||||||
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
|
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
collapse: boolean;
|
collapse: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { setSelectedFolder, selectedFolder, selectedNote } = useNoteStore();
|
const { openContextMenu } = useContextMenu();
|
||||||
|
|
||||||
const { isOver, setNodeRef: setDroppableRef } = useDroppable({
|
const { isOver, setNodeRef: setDroppableRef } = useDroppable({
|
||||||
id: folder.id!,
|
id: folder.id!,
|
||||||
|
|
@ -37,7 +41,7 @@ export const DroppableFolder = ({
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
color: isOver ? "green" : undefined,
|
color: isOver ? "green" : undefined,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0 : 1,
|
||||||
transform: transform
|
transform: transform
|
||||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -46,27 +50,24 @@ export const DroppableFolder = ({
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style}>
|
<div ref={setNodeRef} style={style}>
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedFolder(folder.id as number)}
|
onClick={(e) => {
|
||||||
className={`font-semibold mb-1 flex items-center gap-1 px-2 py-1 rounded cursor-pointer ${
|
e.stopPropagation();
|
||||||
selectedFolder === folder.id &&
|
setCollapse(!collapse);
|
||||||
(selectedNote?.folder_id == folder.id || selectedNote == null)
|
}}
|
||||||
? "bg-ctp-surface1"
|
onContextMenu={(e) => {
|
||||||
: "hover:bg-ctp-surface0"
|
e.preventDefault();
|
||||||
}`}
|
e.stopPropagation();
|
||||||
|
openContextMenu(e.clientX, e.clientY, "folder", folder);
|
||||||
|
}}
|
||||||
|
className={`font-semibold mb-1 flex items-center gap-1 pr-1 py-1 rounded cursor-pointer select-none`}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
<i className="fadr fa-folder text-sm"></i>
|
<CaretRightIcon
|
||||||
|
className={`w-4 h-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-ctp-mauve`}
|
||||||
|
/>
|
||||||
|
<FolderIcon className="w-4 h-4 fill-ctp-mauve mr-1" />
|
||||||
{folder.name}
|
{folder.name}
|
||||||
<div
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation(); // Prevent dragging when clicking the collapse button
|
|
||||||
setCollapse(!collapse);
|
|
||||||
}}
|
|
||||||
className="ml-auto"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FolderTreeNode } from "../../api/folders";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { FolderTreeNode, NoteRead } from "../../api/folders";
|
||||||
import { DraggableNote } from "./DraggableNote";
|
import { DraggableNote } from "./DraggableNote";
|
||||||
import { DroppableFolder } from "./DroppableFolder";
|
import { DroppableFolder } from "./DroppableFolder";
|
||||||
|
|
||||||
|
|
@ -15,28 +16,42 @@ export const RecursiveFolder = ({
|
||||||
const [collapse, setCollapse] = useState(false);
|
const [collapse, setCollapse] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={folder.id} className="flex flex-col relative">
|
||||||
key={folder.id}
|
|
||||||
className="flex flex-col"
|
|
||||||
style={{ marginLeft: depth > 0 ? "1.5rem" : "0" }}
|
|
||||||
>
|
|
||||||
<DroppableFolder
|
<DroppableFolder
|
||||||
folder={folder}
|
folder={folder}
|
||||||
setCollapse={setCollapse}
|
setCollapse={setCollapse}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
/>
|
/>
|
||||||
|
<AnimatePresence>
|
||||||
{collapse && (
|
{collapse && (
|
||||||
<>
|
<motion.div
|
||||||
<div className="flex flex-col gap-0.5 ml-6">
|
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">
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
{folder.notes.map((note) => (
|
{folder.notes.map((note) => (
|
||||||
<DraggableNote key={note.id} note={note} />
|
<DraggableNote key={note.id} note={note} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Child Folders */}
|
||||||
{folder.children.map((child) => (
|
{folder.children.map((child) => (
|
||||||
<RecursiveFolder key={child.id} folder={child} depth={depth + 1} />
|
<RecursiveFolder
|
||||||
|
key={child.id}
|
||||||
|
folder={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,47 @@
|
||||||
import React, { useState, useRef, useEffect, SetStateAction } from "react";
|
import React, { useState, useRef, useEffect, SetStateAction } from "react";
|
||||||
import {
|
// @ts-ignore
|
||||||
FolderCreate,
|
import FolderPlusIcon from "../../assets/fontawesome/svg/folder-plus.svg?react";
|
||||||
FolderTreeNode,
|
// @ts-ignore
|
||||||
FolderTreeResponse,
|
import FileCirclePlusIcon from "../../assets/fontawesome/svg/file-circle-plus.svg?react";
|
||||||
NoteRead,
|
// @ts-ignore
|
||||||
folderApi,
|
import FolderIcon from "../../assets/fontawesome/svg/folder.svg?react";
|
||||||
} from "../../api/folders";
|
|
||||||
import { DraggableNote } from "./DraggableNote";
|
import { DraggableNote } from "./DraggableNote";
|
||||||
import { DroppableFolder } from "./DroppableFolder";
|
|
||||||
import { useNoteStore } from "../../stores/notesStore";
|
import { useNoteStore } from "../../stores/notesStore";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { notesApi } from "../../api/notes";
|
|
||||||
import { RecursiveFolder } from "./RecursiveFolder";
|
import { RecursiveFolder } from "./RecursiveFolder";
|
||||||
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
|
import { NoteRead } from "../../api/folders";
|
||||||
|
|
||||||
export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
|
export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
|
||||||
// const [folderTree, setFolderTree] = useState<FolderTreeResponse | null>(null);
|
|
||||||
const [newFolder, setNewFolder] = useState(false);
|
const [newFolder, setNewFolder] = useState(false);
|
||||||
const [newFolderText, setNewFolderText] = useState("");
|
const [newFolderText, setNewFolderText] = useState("");
|
||||||
|
const [activeItem, setActiveItem] = useState<{
|
||||||
|
type: "note" | "folder";
|
||||||
|
data: any;
|
||||||
|
} | null>(null);
|
||||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setSelectedFolder,
|
|
||||||
selectedFolder,
|
|
||||||
folderTree,
|
folderTree,
|
||||||
loadFolderTree,
|
loadFolderTree,
|
||||||
selectedNote,
|
moveNoteToFolder,
|
||||||
setSelectedNote,
|
moveFolderToFolder,
|
||||||
|
createFolder,
|
||||||
} = useNoteStore();
|
} = useNoteStore();
|
||||||
|
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
const { setSideBarResize, sideBarResize } = useUIStore();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (newFolder && newFolderRef.current) {
|
if (newFolder && newFolderRef.current) {
|
||||||
newFolderRef.current.focus();
|
newFolderRef.current.focus();
|
||||||
|
|
@ -41,18 +49,17 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
|
||||||
}, [newFolder]);
|
}, [newFolder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// if (!isAuthenticated) return;
|
||||||
loadFolderTree();
|
loadFolderTree();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (!newFolderText.trim()) return;
|
if (!newFolderText.trim()) return;
|
||||||
const newFolderData: FolderCreate = {
|
await createFolder({
|
||||||
name: newFolderText,
|
name: newFolderText,
|
||||||
parent_id: null,
|
parent_id: null,
|
||||||
};
|
});
|
||||||
await folderApi.create(newFolderData);
|
|
||||||
setNewFolderText("");
|
setNewFolderText("");
|
||||||
loadFolderTree();
|
|
||||||
setNewFolder(false);
|
setNewFolder(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -63,7 +70,17 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
|
||||||
});
|
});
|
||||||
const sensors = useSensors(pointer);
|
const sensors = useSensors(pointer);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
const { active } = event;
|
||||||
|
if (active.data.current?.type === "note") {
|
||||||
|
setActiveItem({ type: "note", data: active.data.current.note });
|
||||||
|
} else if (active.data.current?.type === "folder") {
|
||||||
|
setActiveItem({ type: "folder", data: active.data.current.folder });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
setActiveItem(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
||||||
|
|
@ -77,9 +94,7 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
|
||||||
|
|
||||||
if (active.data.current?.type === "note") {
|
if (active.data.current?.type === "note") {
|
||||||
console.log("Updating note", active.id, "to folder", over.id);
|
console.log("Updating note", active.id, "to folder", over.id);
|
||||||
await notesApi.update(active.id as number, {
|
await moveNoteToFolder(active.id as number, over.id as number);
|
||||||
folder_id: over.id as number,
|
|
||||||
});
|
|
||||||
} else if (active.data.current?.type === "folder") {
|
} else if (active.data.current?.type === "folder") {
|
||||||
// Prevent dropping folder into itself
|
// Prevent dropping folder into itself
|
||||||
if (active.data.current.folder.id === over.id) {
|
if (active.data.current.folder.id === over.id) {
|
||||||
|
|
@ -94,30 +109,76 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
|
||||||
over.id,
|
over.id,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const response = await folderApi.update(active.data.current.folder.id, {
|
await moveFolderToFolder(
|
||||||
parent_id: over.id as number,
|
active.data.current.folder.id,
|
||||||
});
|
over.id as number,
|
||||||
console.log("Folder update response:", response);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update folder:", error);
|
console.error("Failed to update folder:", error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFolderTree();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
setIsResizing(true);
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
// Calculate new width based on mouse position from the left edge
|
||||||
|
const newWidth = e.clientX;
|
||||||
|
|
||||||
|
if (newWidth >= 200 && newWidth <= 500) {
|
||||||
|
setSideBarResize(newWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isResizing) {
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isResizing]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext onDragEnd={handleDragEnd} autoScroll={false} sensors={sensors}>
|
<DndContext
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
autoScroll={false}
|
||||||
|
sensors={sensors}
|
||||||
|
>
|
||||||
|
<div className="flex-row-reverse flex">
|
||||||
<div
|
<div
|
||||||
className="bg-ctp-mantle border-r border-ctp-surface2 w-[300px] p-4 overflow-y-auto sm:block hidden flex-col gap-3"
|
className="h-screen bg-ctp-surface0 w-0.5 hover:cursor-ew-resize hover:bg-ctp-mauve transition-colors"
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onMouseDown={handleMouseDown}
|
||||||
onTouchMove={(e) => e.preventDefault()}
|
></div>
|
||||||
|
<div
|
||||||
|
className="flex flex-col min-h-full"
|
||||||
|
style={{ width: `${sideBarResize}px` }}
|
||||||
>
|
>
|
||||||
<SidebarHeader
|
<SidebarHeader
|
||||||
clearSelection={clearSelection}
|
clearSelection={clearSelection}
|
||||||
setNewFolder={setNewFolder}
|
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 */}
|
{/* New folder input */}
|
||||||
{newFolder && (
|
{newFolder && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
|
|
@ -127,7 +188,7 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
|
||||||
value={newFolderText}
|
value={newFolderText}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Folder name..."
|
placeholder="Folder name..."
|
||||||
className="border border-ctp-mauve rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-ctp-mauve bg-ctp-base text-ctp-text placeholder:text-ctp-overlay0"
|
className="standard-input"
|
||||||
ref={newFolderRef}
|
ref={newFolderRef}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
|
|
@ -149,17 +210,31 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Orphaned notes */}
|
{/* Orphaned notes */}
|
||||||
{folderTree?.orphaned_notes && folderTree.orphaned_notes.length > 0 && (
|
{folderTree?.orphaned_notes &&
|
||||||
|
folderTree.orphaned_notes.length > 0 && (
|
||||||
<div className="mt-4 flex flex-col gap-1">
|
<div className="mt-4 flex flex-col gap-1">
|
||||||
{/*<div className="text-ctp-subtext0 text-sm font-medium mb-1 px-2">
|
|
||||||
Unsorted
|
|
||||||
</div>*/}
|
|
||||||
{folderTree.orphaned_notes.map((note) => (
|
{folderTree.orphaned_notes.map((note) => (
|
||||||
<DraggableNote key={note.id} note={note} />
|
<DraggableNote key={note.id} note={note} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -171,25 +246,30 @@ export const SidebarHeader = ({
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
|
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { createNote, selectedFolder } = useNoteStore();
|
||||||
|
const handleCreate = async () => {
|
||||||
|
await createNote({
|
||||||
|
title: "Untitled",
|
||||||
|
content: "",
|
||||||
|
folder_id: selectedFolder,
|
||||||
|
});
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1">
|
||||||
<h2 className="text-lg font-semibold text-ctp-text">FastNotes</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setNewFolder(true)}
|
onClick={() => setNewFolder(true)}
|
||||||
className="hover:bg-ctp-mauve group transition-colors rounded-md p-1.5"
|
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2"
|
||||||
title="New folder"
|
title="New folder"
|
||||||
>
|
>
|
||||||
<i className="fadr fa-folder-plus text-base text-ctp-mauve group-hover:text-ctp-base transition-colors"></i>
|
<FolderPlusIcon className="w-4 h-4 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={clearSelection}
|
onClick={handleCreate}
|
||||||
className="hover:bg-ctp-mauve group transition-colors rounded-md p-1.5"
|
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2 fill-ctp-mauve hover:fill-ctp-base"
|
||||||
title="New note"
|
title="New note"
|
||||||
>
|
>
|
||||||
<i className="fadr fa-file-circle-plus text-base text-ctp-mauve group-hover:text-ctp-base transition-colors"></i>
|
<FileCirclePlusIcon className="w-4 h-4 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
114
frontend/src/contexts/ContextMenuContext.tsx
Normal file
114
frontend/src/contexts/ContextMenuContext.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
type: "note" | "folder" | "editor" | null;
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuContextType {
|
||||||
|
contextMenu: ContextMenuState | null;
|
||||||
|
openContextMenu: (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
type: "note" | "folder" | "editor",
|
||||||
|
data: any,
|
||||||
|
) => void;
|
||||||
|
closeContextMenu: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextMenuContext = createContext<ContextMenuContextType | null>(null);
|
||||||
|
|
||||||
|
export const useContextMenu = () => {
|
||||||
|
const context = useContext(ContextMenuContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useContextMenu must be used within a ContextMenuProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ContextMenuProvider = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
|
||||||
|
const openContextMenu = (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
type: "note" | "folder" | "editor",
|
||||||
|
data: any,
|
||||||
|
) => {
|
||||||
|
// Estimate menu height (you can adjust this based on your menu)
|
||||||
|
const menuHeight = 200;
|
||||||
|
const menuWidth = 160;
|
||||||
|
|
||||||
|
// Adjust y position if too close to bottom
|
||||||
|
const adjustedY =
|
||||||
|
y + menuHeight > window.innerHeight
|
||||||
|
? window.innerHeight - menuHeight - 10
|
||||||
|
: y;
|
||||||
|
|
||||||
|
// Adjust x position if too close to right edge
|
||||||
|
const adjustedX =
|
||||||
|
x + menuWidth > window.innerWidth
|
||||||
|
? window.innerWidth - menuWidth - 10
|
||||||
|
: x;
|
||||||
|
|
||||||
|
setContextMenu({ x: adjustedX, y: adjustedY, type, data });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeContextMenu = () => {
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = () => {
|
||||||
|
if (contextMenu) {
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (contextMenu) {
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", handleClick);
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
};
|
||||||
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
// Close on escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && contextMenu) {
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenuContext.Provider
|
||||||
|
value={{ contextMenu, openContextMenu, closeContextMenu }}
|
||||||
|
>
|
||||||
|
{contextMenu && (
|
||||||
|
<div
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
closeContextMenu();
|
||||||
|
}}
|
||||||
|
className=" h-screen w-screen bg-ctp-crust/25 z-40 fixed top-0 left-0"
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</ContextMenuContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -2,6 +2,46 @@
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
@import "@catppuccin/tailwindcss/macchiato.css";
|
@import "@catppuccin/tailwindcss/macchiato.css";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-base:
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Map Tailwind classes to CSS variables */
|
||||||
|
--color-ctp-base: var(--color-ctp-base);
|
||||||
|
--color-ctp-mantle: var(--color-ctp-mantle);
|
||||||
|
--color-ctp-crust: var(--color-ctp-crust);
|
||||||
|
|
||||||
|
--color-ctp-text: var(--color-ctp-text);
|
||||||
|
--color-ctp-subtext0: #a5adcb;
|
||||||
|
--color-ctp-overlay0: #6e738d;
|
||||||
|
|
||||||
|
--color-ctp-mauve: var(--color-ctp-mauve);
|
||||||
|
--color-ctp-blue: var(--color-ctp-blue);
|
||||||
|
--color-ctp-green: #a6da95;
|
||||||
|
--color-ctp-red: #ed8796;
|
||||||
|
--color-ctp-yellow: #eed49f;
|
||||||
|
--color-ctp-teal: #8bd5ca;
|
||||||
|
--color-ctp-sapphire: #7dc4e4;
|
||||||
|
--color-ctp-peach: #f5a97f;
|
||||||
|
|
||||||
|
/* Surface colors */
|
||||||
|
--color-ctp-surface0: #363a4f;
|
||||||
|
--color-ctp-surface1: #494d64;
|
||||||
|
--color-ctp-surface2: #5b6078;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default values (Macchiato) - injected by JS, but good as fallback */
|
||||||
|
:root {
|
||||||
|
--color-ctp-base: #24273a;
|
||||||
|
--color-ctp-mantle: #1e2030;
|
||||||
|
--color-ctp-crust: #181926;
|
||||||
|
--color-ctp-text: #cad3f5;
|
||||||
|
--color-ctp-mauve: #c6a0f6;
|
||||||
|
--color-ctp-blue: #8aadf4;
|
||||||
|
}
|
||||||
|
|
||||||
/* Override MDXEditor and all its children */
|
/* Override MDXEditor and all its children */
|
||||||
[class*="mdxeditor"],
|
[class*="mdxeditor"],
|
||||||
._mdxeditor-root-content-editable,
|
._mdxeditor-root-content-editable,
|
||||||
|
|
@ -10,42 +50,6 @@ div[contenteditable="true"] {
|
||||||
color: var(--color-ctp-text) !important;
|
color: var(--color-ctp-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override prose specifically */
|
|
||||||
.prose,
|
|
||||||
.prose * {
|
|
||||||
color: var(--color-ctp-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override list markers */
|
|
||||||
.prose ul li::marker,
|
|
||||||
.prose ol li::marker,
|
|
||||||
ul li::marker,
|
|
||||||
ol li::marker {
|
|
||||||
color: var(--color-ctp-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-class {
|
|
||||||
background-color: var(--color-ctp-mantle) !important;
|
|
||||||
border-bottom: 1px solid var(--color-ctp-surface2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-class button {
|
|
||||||
color: var(--color-ctp-text) !important;
|
|
||||||
background-color: var(--color-ctp-surface0) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
_listItemChecked_1tncs_73::before {
|
|
||||||
background-color: var(--color-ctp-mauve);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mdxeditor-popup-container > * {
|
|
||||||
background-color: var(--color-ctp-base) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
background-color: var(--color-ctp-crust) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
._listItemChecked_1tncs_73::before {
|
._listItemChecked_1tncs_73::before {
|
||||||
--accentSolid: var(--color-ctp-mauve) !important;
|
--accentSolid: var(--color-ctp-mauve) !important;
|
||||||
border-color: var(--color-ctp-mauve-900) !important;
|
border-color: var(--color-ctp-mauve-900) !important;
|
||||||
|
|
@ -55,3 +59,7 @@ _listItemChecked_1tncs_73::before {
|
||||||
._listItemChecked_1tncs_73::after {
|
._listItemChecked_1tncs_73::after {
|
||||||
border-color: var(--color-ctp-mauve-900) !important;
|
border-color: var(--color-ctp-mauve-900) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.standard-input {
|
||||||
|
@apply border border-ctp-mauve rounded-sm px-2 py-1 w-full focus:outline-none focus:ring-2 focus:ring-ctp-mauve bg-ctp-base text-ctp-text placeholder:text-ctp-overlay0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./main.css";
|
import "./main.css";
|
||||||
import "./assets/fontawesome/js/fontawesome.min.js";
|
// import "./assets/fontawesome/js/fontawesome.min.js";
|
||||||
import "./assets/fontawesome/js/duotone-regular.js";
|
// import "./assets/fontawesome/js/duotone-regular.js";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,47 @@
|
||||||
import {
|
import { useEffect, useRef, useState } from "react";
|
||||||
BoldItalicUnderlineToggles,
|
import { notesApi } from "../api/notes";
|
||||||
codeBlockPlugin,
|
|
||||||
codeMirrorPlugin,
|
|
||||||
diffSourcePlugin,
|
|
||||||
headingsPlugin,
|
|
||||||
imagePlugin,
|
|
||||||
linkPlugin,
|
|
||||||
listsPlugin,
|
|
||||||
markdownShortcutPlugin,
|
|
||||||
MDXEditor,
|
|
||||||
quotePlugin,
|
|
||||||
SandpackConfig,
|
|
||||||
sandpackPlugin,
|
|
||||||
tablePlugin,
|
|
||||||
thematicBreakPlugin,
|
|
||||||
toolbarPlugin,
|
|
||||||
UndoRedo,
|
|
||||||
DiffSourceToggleWrapper,
|
|
||||||
} from "@mdxeditor/editor";
|
|
||||||
import { SetStateAction, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
folderApi,
|
|
||||||
FolderCreate,
|
|
||||||
FolderTreeNode,
|
|
||||||
FolderTreeResponse,
|
|
||||||
NoteRead,
|
|
||||||
} from "../api/folders";
|
|
||||||
import { NoteCreate, notesApi } from "../api/notes";
|
|
||||||
import "../main.css";
|
import "../main.css";
|
||||||
import {
|
import { motion } from "framer-motion";
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
|
|
||||||
import "@mdxeditor/editor/style.css";
|
import "@mdxeditor/editor/style.css";
|
||||||
import { DroppableFolder } from "../components/sidebar/DroppableFolder";
|
|
||||||
import { DraggableNote } from "../components/sidebar/DraggableNote";
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import CheckIcon from "../assets/fontawesome/svg/circle-check.svg?react";
|
import CheckIcon from "../assets/fontawesome/svg/circle-check.svg?react";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import SpinnerIcon from "../assets/fontawesome/svg/rotate.svg?react";
|
import SpinnerIcon from "../assets/fontawesome/svg/rotate.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import WarningIcon from "../assets/fontawesome/svg/circle-exclamation.svg?react";
|
||||||
import { useNoteStore } from "../stores/notesStore";
|
import { useNoteStore } from "../stores/notesStore";
|
||||||
import { create } from "zustand";
|
|
||||||
import { Sidebar } from "../components/sidebar/SideBar";
|
import { Sidebar } from "../components/sidebar/SideBar";
|
||||||
import { Editor } from "../components/editor/Editor";
|
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { TiptapEditor } from "./TipTap";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import { Login } from "./Login";
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
// const [folderTree, setFolderTree] = useState<FolderTreeResponse | null>(null);
|
|
||||||
// const [selectedNote, setSelectedNote] = useState<NoteRead | null>(null);
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const [newFolder, setNewFolder] = useState(false);
|
const [newFolder, setNewFolder] = useState(false);
|
||||||
const [newFolderText, setNewFolderText] = useState("");
|
const [lastSavedNote, setLastSavedNote] = useState<{
|
||||||
// const [selectedFolder, setSelectedFolder] = useState<number | null>(null);
|
id: number;
|
||||||
const [encrypted, setEncrypted] = useState(false);
|
title: string;
|
||||||
// const [updating, setUpdating] = useState(false);
|
content: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setSelectedFolder,
|
|
||||||
selectedFolder,
|
|
||||||
folderTree,
|
|
||||||
loadFolderTree,
|
loadFolderTree,
|
||||||
createNote,
|
|
||||||
createFolder,
|
|
||||||
updateNote,
|
updateNote,
|
||||||
setSelectedNote,
|
setSelectedNote,
|
||||||
|
setContent,
|
||||||
selectedNote,
|
selectedNote,
|
||||||
|
setTitle,
|
||||||
} = useNoteStore();
|
} = useNoteStore();
|
||||||
|
|
||||||
const { updating } = useUIStore();
|
const { isAuthenticated, encryptionKey } = useAuthStore();
|
||||||
|
|
||||||
|
const { showModal, setShowModal } = useUIStore();
|
||||||
|
|
||||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// if (!isAuthenticated) return;
|
||||||
|
console.log(encryptionKey);
|
||||||
loadFolderTree();
|
loadFolderTree();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -86,73 +51,103 @@ function Home() {
|
||||||
}
|
}
|
||||||
}, [newFolder]);
|
}, [newFolder]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!title.trim()) return;
|
|
||||||
await createNote({ title, content, folder_id: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
await notesApi.delete(id);
|
|
||||||
loadFolderTree();
|
|
||||||
clearSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
setSelectedNote(null);
|
setSelectedNote(null);
|
||||||
setTitle("");
|
};
|
||||||
setContent("");
|
|
||||||
|
const { updating, setUpdating } = useUIStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedNote) return;
|
||||||
|
if (!encryptionKey) return; // Don't try to save without encryption key
|
||||||
|
|
||||||
|
// Check if content or title actually changed (not just selecting a different note)
|
||||||
|
const hasChanges =
|
||||||
|
lastSavedNote &&
|
||||||
|
lastSavedNote.id === selectedNote.id &&
|
||||||
|
(lastSavedNote.title !== selectedNote.title ||
|
||||||
|
lastSavedNote.content !== selectedNote.content);
|
||||||
|
|
||||||
|
// If it's a new note selection, just update lastSavedNote without saving
|
||||||
|
if (!lastSavedNote || lastSavedNote.id !== selectedNote.id) {
|
||||||
|
setLastSavedNote({
|
||||||
|
id: selectedNote.id,
|
||||||
|
title: selectedNote.title,
|
||||||
|
content: selectedNote.content,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setUpdating(true);
|
||||||
|
await handleUpdate();
|
||||||
|
setLastSavedNote({
|
||||||
|
id: selectedNote.id,
|
||||||
|
title: selectedNote.title,
|
||||||
|
content: selectedNote.content,
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [selectedNote, encryptionKey]);
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!selectedNote) return;
|
||||||
|
if (!encryptionKey) {
|
||||||
|
setUpdating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateNote(selectedNote.id);
|
||||||
|
console.log(selectedNote.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update note:", error);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
setUpdating(false);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 />}
|
||||||
|
|
||||||
<Sidebar clearSelection={clearSelection} />
|
<Sidebar clearSelection={clearSelection} />
|
||||||
|
|
||||||
{/* 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">
|
||||||
{/* Top accent bar */}
|
{/*<Editor />*/}
|
||||||
<div className="w-full bg-ctp-crust h-1 shrink-0"></div>
|
<input
|
||||||
|
type="text"
|
||||||
<Editor />
|
placeholder="Untitled note..."
|
||||||
|
value={selectedNote?.title || ""}
|
||||||
{/* Action bar */}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
<div className="flex items-center gap-3 px-8 py-4 border-t border-ctp-surface2 bg-ctp-mantle shrink-0">
|
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"
|
||||||
{selectedNote ? (
|
/>
|
||||||
<>
|
<TiptapEditor
|
||||||
{/*<button
|
key={selectedNote?.id}
|
||||||
onClick={handleUpdate}
|
content={selectedNote?.content || ""}
|
||||||
className="px-2 py-0.5 bg-ctp-blue text-ctp-base rounded-lg hover:bg-ctp-sapphire transition-colors font-medium shadow-sm"
|
onChange={setContent}
|
||||||
>
|
/>
|
||||||
Save
|
|
||||||
</button>*/}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(selectedNote.id)}
|
|
||||||
className="px-2 py-0.5 bg-ctp-red text-ctp-base rounded-lg hover:bg-ctp-maroon transition-colors font-medium shadow-sm"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={clearSelection}
|
|
||||||
className="px-2 py-0.5 bg-ctp-surface0 text-ctp-text rounded-lg hover:bg-ctp-surface1 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
className="px-2 py-0.5 bg-ctp-green text-ctp-base rounded-lg hover:bg-ctp-teal transition-colors font-medium shadow-sm"
|
|
||||||
>
|
|
||||||
Create Note
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<div className="fixed bottom-4 right-4 bg-ctp-surface0 border border-ctp-surface2 rounded-lg px-2 py-0.5 flex items-center gap-2.5 shadow-lg backdrop-blur-sm">
|
<div
|
||||||
{updating ? (
|
className="fixed bottom-2 right-3 bg-ctp-surface0 border border-ctp-surface2 rounded-sm px-2 py-0.5 flex items-center gap-2.5 shadow-lg backdrop-blur-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!encryptionKey) {
|
||||||
|
setShowModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!encryptionKey ? (
|
||||||
|
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-ctp-yellow [&_.fa-secondary]:fill-ctp-orange" />
|
||||||
|
) : updating ? (
|
||||||
<>
|
<>
|
||||||
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-ctp-blue [&_.fa-secondary]:fill-ctp-sapphire" />
|
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-ctp-blue [&_.fa-secondary]:fill-ctp-sapphire" />
|
||||||
<span className="text-sm text-ctp-subtext0 font-medium">
|
<span className="text-sm text-ctp-subtext0 font-medium">
|
||||||
|
|
@ -171,3 +166,22 @@ function Home() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Home;
|
export default Home;
|
||||||
|
|
||||||
|
const Modal = () => {
|
||||||
|
const { setShowModal } = useUIStore();
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="absolute h-screen w-screen flex items-center justify-center bg-ctp-crust/60 z-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5"
|
||||||
|
>
|
||||||
|
<Login />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
56
frontend/src/pages/Login.tsx
Normal file
56
frontend/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
export const Login = () => {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [remember, setRemember] = useState(false);
|
||||||
|
const { login, setRememberMe } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setShowModal } = useUIStore();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setRememberMe(remember);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
setShowModal(false);
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="gap-2 flex flex-col">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
className="standard-input"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="standard-input"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <div>{error}</div>}
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="check box"
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div>Remember me?</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
// src/pages/TestPage.tsx
|
|
||||||
import { FC } from "react";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { MDXEditor, SandpackConfig } from "@mdxeditor/editor";
|
|
||||||
import {
|
|
||||||
headingsPlugin,
|
|
||||||
listsPlugin,
|
|
||||||
quotePlugin,
|
|
||||||
thematicBreakPlugin,
|
|
||||||
linkPlugin,
|
|
||||||
codeBlockPlugin,
|
|
||||||
codeMirrorPlugin,
|
|
||||||
sandpackPlugin,
|
|
||||||
markdownShortcutPlugin,
|
|
||||||
toolbarPlugin,
|
|
||||||
BoldItalicUnderlineToggles,
|
|
||||||
} from "@mdxeditor/editor";
|
|
||||||
|
|
||||||
import "@mdxeditor/editor/style.css";
|
|
||||||
|
|
||||||
const simpleSandpackConfig: SandpackConfig = {
|
|
||||||
defaultPreset: "react",
|
|
||||||
presets: [
|
|
||||||
{
|
|
||||||
label: "React",
|
|
||||||
name: "react",
|
|
||||||
meta: "live react",
|
|
||||||
sandpackTemplate: "react",
|
|
||||||
sandpackTheme: "dark",
|
|
||||||
snippetFileName: "/App.js",
|
|
||||||
snippetLanguage: "jsx",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MarkdownPage: FC = () => {
|
|
||||||
const markdown = `
|
|
||||||
# This is *perfect*!
|
|
||||||
- TestPage
|
|
||||||
- te
|
|
||||||
`;
|
|
||||||
return (
|
|
||||||
<MDXEditor
|
|
||||||
markdown={markdown}
|
|
||||||
plugins={[
|
|
||||||
toolbarPlugin({
|
|
||||||
toolbarClassName: "my-class",
|
|
||||||
toolbarContents: () => (
|
|
||||||
<>
|
|
||||||
<BoldItalicUnderlineToggles />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
headingsPlugin(),
|
|
||||||
listsPlugin(),
|
|
||||||
quotePlugin(),
|
|
||||||
thematicBreakPlugin(),
|
|
||||||
linkPlugin(),
|
|
||||||
codeBlockPlugin({ defaultCodeBlockLanguage: "js" }),
|
|
||||||
sandpackPlugin({ sandpackConfig: simpleSandpackConfig }),
|
|
||||||
codeMirrorPlugin({
|
|
||||||
codeBlockLanguages: { js: "JavaScript", css: "CSS" },
|
|
||||||
}),
|
|
||||||
markdownShortcutPlugin(),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
51
frontend/src/pages/Register.tsx
Normal file
51
frontend/src/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export const Register = () => {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { register } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(username, email, password);
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <div>{error}</div>}
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Similar pattern for Register.tsx
|
||||||
11
frontend/src/pages/Test.tsx
Normal file
11
frontend/src/pages/Test.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export const Test = () => {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center bg-ctp-base p-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Folder name..."
|
||||||
|
className="standard-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
198
frontend/src/pages/TipTap.tsx
Normal file
198
frontend/src/pages/TipTap.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { Markdown } from "tiptap-markdown";
|
||||||
|
import { ListKit } from "@tiptap/extension-list";
|
||||||
|
import "./tiptap.css";
|
||||||
|
// @ts-ignore
|
||||||
|
import BoldIcon from "../assets/fontawesome/svg/bold.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import ItalicIcon from "../assets/fontawesome/svg/italic.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import StrikethroughIcon from "../assets/fontawesome/svg/strikethrough.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import CodeIcon from "../assets/fontawesome/svg/code.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import ListUlIcon from "../assets/fontawesome/svg/list-ul.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import ListOlIcon from "../assets/fontawesome/svg/list-ol.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import SquareCheckIcon from "../assets/fontawesome/svg/square-check.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import CodeBracketIcon from "../assets/fontawesome/svg/code-simple.svg?react";
|
||||||
|
// @ts-ignore
|
||||||
|
import QuoteLeftIcon from "../assets/fontawesome/svg/quote-left.svg?react";
|
||||||
|
|
||||||
|
interface TiptapEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (markdown: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TiptapEditor = ({
|
||||||
|
placeholder,
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
}: TiptapEditorProps) => {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
ListKit,
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: {
|
||||||
|
levels: [1, 2, 3, 4, 5, 6],
|
||||||
|
},
|
||||||
|
codeBlock: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "code-block",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bulletList: false,
|
||||||
|
orderedList: false,
|
||||||
|
listItem: false,
|
||||||
|
listKeymap: false,
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: placeholder || "Start writing...",
|
||||||
|
}),
|
||||||
|
Markdown.configure({
|
||||||
|
html: false,
|
||||||
|
transformPastedText: true,
|
||||||
|
transformCopiedText: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "prose prose-invert max-w-none focus:outline-none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
const markdown = (
|
||||||
|
editor.storage as Record<string, any>
|
||||||
|
).markdown.getMarkdown();
|
||||||
|
onChange(markdown);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tiptap-editor h-full">
|
||||||
|
{/* Toolbar */}
|
||||||
|
{/*<div className="editor-toolbar">
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
className={editor.isActive("bold") ? "active" : ""}
|
||||||
|
title="Bold (Ctrl+B)"
|
||||||
|
>
|
||||||
|
<BoldIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
className={editor.isActive("italic") ? "active" : ""}
|
||||||
|
title="Italic (Ctrl+I)"
|
||||||
|
>
|
||||||
|
<ItalicIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
className={editor.isActive("strike") ? "active" : ""}
|
||||||
|
title="Strikethrough"
|
||||||
|
>
|
||||||
|
<StrikethroughIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
className={editor.isActive("code") ? "active" : ""}
|
||||||
|
title="Inline code"
|
||||||
|
>
|
||||||
|
<CodeIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider"></div>
|
||||||
|
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||||
|
}
|
||||||
|
className={editor.isActive("heading", { level: 1 }) ? "active" : ""}
|
||||||
|
title="Heading 1"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||||
|
}
|
||||||
|
className={editor.isActive("heading", { level: 2 }) ? "active" : ""}
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||||
|
}
|
||||||
|
className={editor.isActive("heading", { level: 3 }) ? "active" : ""}
|
||||||
|
title="Heading 3"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider"></div>
|
||||||
|
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
className={editor.isActive("bulletList") ? "active" : ""}
|
||||||
|
title="Bullet list"
|
||||||
|
>
|
||||||
|
<ListUlIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
className={editor.isActive("orderedList") ? "active" : ""}
|
||||||
|
title="Numbered list"
|
||||||
|
>
|
||||||
|
<ListOlIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||||
|
className={editor.isActive("taskList") ? "active" : ""}
|
||||||
|
title="Task list"
|
||||||
|
>
|
||||||
|
<SquareCheckIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
className={editor.isActive("codeBlock") ? "active" : ""}
|
||||||
|
title="Code block"
|
||||||
|
>
|
||||||
|
<CodeBracketIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
className={editor.isActive("blockquote") ? "active" : ""}
|
||||||
|
title="Quote"
|
||||||
|
>
|
||||||
|
<QuoteLeftIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider"></div>
|
||||||
|
</div>*/}
|
||||||
|
|
||||||
|
{/* Editor content */}
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className="editor-content h-min-screen p-4!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
172
frontend/src/pages/tiptap.css
Normal file
172
frontend/src/pages/tiptap.css
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
@reference "../main.css";
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
@apply w-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
@apply bg-ctp-mantle rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-ctp-surface2 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-ctp-overlay0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-ctp-surface2) var(--color-ctp-mantle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor {
|
||||||
|
@apply flex flex-col h-full bg-ctp-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
@apply text-ctp-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
@apply flex gap-2 px-4 bg-ctp-mantle border-b border-ctp-surface2 flex-wrap items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group {
|
||||||
|
@apply flex gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-divider {
|
||||||
|
@apply w-px h-6 bg-ctp-surface2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar button {
|
||||||
|
@apply p-2 bg-transparent border-none rounded-sm text-ctp-text cursor-pointer transition-all duration-150 text-sm font-semibold min-w-8 flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar button:hover:not(:disabled) {
|
||||||
|
@apply bg-ctp-surface0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar button.active {
|
||||||
|
@apply bg-ctp-mauve text-ctp-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar button:disabled {
|
||||||
|
@apply opacity-40 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror:focus {
|
||||||
|
@apply outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
@apply float-left text-ctp-overlay0 pointer-events-none h-0;
|
||||||
|
}
|
||||||
|
.ProseMirror ul {
|
||||||
|
@apply mb-0!;
|
||||||
|
}
|
||||||
|
.ProseMirror h1 {
|
||||||
|
@apply text-3xl font-bold text-ctp-mauve mt-8 mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h2 {
|
||||||
|
@apply text-2xl font-semibold text-ctp-blue mt-6 mb-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h3 {
|
||||||
|
@apply text-xl font-semibold text-ctp-teal mt-5 mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror code {
|
||||||
|
@apply bg-ctp-surface0 text-ctp-peach px-1.5 py-0.5 rounded text-sm;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror .code-block {
|
||||||
|
@apply bg-ctp-surface0 border border-ctp-surface2 rounded-sm p-4 my-4 overflow-x-auto;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror .code-block code {
|
||||||
|
@apply bg-transparent p-0 text-ctp-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror blockquote {
|
||||||
|
@apply border-l-4 border-ctp-mauve pl-4 ml-0 text-ctp-subtext0 italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror hr {
|
||||||
|
@apply border-none border-t-2 border-ctp-surface2 my-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror a {
|
||||||
|
@apply text-ctp-blue underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror a:hover {
|
||||||
|
@apply text-ctp-sapphire;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror strong {
|
||||||
|
@apply text-ctp-peach font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror em {
|
||||||
|
@apply text-ctp-yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task List (Checkboxes) */
|
||||||
|
.ProseMirror ul[data-type="taskList"] {
|
||||||
|
@apply list-none p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror ul[data-type="taskList"] > li {
|
||||||
|
@apply flex flex-row items-baseline m-0 p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror ul[data-type="taskList"] > li > label {
|
||||||
|
@apply flex-none mr-2 select-none flex items-center h-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror ul[data-type="taskList"] > li > label input[type="checkbox"] {
|
||||||
|
@apply cursor-pointer m-0 accent-ctp-mauve;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror ul[data-type="taskList"] > li > div {
|
||||||
|
@apply flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror ul[data-type="taskList"] > li > div p {
|
||||||
|
@apply m-0 p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror li[data-checked="true"] > div > p {
|
||||||
|
@apply line-through text-ctp-overlay0;
|
||||||
|
text-decoration-style: wavy;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror u {
|
||||||
|
@apply decoration-ctp-mauve;
|
||||||
|
text-decoration-style: wavy;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror li::marker {
|
||||||
|
@apply text-ctp-mauve;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tiptap.css */
|
||||||
|
.ProseMirror ul,
|
||||||
|
.ProseMirror ol {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror li > p {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
151
frontend/src/stores/authStore.ts
Normal file
151
frontend/src/stores/authStore.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import {
|
||||||
|
deriveKey,
|
||||||
|
generateMasterKey,
|
||||||
|
unwrapMasterKey,
|
||||||
|
wrapMasterKey,
|
||||||
|
} from "../api/encryption";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
salt: string; // For key derivation
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
encryptionKey: CryptoKey | null; // Memory only!
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
rememberMe: boolean;
|
||||||
|
setRememberMe: (boolean) => void;
|
||||||
|
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
register: (
|
||||||
|
username: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
initEncryptionKey: (password: string, salt: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = "http://localhost:8000/api";
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
user: null,
|
||||||
|
encryptionKey: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
rememberMe: false,
|
||||||
|
setRememberMe: (bool) => {
|
||||||
|
set({ rememberMe: bool });
|
||||||
|
},
|
||||||
|
initEncryptionKey: async (password: string, salt: string) => {
|
||||||
|
// Use user-specific salt instead of hardcoded
|
||||||
|
const key = await deriveKey(password, salt);
|
||||||
|
set({ encryptionKey: key });
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (username: string, email: string, password: string) => {
|
||||||
|
const masterKey = await generateMasterKey();
|
||||||
|
const salt = crypto.randomUUID();
|
||||||
|
const kek = await deriveKey(password, salt);
|
||||||
|
const wrappedMasterKey = await wrapMasterKey(masterKey, kek);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
wrappedMasterKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Store the master key directly (not derived from password)
|
||||||
|
set({
|
||||||
|
user: data.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
encryptionKey: masterKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await response.json();
|
||||||
|
|
||||||
|
// Derive KEK and unwrap master key
|
||||||
|
const kek = await deriveKey(password, user.salt);
|
||||||
|
const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek);
|
||||||
|
|
||||||
|
// Store master key in memory
|
||||||
|
set({ encryptionKey: masterKey, user, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
await fetch(`${API_URL}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
encryptionKey: null, // Wipe from memory
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAuth: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/auth/me`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
get().logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
set({ user: data.user, isAuthenticated: true });
|
||||||
|
} catch (e) {
|
||||||
|
get().logout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "auth-storage",
|
||||||
|
partialize: (state) => {
|
||||||
|
return {
|
||||||
|
user: state.user,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -1,13 +1,36 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { devtools, persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import {
|
import {
|
||||||
folderApi,
|
folderApi,
|
||||||
FolderCreate,
|
FolderCreate,
|
||||||
|
FolderTreeNode,
|
||||||
FolderTreeResponse,
|
FolderTreeResponse,
|
||||||
NoteRead,
|
NoteRead,
|
||||||
} from "../api/folders";
|
} from "../api/folders";
|
||||||
import { Note, NoteCreate, notesApi } from "../api/notes";
|
import { Note, NoteCreate, notesApi } from "../api/notes";
|
||||||
import { getSelectedNode } from "@mdxeditor/editor";
|
|
||||||
|
// Helper function to update a note within the folder tree
|
||||||
|
const updateNoteInTree = (
|
||||||
|
tree: FolderTreeResponse | null,
|
||||||
|
updatedNote: NoteRead,
|
||||||
|
): FolderTreeResponse | null => {
|
||||||
|
if (!tree) return null;
|
||||||
|
|
||||||
|
const updateNotesInFolder = (folder: FolderTreeNode): FolderTreeNode => ({
|
||||||
|
...folder,
|
||||||
|
notes: folder.notes.map((note) =>
|
||||||
|
note.id === updatedNote.id ? updatedNote : note,
|
||||||
|
),
|
||||||
|
children: folder.children.map(updateNotesInFolder),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders: tree.folders.map(updateNotesInFolder),
|
||||||
|
orphaned_notes: tree.orphaned_notes.map((note) =>
|
||||||
|
note.id === updatedNote.id ? updatedNote : note,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface NoteState {
|
interface NoteState {
|
||||||
folderTree: FolderTreeResponse | null;
|
folderTree: FolderTreeResponse | null;
|
||||||
|
|
@ -22,9 +45,13 @@ interface NoteState {
|
||||||
createFolder: (folder: FolderCreate) => Promise<void>;
|
createFolder: (folder: FolderCreate) => Promise<void>;
|
||||||
setSelectedFolder: (id: number | null) => void;
|
setSelectedFolder: (id: number | null) => void;
|
||||||
setSelectedNote: (id: NoteRead | null) => void;
|
setSelectedNote: (id: NoteRead | null) => void;
|
||||||
|
moveNoteToFolder: (noteId: number, folderId: number) => Promise<void>;
|
||||||
|
moveFolderToFolder: (folderId: number, newParentId: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useNoteStore = create<NoteState>()((set, get) => ({
|
export const useNoteStore = create<NoteState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
folderTree: null,
|
folderTree: null,
|
||||||
selectedFolder: null,
|
selectedFolder: null,
|
||||||
selectedNote: null,
|
selectedNote: null,
|
||||||
|
|
@ -32,36 +59,125 @@ export const useNoteStore = create<NoteState>()((set, get) => ({
|
||||||
setContent: (content) => {
|
setContent: (content) => {
|
||||||
const currentNote = get().selectedNote;
|
const currentNote = get().selectedNote;
|
||||||
if (currentNote) {
|
if (currentNote) {
|
||||||
set({ selectedNote: { ...currentNote, content: content } });
|
const updatedNote = { ...currentNote, content: content };
|
||||||
|
set({
|
||||||
|
selectedNote: updatedNote,
|
||||||
|
folderTree: updateNoteInTree(get().folderTree, updatedNote),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setTitle: (title) => {
|
setTitle: (title) => {
|
||||||
const currentNote = get().selectedNote;
|
const currentNote = get().selectedNote;
|
||||||
if (currentNote) {
|
if (currentNote) {
|
||||||
set({ selectedNote: { ...currentNote, title: title } });
|
const updatedNote = { ...currentNote, title: title };
|
||||||
|
set({
|
||||||
|
selectedNote: updatedNote,
|
||||||
|
folderTree: updateNoteInTree(get().folderTree, updatedNote),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFolderTree: async () => {
|
loadFolderTree: async () => {
|
||||||
const data = await folderApi.tree();
|
const data = await folderApi.tree();
|
||||||
|
console.log("getting tree");
|
||||||
set({ folderTree: data });
|
set({ folderTree: data });
|
||||||
},
|
},
|
||||||
|
|
||||||
createNote: async (note: NoteCreate) => {
|
createNote: async (note: Partial<NoteRead>) => {
|
||||||
await notesApi.create(note);
|
const response = await notesApi.create(note as NoteCreate);
|
||||||
await get().loadFolderTree();
|
const newNote = response.data as NoteRead;
|
||||||
|
console.log(newNote.id);
|
||||||
|
const noteToAppend: NoteRead = {
|
||||||
|
...newNote,
|
||||||
|
title: note.title || "Untitled",
|
||||||
|
content: note.content || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tree = get().folderTree;
|
||||||
|
if (!tree) return;
|
||||||
|
|
||||||
|
if (note.folder_id) {
|
||||||
|
const addNoteToFolder = (folder: FolderTreeNode): FolderTreeNode => {
|
||||||
|
if (folder.id === note.folder_id) {
|
||||||
|
return {
|
||||||
|
...folder,
|
||||||
|
notes: [...folder.notes, noteToAppend],
|
||||||
|
children: folder.children.map(addNoteToFolder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...folder,
|
||||||
|
children: folder.children.map(addNoteToFolder),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
set({
|
||||||
|
folderTree: {
|
||||||
|
folders: tree.folders.map(addNoteToFolder),
|
||||||
|
orphaned_notes: tree.orphaned_notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Add to orphaned notes
|
||||||
|
set({
|
||||||
|
folderTree: {
|
||||||
|
folders: tree.folders,
|
||||||
|
orphaned_notes: [...tree.orphaned_notes, noteToAppend],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createFolder: async (folder: FolderCreate) => {
|
createFolder: async (folder: FolderCreate) => {
|
||||||
await folderApi.create(folder);
|
const response = await folderApi.create(folder);
|
||||||
await get().loadFolderTree();
|
const newFolder = response.data;
|
||||||
|
const tree = get().folderTree;
|
||||||
|
if (!tree) return;
|
||||||
|
|
||||||
|
const newFolderNode: FolderTreeNode = {
|
||||||
|
id: newFolder.id,
|
||||||
|
name: newFolder.name,
|
||||||
|
notes: [],
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (folder.parent_id) {
|
||||||
|
// Add as child of parent folder
|
||||||
|
const addToParent = (f: FolderTreeNode): FolderTreeNode => {
|
||||||
|
if (f.id === folder.parent_id) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
children: [...f.children, newFolderNode],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
children: f.children.map(addToParent),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
set({
|
||||||
|
folderTree: {
|
||||||
|
folders: tree.folders.map(addToParent),
|
||||||
|
orphaned_notes: tree.orphaned_notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Add as top-level folder
|
||||||
|
set({
|
||||||
|
folderTree: {
|
||||||
|
folders: [...tree.folders, newFolderNode],
|
||||||
|
orphaned_notes: tree.orphaned_notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateNote: async (id: number) => {
|
updateNote: async (id: number) => {
|
||||||
const note = get().selectedNote as Partial<Note>;
|
const note = get().selectedNote as Partial<Note>;
|
||||||
await notesApi.update(id, note);
|
await notesApi.update(id, note);
|
||||||
await get().loadFolderTree();
|
// await get().loadFolderTree();
|
||||||
},
|
},
|
||||||
|
|
||||||
setSelectedFolder: (id: number | null) => {
|
setSelectedFolder: (id: number | null) => {
|
||||||
|
|
@ -71,4 +187,131 @@ export const useNoteStore = create<NoteState>()((set, get) => ({
|
||||||
setSelectedNote: (id: NoteRead | null) => {
|
setSelectedNote: (id: NoteRead | null) => {
|
||||||
set({ selectedNote: id });
|
set({ selectedNote: id });
|
||||||
},
|
},
|
||||||
}));
|
|
||||||
|
moveNoteToFolder: async (noteId: number, folderId: number) => {
|
||||||
|
const tree = get().folderTree;
|
||||||
|
if (!tree) return;
|
||||||
|
|
||||||
|
// Find and remove the note from its current location
|
||||||
|
let noteToMove: NoteRead | null = null;
|
||||||
|
|
||||||
|
// Check orphaned notes
|
||||||
|
const orphanedIndex = tree.orphaned_notes.findIndex(
|
||||||
|
(n) => n.id === noteId,
|
||||||
|
);
|
||||||
|
if (orphanedIndex !== -1) {
|
||||||
|
noteToMove = tree.orphaned_notes[orphanedIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check folders recursively
|
||||||
|
const findAndRemoveNote = (folder: FolderTreeNode): FolderTreeNode => {
|
||||||
|
const noteIndex = folder.notes.findIndex((n) => n.id === noteId);
|
||||||
|
if (noteIndex !== -1) {
|
||||||
|
noteToMove = folder.notes[noteIndex];
|
||||||
|
return {
|
||||||
|
...folder,
|
||||||
|
notes: folder.notes.filter((n) => n.id !== noteId),
|
||||||
|
children: folder.children.map(findAndRemoveNote),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...folder,
|
||||||
|
children: folder.children.map(findAndRemoveNote),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add note to target folder
|
||||||
|
const addNoteToFolder = (folder: FolderTreeNode): FolderTreeNode => {
|
||||||
|
if (folder.id === folderId && noteToMove) {
|
||||||
|
return {
|
||||||
|
...folder,
|
||||||
|
notes: [...folder.notes, { ...noteToMove, folder_id: folderId }],
|
||||||
|
children: folder.children.map(addNoteToFolder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...folder,
|
||||||
|
children: folder.children.map(addNoteToFolder),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update local tree
|
||||||
|
let newFolders = tree.folders.map(findAndRemoveNote);
|
||||||
|
let newOrphaned = tree.orphaned_notes.filter((n) => n.id !== noteId);
|
||||||
|
newFolders = newFolders.map(addNoteToFolder);
|
||||||
|
|
||||||
|
set({
|
||||||
|
folderTree: {
|
||||||
|
folders: newFolders,
|
||||||
|
orphaned_notes: newOrphaned,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update backend
|
||||||
|
await notesApi.update(noteId, { folder_id: folderId });
|
||||||
|
},
|
||||||
|
|
||||||
|
moveFolderToFolder: async (folderId: number, newParentId: number) => {
|
||||||
|
const tree = get().folderTree;
|
||||||
|
if (!tree) return;
|
||||||
|
|
||||||
|
let folderToMove: FolderTreeNode | null = null;
|
||||||
|
|
||||||
|
// Find and remove folder from current location
|
||||||
|
const findAndRemoveFolder = (
|
||||||
|
folders: FolderTreeNode[],
|
||||||
|
): FolderTreeNode[] => {
|
||||||
|
return folders
|
||||||
|
.filter((f) => {
|
||||||
|
if (f.id === folderId) {
|
||||||
|
folderToMove = f;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((f) => ({
|
||||||
|
...f,
|
||||||
|
children: findAndRemoveFolder(f.children),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add folder to new parent
|
||||||
|
const addFolderToParent = (
|
||||||
|
folders: FolderTreeNode[],
|
||||||
|
): FolderTreeNode[] => {
|
||||||
|
return folders.map((f) => {
|
||||||
|
if (f.id === newParentId && folderToMove) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
children: [...f.children, folderToMove],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
children: addFolderToParent(f.children),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let newFolders = findAndRemoveFolder(tree.folders);
|
||||||
|
newFolders = addFolderToParent(newFolders);
|
||||||
|
|
||||||
|
set({
|
||||||
|
folderTree: {
|
||||||
|
folders: newFolders,
|
||||||
|
orphaned_notes: tree.orphaned_notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update backend
|
||||||
|
await folderApi.update(folderId, { parent_id: newParentId });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "notes-storage",
|
||||||
|
partialize: (state) => ({
|
||||||
|
folderTree: state.folderTree,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,40 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
updating: boolean;
|
updating: boolean;
|
||||||
setUpdating: (update: boolean) => void;
|
setUpdating: (update: boolean) => void;
|
||||||
|
|
||||||
|
showModal: boolean;
|
||||||
|
setShowModal: (show: boolean) => void;
|
||||||
|
|
||||||
|
sideBarResize: number;
|
||||||
|
setSideBarResize: (size: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>()((set, get) => ({
|
export const useUIStore = create<UIState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
updating: false,
|
updating: false,
|
||||||
setUpdating: (update) => {
|
setUpdating: (update) => {
|
||||||
set({ updating: update });
|
set({ updating: update });
|
||||||
},
|
},
|
||||||
}));
|
showModal: false,
|
||||||
|
setShowModal: (show) => {
|
||||||
|
set({ showModal: show });
|
||||||
|
},
|
||||||
|
sideBarResize: 300,
|
||||||
|
setSideBarResize: (size) => {
|
||||||
|
set({ sideBarResize: size });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "ui-store",
|
||||||
|
partialize: (state) => {
|
||||||
|
return {
|
||||||
|
sideBarResize: state.sideBarResize,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue