Initial push.
This commit is contained in:
commit
9d47a464eb
53 changed files with 15228 additions and 0 deletions
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
*.pyc
|
||||
dist/
|
||||
node_modules/
|
||||
*.svg
|
||||
backend/.env
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.vscode/
|
||||
.idea/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.md
|
||||
!README.md
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
.zed
|
||||
backend/notes.db
|
||||
frontend/src/assets/fontawesome/js/duotone-regular.js
|
||||
frontend/src/assets/fontawesome/js/fontawesome.min.js
|
||||
frontend/src/pages/Import.tsx
|
||||
29
backend/Dockerfile
Normal file
29
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
|
||||
COPY app ./app
|
||||
|
||||
COPY start.sh /app/start.sh
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["/app/start.sh"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
74
backend/app/auth.py
Normal file
74
backend/app/auth.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt # pyright: ignore[reportMissingImports]
|
||||
from fastapi import ( # pyright: ignore[reportMissingImports]
|
||||
Cookie,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
status,
|
||||
)
|
||||
from sqlmodel import Session, select # pyright: ignore[reportMissingImports]
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import Session as SessionModel
|
||||
from app.models import User
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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.now() + 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.now():
|
||||
return None
|
||||
|
||||
return session.user
|
||||
|
||||
|
||||
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
|
||||
26
backend/app/database.py
Normal file
26
backend/app/database.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import os
|
||||
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports]
|
||||
from sqlmodel import Session, SQLModel, create_engine # type: ignore
|
||||
|
||||
load_dotenv()
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
if not DATABASE_URL or DATABASE_URL.strip() == "":
|
||||
DATABASE_URL = "sqlite:////app/data/notes.db"
|
||||
print(f"WARNING: DATABASE_URL not set, using default: {DATABASE_URL}")
|
||||
else:
|
||||
print(f"Using DATABASE_URL: {DATABASE_URL}")
|
||||
|
||||
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||
|
||||
engine = create_engine(DATABASE_URL, echo=True, connect_args=connect_args)
|
||||
|
||||
|
||||
def create_db_and_tables():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
40
backend/app/main.py
Normal file
40
backend/app/main.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import os
|
||||
|
||||
from fastapi import FastAPI # type: ignore
|
||||
from fastapi.middleware.cors import CORSMiddleware # type:ignore
|
||||
|
||||
from app.database import create_db_and_tables
|
||||
from app.routes import auth, folders, notes, tags
|
||||
|
||||
app = FastAPI(title="Notes API")
|
||||
|
||||
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
create_db_and_tables()
|
||||
|
||||
|
||||
app.include_router(notes.router, prefix="/api")
|
||||
app.include_router(folders.router, prefix="/api")
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(tags.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Notes API"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check endpoint for Docker and Coolify"""
|
||||
return {"status": "healthy"}
|
||||
162
backend/app/models.py
Normal file
162
backend/app/models.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlmodel import Field, Relationship, SQLModel # type: ignore
|
||||
|
||||
|
||||
class User(SQLModel, table=True): # type: ignore
|
||||
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.now)
|
||||
|
||||
# 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")
|
||||
tags: List["Tag"] = Relationship(back_populates="user")
|
||||
|
||||
|
||||
class Session(SQLModel, table=True): # type: ignore
|
||||
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.now)
|
||||
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
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(max_length=255)
|
||||
parent_id: Optional[int] = Field(default=None, foreign_key="folder.id")
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
|
||||
# Relationships
|
||||
parent: Optional["Folder"] = Relationship(
|
||||
back_populates="children", sa_relationship_kwargs={"remote_side": "Folder.id"}
|
||||
)
|
||||
children: List["Folder"] = Relationship(back_populates="parent")
|
||||
notes: List["Note"] = Relationship(back_populates="folder")
|
||||
user: User = Relationship(back_populates="folders")
|
||||
|
||||
|
||||
class NoteTag(SQLModel, table=True): #type: ignore
|
||||
note_id: int = Field(foreign_key="note.id", primary_key=True)
|
||||
tag_id: int = Field(foreign_key="tag.id", primary_key=True)
|
||||
|
||||
|
||||
class Tag(SQLModel, table=True): # type: ignore
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(max_length=255)
|
||||
parent_id: Optional[int] = Field(default=None, foreign_key="tag.id")
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user: User = Relationship(back_populates="tags")
|
||||
parent: Optional["Tag"] = Relationship(
|
||||
back_populates="children",
|
||||
sa_relationship_kwargs={"remote_side": "Tag.id"}
|
||||
)
|
||||
children: List["Tag"] = Relationship(back_populates="parent")
|
||||
notes: List["Note"] = Relationship(back_populates="tags", link_model=NoteTag)
|
||||
|
||||
|
||||
|
||||
class Note(SQLModel, table=True): # type: ignore
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
title: str = Field(max_length=255)
|
||||
content: str
|
||||
folder_id: Optional[int] = Field(default=None, foreign_key="folder.id")
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
|
||||
#Relationships
|
||||
folder: Optional[Folder] = Relationship(back_populates="notes")
|
||||
user: User = Relationship(back_populates="notes")
|
||||
tags: List[Tag] = Relationship(back_populates="notes", link_model=NoteTag)
|
||||
|
||||
|
||||
|
||||
# API Response models
|
||||
class TagRead(SQLModel):
|
||||
id: int
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TagCreate(SQLModel):
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class TagUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class TagTreeNode(SQLModel):
|
||||
id: int
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
children: List["TagTreeNode"] = []
|
||||
|
||||
|
||||
class TagTreeResponse(SQLModel):
|
||||
tags: List[TagTreeNode]
|
||||
|
||||
|
||||
class NoteRead(SQLModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
folder_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tags: List[TagRead] = []
|
||||
|
||||
|
||||
class FolderTreeNode(SQLModel):
|
||||
id: int
|
||||
name: str
|
||||
notes: List[NoteRead] = []
|
||||
children: List["FolderTreeNode"] = []
|
||||
|
||||
|
||||
class FolderTreeResponse(SQLModel):
|
||||
folders: List[FolderTreeNode]
|
||||
orphaned_notes: List[NoteRead]
|
||||
|
||||
|
||||
# Create/Update models
|
||||
class NoteCreate(SQLModel):
|
||||
title: str
|
||||
content: str
|
||||
folder_id: Optional[int] = None
|
||||
|
||||
|
||||
class NoteUpdate(SQLModel):
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
folder_id: Optional[int] = None
|
||||
|
||||
|
||||
class FolderCreate(SQLModel):
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class FolderUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
172
backend/app/routes/auth.py
Normal file
172
backend/app/routes/auth.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
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
|
||||
from fastapi import ( # pyright: ignore[reportMissingImports]
|
||||
APIRouter,
|
||||
Cookie,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from sqlmodel import Session, SQLModel, select # pyright: ignore[reportMissingImports]
|
||||
|
||||
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
|
||||
wrapped_master_key: str
|
||||
|
||||
|
||||
@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,
|
||||
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}")
|
||||
def revoke_session(
|
||||
session_token: str,
|
||||
current_user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
session = db.exec(
|
||||
select(SessionModel)
|
||||
.where(SessionModel.session_id == session_token)
|
||||
.where(SessionModel.user_id == current_user.id)
|
||||
).first()
|
||||
|
||||
if session:
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Session revoked"}
|
||||
114
backend/app/routes/folders.py
Normal file
114
backend/app/routes/folders.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from typing import List
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.database import get_session
|
||||
from app.models import (
|
||||
Folder,
|
||||
FolderCreate,
|
||||
FolderTreeNode,
|
||||
FolderTreeResponse,
|
||||
FolderUpdate,
|
||||
Note,
|
||||
NoteRead,
|
||||
User,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException # type: ignore
|
||||
from sqlalchemy.orm import selectinload # pyright: ignore[reportMissingImports]
|
||||
from sqlmodel import Session, select # type: ignore
|
||||
|
||||
router = APIRouter(prefix="/folders", tags=["folders"])
|
||||
|
||||
|
||||
def build_folder_tree_node(folder: Folder) -> FolderTreeNode:
|
||||
"""Recursively build a folder tree node with notes and children"""
|
||||
return FolderTreeNode(
|
||||
id=folder.id, # pyright: ignore[reportArgumentType]
|
||||
name=folder.name,
|
||||
notes=[NoteRead.model_validate(note) for note in folder.notes],
|
||||
children=[build_folder_tree_node(child) for child in folder.children],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tree", response_model=FolderTreeResponse)
|
||||
def get_folder_tree(
|
||||
current_user: User = Depends(require_auth), session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get complete folder tree with notes"""
|
||||
|
||||
top_level_folders = session.exec(
|
||||
select(Folder)
|
||||
.options(selectinload(Folder.notes).selectinload(Note.tags))
|
||||
.where(Folder.parent_id == None)
|
||||
.where(Folder.user_id == current_user.id)
|
||||
).all()
|
||||
|
||||
orphaned_notes = session.exec(
|
||||
select(Note)
|
||||
.options(selectinload(Note.tags))
|
||||
.where(Note.folder_id == None)
|
||||
.where(Note.user_id == current_user.id)
|
||||
).all()
|
||||
|
||||
tree = [build_folder_tree_node(folder) for folder in top_level_folders]
|
||||
|
||||
return FolderTreeResponse(
|
||||
folders=tree,
|
||||
orphaned_notes=[NoteRead.model_validate(note) for note in orphaned_notes],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Folder])
|
||||
def list_folders(session: Session = Depends(get_session)):
|
||||
"""Get flat list of all folders"""
|
||||
folders = session.exec(select(Folder)).all()
|
||||
return folders
|
||||
|
||||
|
||||
@router.post("/", response_model=Folder)
|
||||
def create_folder(
|
||||
folder: FolderCreate,
|
||||
current_user: User = Depends(require_auth),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Create a new 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.commit()
|
||||
session.refresh(db_folder)
|
||||
return db_folder
|
||||
|
||||
|
||||
@router.delete("/{folder_id}")
|
||||
def delete_folder(folder_id: int, session: Session = Depends(get_session)):
|
||||
"""Delete a folder"""
|
||||
folder = session.get(Folder, folder_id)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
session.delete(folder)
|
||||
session.commit()
|
||||
return {"message": "Folder deleted"}
|
||||
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
def update_folder(
|
||||
folder_id: int, folder_update: FolderUpdate, session: Session = Depends(get_session)
|
||||
):
|
||||
"""Update a folder"""
|
||||
folder = session.get(Folder, folder_id)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
update_data = folder_update.model_dump(exclude_unset=True)
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(folder, key, value)
|
||||
|
||||
session.add(folder)
|
||||
session.commit()
|
||||
|
||||
session.refresh(folder)
|
||||
|
||||
return folder
|
||||
72
backend/app/routes/notes.py
Normal file
72
backend/app/routes/notes.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from datetime import datetime
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.database import get_session
|
||||
from app.models import Note, NoteCreate, NoteRead, NoteUpdate, User
|
||||
from fastapi import ( # pyright: ignore[reportMissingImports]
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from sqlmodel import Session, select # pyright: ignore[reportMissingImports]
|
||||
|
||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[NoteRead])
|
||||
def list_notes(session: Session = Depends(get_session)):
|
||||
notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() # pyright: ignore[reportAttributeAccessIssue]
|
||||
return notes
|
||||
|
||||
|
||||
@router.post("/", response_model=Note)
|
||||
def create_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.commit()
|
||||
session.refresh(db_note)
|
||||
return db_note
|
||||
|
||||
|
||||
@router.get("/{note_id}", response_model=Note)
|
||||
def get_note(note_id: int, session: Session = Depends(get_session)):
|
||||
note = session.get(Note, note_id)
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
return note
|
||||
|
||||
|
||||
@router.patch("/{note_id}", response_model=Note)
|
||||
def update_note(
|
||||
note_id: int, note_update: NoteUpdate, session: Session = Depends(get_session)
|
||||
):
|
||||
note = session.get(Note, note_id)
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
update_data = note_update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(note, key, value)
|
||||
|
||||
note.updated_at = datetime.now()
|
||||
session.add(note)
|
||||
session.commit()
|
||||
session.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
def delete_note(note_id: int, session: Session = Depends(get_session)):
|
||||
note = session.get(Note, note_id)
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
session.delete(note)
|
||||
session.commit()
|
||||
return {"message": "Note deleted"}
|
||||
95
backend/app/routes/tags.py
Normal file
95
backend/app/routes/tags.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from app.auth import require_auth
|
||||
from app.database import get_session
|
||||
from app.models import (
|
||||
NoteTag,
|
||||
Tag,
|
||||
TagCreate,
|
||||
TagTreeNode,
|
||||
TagTreeResponse,
|
||||
User,
|
||||
)
|
||||
from fastapi import ( # pyright: ignore[reportMissingImports]
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from sqlalchemy.orm import selectinload # pyright: ignore[reportMissingImports]
|
||||
from sqlmodel import Session, select # pyright: ignore[reportMissingImports]
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[Tag])
|
||||
def list_tags(session: Session = Depends(get_session)):
|
||||
tags = session.exec(select(Tag)).all()
|
||||
return tags
|
||||
|
||||
|
||||
@router.post("/", response_model=Tag)
|
||||
def create_tag(
|
||||
tag: TagCreate,
|
||||
current_user: User = Depends(require_auth),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
tag_data = tag.model_dump()
|
||||
tag_data["user_id"] = current_user.id
|
||||
db_tag = Tag.model_validate(tag_data)
|
||||
|
||||
session.add(db_tag)
|
||||
session.commit()
|
||||
session.refresh(db_tag)
|
||||
return db_tag
|
||||
|
||||
|
||||
def build_tag_tree_node(tag: Tag) -> TagTreeNode:
|
||||
return TagTreeNode(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
parent_id=tag.parent_id,
|
||||
created_at=tag.created_at,
|
||||
children=[build_tag_tree_node(child) for child in tag.children],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tree", response_model=TagTreeResponse)
|
||||
def get_tag_tree(session: Session = Depends(get_session)):
|
||||
top_level_tags = session.exec(
|
||||
select(Tag).options(selectinload(Tag.children)).where(Tag.parent_id == None)
|
||||
).all()
|
||||
|
||||
tree = [build_tag_tree_node(tag) for tag in top_level_tags]
|
||||
return TagTreeResponse(tags=tree)
|
||||
|
||||
|
||||
@router.post("/note/{note_id}/tag/{tag_id}", response_model=NoteTag)
|
||||
def add_tag_to_note(
|
||||
note_id: int,
|
||||
tag_id: int,
|
||||
current_user: User = Depends(require_auth),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
existing = session.exec(
|
||||
select(NoteTag)
|
||||
.where(NoteTag.note_id == note_id)
|
||||
.where(NoteTag.tag_id == tag_id)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return {"message": "Tag already added"}
|
||||
|
||||
note_tag = NoteTag(note_id=note_id, tag_id=tag_id)
|
||||
session.add(note_tag)
|
||||
session.commit()
|
||||
|
||||
return note_tag
|
||||
|
||||
|
||||
@router.delete("/{tag_id}")
|
||||
def delete_note(tag_id: int, session: Session = Depends(get_session)):
|
||||
tag = session.get(Tag, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
session.delete(tag)
|
||||
session.commit()
|
||||
return {"message": "tag deleted"}
|
||||
54
backend/requirements.txt
Normal file
54
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.11.0
|
||||
bcrypt==5.0.0
|
||||
certifi==2025.11.12
|
||||
cffi==2.0.0
|
||||
click==8.3.1
|
||||
cryptography==46.0.3
|
||||
dnspython==2.8.0
|
||||
ecdsa==0.19.1
|
||||
email-validator==2.3.0
|
||||
fastapi==0.121.3
|
||||
fastapi-cli==0.0.16
|
||||
fastapi-cloud-cli==0.4.0
|
||||
fastar==0.6.0
|
||||
greenlet==3.2.4
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.7.1
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
Jinja2==3.1.6
|
||||
markdown-it-py==4.0.0
|
||||
MarkupSafe==3.0.3
|
||||
mdurl==0.1.2
|
||||
passlib==1.7.4
|
||||
pyasn1==0.6.1
|
||||
pycparser==2.23
|
||||
pydantic==2.12.4
|
||||
pydantic_core==2.41.5
|
||||
Pygments==2.19.2
|
||||
python-dotenv==1.2.1
|
||||
python-jose==3.5.0
|
||||
python-multipart==0.0.20
|
||||
PyYAML==6.0.3
|
||||
rich==14.2.0
|
||||
rich-toolkit==0.16.0
|
||||
rignore==0.7.6
|
||||
rsa==4.9.1
|
||||
sentry-sdk==2.45.0
|
||||
shellingham==1.5.4
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.44
|
||||
sqlmodel==0.0.27
|
||||
starlette==0.50.0
|
||||
typer==0.20.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.38.0
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.1.1
|
||||
websockets==15.0.1
|
||||
18
backend/start.sh
Normal file
18
backend/start.sh
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo "Starting Jotzy API..."
|
||||
echo "========================================="
|
||||
echo "DATABASE_URL: ${DATABASE_URL:-'not set'}"
|
||||
echo "CORS_ORIGINS: ${CORS_ORIGINS:-'not set'}"
|
||||
echo "SECRET_KEY: ${SECRET_KEY:+'***set***'}"
|
||||
echo "Working directory: $(pwd)"
|
||||
echo "Contents of /app:"
|
||||
ls -la /app
|
||||
echo "========================================="
|
||||
|
||||
mkdir -p /app/data
|
||||
echo "Created/verified /app/data directory"
|
||||
|
||||
echo "Starting uvicorn..."
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips "*"
|
||||
41
compose.yaml
Normal file
41
compose.yaml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
services:
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: jotyz-api
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-sqlite:////app/data/notes.db}
|
||||
- SECRET_KEY=${SECRET_KEY:-change-this-in-production}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-*}
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- api_data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 40s
|
||||
retries: 3
|
||||
|
||||
ui:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL:-/api}
|
||||
container_name: jotyz-ui
|
||||
expose:
|
||||
- "80"
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
api_data:
|
||||
27
frontend/.dockerignore
Normal file
27
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.git/
|
||||
.gitignore
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.vscode/
|
||||
.idea/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.md
|
||||
!README.md
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
compose*.yaml
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# ---------- Builder ----------
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Accept build argument
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci --prefer-offline --no-audit
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---------- Runtime ----------
|
||||
FROM nginx:stable-alpine AS runtime
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Add healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/health || exit 1
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notes App</title>
|
||||
</head>
|
||||
<body style="margin: 0">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
48
frontend/nginx.conf
Normal file
48
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Static files with caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# API proxy - routes /api requests to backend service
|
||||
location /api {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
9788
frontend/package-lock.json
generated
Normal file
9788
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
60
frontend/package.json
Normal file
60
frontend/package.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"name": "note-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"generate-types": "openapi-typescript http://localhost:8000/openapi.json -o src/types/api.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@mdxeditor/editor": "^3.49.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.15.3",
|
||||
"@tiptap/extension-placeholder": "^3.12.1",
|
||||
"@tiptap/react": "^3.12.1",
|
||||
"@tiptap/starter-kit": "^3.12.1",
|
||||
"axios": "^1.13.2",
|
||||
"framer-motion": "^12.23.25",
|
||||
"highlight.js": "^11.11.1",
|
||||
"humps": "^2.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lowlight": "^3.3.0",
|
||||
"openapi-fetch": "^0.15.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@catppuccin/tailwindcss": "^1.0.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/humps": "^2.0.6",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
"jsdom": "^27.3.0",
|
||||
"openapi-typescript": "^7.10.1",
|
||||
"type-fest": "^5.3.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vitest": "^4.0.15"
|
||||
}
|
||||
}
|
||||
31
frontend/src/App.tsx
Normal file
31
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import Home from "./pages/Home/Home.tsx";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Register } from "./pages/Register";
|
||||
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>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
</Routes>
|
||||
<ContextMenuRenderer />
|
||||
</BrowserRouter>
|
||||
</ContextMenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
67
frontend/src/api/client.ts
Normal file
67
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// frontend/src/api/client.ts
|
||||
import createClient from "openapi-fetch";
|
||||
import { camelizeKeys, decamelizeKeys } from "humps";
|
||||
import type { paths } from "@/types/api";
|
||||
|
||||
const API_URL = import.meta.env.PROD ? "/api" : "http://localhost:8000/api";
|
||||
|
||||
export const client = createClient<paths>({
|
||||
baseUrl: API_URL,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
client.use({
|
||||
async onRequest({ request }) {
|
||||
const cloned = request.clone();
|
||||
|
||||
try {
|
||||
const bodyText = await cloned.text();
|
||||
if (bodyText) {
|
||||
const bodyJson = JSON.parse(bodyText);
|
||||
const transformedBody = decamelizeKeys(bodyJson);
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
credentials: request.credentials,
|
||||
mode: request.mode,
|
||||
cache: request.cache,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
integrity: request.integrity,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// If not JSON, pass through unchanged
|
||||
}
|
||||
|
||||
return request;
|
||||
},
|
||||
|
||||
async onResponse({ response }) {
|
||||
if (response.body) {
|
||||
try {
|
||||
const clonedResponse = response.clone();
|
||||
const json = await clonedResponse.json();
|
||||
const transformedData = camelizeKeys(json);
|
||||
|
||||
return new Response(JSON.stringify(transformedData), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} catch (e) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
export default client;
|
||||
66
frontend/src/api/encryption.test.ts
Normal file
66
frontend/src/api/encryption.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// src/api/encryption.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveKey,
|
||||
wrapMasterKey,
|
||||
unwrapMasterKey,
|
||||
generateMasterKey,
|
||||
} from "./encryption";
|
||||
|
||||
describe("Encryption", () => {
|
||||
it("should derive consistent keys from same password and salt", async () => {
|
||||
const password = "testPassword123";
|
||||
const salt = "test-salt";
|
||||
|
||||
const key1 = await deriveKey(password, salt);
|
||||
const key2 = await deriveKey(password, salt);
|
||||
|
||||
const testMessage = "test data";
|
||||
const testData = new TextEncoder().encode(testMessage);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key1,
|
||||
testData,
|
||||
);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key2,
|
||||
encrypted,
|
||||
);
|
||||
|
||||
const decryptedMessage = new TextDecoder().decode(decrypted);
|
||||
expect(decryptedMessage).toBe(testMessage);
|
||||
});
|
||||
|
||||
it("should wrap and unwrap master key correctly", async () => {
|
||||
const masterKey = await generateMasterKey();
|
||||
const password = "testPassword123";
|
||||
const salt = "test-salt";
|
||||
const kek = await deriveKey(password, salt);
|
||||
|
||||
const wrapped = await wrapMasterKey(masterKey, kek);
|
||||
const unwrapped = await unwrapMasterKey(wrapped, kek);
|
||||
|
||||
const testMessage = "test message";
|
||||
const testData = new TextEncoder().encode(testMessage);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
masterKey,
|
||||
testData,
|
||||
);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
unwrapped,
|
||||
encrypted,
|
||||
);
|
||||
|
||||
const decryptedMessage = new TextDecoder().decode(decrypted);
|
||||
expect(decryptedMessage).toBe(testMessage);
|
||||
});
|
||||
});
|
||||
190
frontend/src/api/encryption.tsx
Normal file
190
frontend/src/api/encryption.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { components } from "@/types/api";
|
||||
// encryption.tsx
|
||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
||||
import { FolderTreeResponse } from "./folders";
|
||||
export type Tag = CamelCasedPropertiesDeep<components["schemas"]["Tag"]>;
|
||||
export type FolderTreeNode = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderTreeNode"]
|
||||
>;
|
||||
export type TagTreeNode = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["TagTreeNode"]
|
||||
>;
|
||||
|
||||
export interface DecryptedTagNode {
|
||||
id?: number | null | undefined;
|
||||
name: string;
|
||||
parentId?: number | null;
|
||||
createdAt?: string;
|
||||
parentPath: string;
|
||||
children: DecryptedTagNode[];
|
||||
}
|
||||
|
||||
export async function deriveKey(password: string, salt: string) {
|
||||
const enc = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: enc.encode(salt),
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
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"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptString(
|
||||
text: string,
|
||||
key: CryptoKey,
|
||||
): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
enc.encode(text),
|
||||
);
|
||||
|
||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(encrypted), iv.length);
|
||||
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
export async function decryptString(encrypted: string, key: CryptoKey) {
|
||||
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, 12);
|
||||
const data = combined.slice(12);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
data,
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
export async function decryptFolderTree(
|
||||
tree: FolderTreeResponse,
|
||||
encryptionKey: CryptoKey,
|
||||
): Promise<FolderTreeResponse> {
|
||||
const decryptFolder = async (
|
||||
folder: FolderTreeNode,
|
||||
): Promise<FolderTreeNode> => {
|
||||
return {
|
||||
...folder,
|
||||
notes: await Promise.all(
|
||||
folder.notes.map(async (note) => ({
|
||||
...note,
|
||||
title: await decryptString(note.title, encryptionKey),
|
||||
content: await decryptString(note.content, encryptionKey),
|
||||
tags: await Promise.all(
|
||||
note.tags.map(async (tag) => ({
|
||||
...tag,
|
||||
name: await decryptString(tag.name, encryptionKey),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
),
|
||||
children: await Promise.all(
|
||||
folder.children.map((child) => decryptFolder(child)),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
folders: await Promise.all(
|
||||
tree.folders.map((folder) => decryptFolder(folder)),
|
||||
),
|
||||
orphanedNotes: await Promise.all(
|
||||
tree.orphanedNotes.map(async (note) => ({
|
||||
...note,
|
||||
title: await decryptString(note.title, encryptionKey),
|
||||
content: await decryptString(note.content, encryptionKey),
|
||||
tags: await Promise.all(
|
||||
note.tags.map(async (tag) => ({
|
||||
...tag,
|
||||
name: await decryptString(tag.name, encryptionKey),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const decryptTagTree = async (
|
||||
tags: TagTreeNode[],
|
||||
key: CryptoKey,
|
||||
parentPath = "",
|
||||
): Promise<DecryptedTagNode[]> => {
|
||||
return Promise.all(
|
||||
tags.map(async (tag) => {
|
||||
const decryptedName = await decryptString(tag.name, key);
|
||||
const currentPath = parentPath
|
||||
? `${parentPath} › ${decryptedName}`
|
||||
: decryptedName;
|
||||
|
||||
return {
|
||||
...tag,
|
||||
name: decryptedName,
|
||||
parentPath: parentPath,
|
||||
children: await decryptTagTree(tag.children, key, currentPath),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
61
frontend/src/api/folders.tsx
Normal file
61
frontend/src/api/folders.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { decryptFolderTree } from "./encryption";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
||||
import { components } from "@/types/api";
|
||||
import client from "./client";
|
||||
|
||||
export type Folder = CamelCasedPropertiesDeep<components["schemas"]["Folder"]>;
|
||||
|
||||
export type FolderTreeNode = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderTreeNode"]
|
||||
>;
|
||||
|
||||
export type FolderTreeResponse = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderTreeResponse"]
|
||||
>;
|
||||
export type FolderCreate = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderCreate"]
|
||||
>;
|
||||
export type FolderUpdate = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderUpdate"]
|
||||
>;
|
||||
|
||||
const getFolderTree = async () => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
if (!encryptionKey) throw new Error("Not authenticated");
|
||||
|
||||
const { data, error } = await client.GET("/folders/tree", {});
|
||||
|
||||
const newData = data as unknown as FolderTreeResponse;
|
||||
|
||||
const decryptedFolderTree = await decryptFolderTree(newData, encryptionKey);
|
||||
|
||||
return decryptedFolderTree;
|
||||
};
|
||||
|
||||
const updateFolder = async (id: number, folder: FolderUpdate) => {
|
||||
console.log(`Updating folder ${id} with:`, folder);
|
||||
try {
|
||||
const response = await client.PATCH("/folders/{folder_id}", {
|
||||
params: { path: { folder_id: id } },
|
||||
body: folder,
|
||||
});
|
||||
console.log(`Folder ${id} update response:`, response.data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update folder ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const folderApi = {
|
||||
tree: () => getFolderTree(),
|
||||
list: () => client.GET("/folders/", {}),
|
||||
create: (folder: FolderCreate) => client.POST("/folders/", { body: folder }),
|
||||
delete: (id: number) =>
|
||||
client.DELETE("/folders/{folder_id}", {
|
||||
params: { path: { folder_id: id } },
|
||||
}),
|
||||
update: (id: number, updateData: FolderUpdate) =>
|
||||
updateFolder(id, updateData),
|
||||
};
|
||||
114
frontend/src/api/notes.tsx
Normal file
114
frontend/src/api/notes.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { encryptString, decryptString } from "./encryption";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
||||
import { components } from "@/types/api";
|
||||
import client from "./client";
|
||||
|
||||
export type NoteRead = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["NoteRead"]
|
||||
>;
|
||||
export type NoteCreate = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["NoteCreate"]
|
||||
>;
|
||||
export type Note = CamelCasedPropertiesDeep<components["schemas"]["Note"]>;
|
||||
|
||||
const createNote = async (note: NoteCreate) => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
if (!encryptionKey) throw new Error("Not authenticated");
|
||||
|
||||
var noteContent = await encryptString(note.content, encryptionKey);
|
||||
var noteTitle = await encryptString(note.title, encryptionKey);
|
||||
|
||||
var encryptedNote = {
|
||||
title: noteTitle,
|
||||
content: noteContent,
|
||||
folderId: note.folderId,
|
||||
};
|
||||
|
||||
console.log(encryptedNote);
|
||||
return client.POST(`/notes/`, { body: encryptedNote });
|
||||
};
|
||||
const fetchNotes = async () => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
if (!encryptionKey) throw new Error("Not authenticated");
|
||||
|
||||
const { data, error } = await client.GET(`/notes/`);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
console.log(data);
|
||||
|
||||
if (data) {
|
||||
const decryptedNotes = await Promise.all(
|
||||
data.map(async (note) => ({
|
||||
...note,
|
||||
title: await decryptString(note.title, encryptionKey),
|
||||
content: await decryptString(note.content, encryptionKey),
|
||||
tags: note.tags
|
||||
? await Promise.all(
|
||||
note.tags.map(async (tag) => ({
|
||||
...tag,
|
||||
name: await decryptString(tag.name, encryptionKey),
|
||||
})),
|
||||
)
|
||||
: [],
|
||||
})),
|
||||
);
|
||||
return decryptedNotes;
|
||||
}
|
||||
};
|
||||
|
||||
const updateNote = async (id: number, note: Partial<NoteRead>) => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
if (!encryptionKey) throw new Error("Not authenticated");
|
||||
|
||||
var encryptedNote: Partial<NoteRead> = {};
|
||||
if (note.content) {
|
||||
encryptedNote.content = await encryptString(note.content, encryptionKey);
|
||||
}
|
||||
if (note.title) {
|
||||
encryptedNote.title = await encryptString(note.title, encryptionKey);
|
||||
}
|
||||
if (note.folderId) {
|
||||
encryptedNote.folderId = note.folderId;
|
||||
}
|
||||
|
||||
const { data, error } = await client.PATCH(`/notes/{note_id}`, {
|
||||
body: encryptedNote,
|
||||
params: {
|
||||
path: {
|
||||
note_id: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (data) {
|
||||
console.log(data);
|
||||
}
|
||||
if (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const notesApi = {
|
||||
list: () => fetchNotes(),
|
||||
get: (id: number) =>
|
||||
client.GET(`/notes/{note_id}`, {
|
||||
params: {
|
||||
path: {
|
||||
note_id: id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
create: (note: NoteCreate) => createNote(note),
|
||||
update: (id: number, note: Partial<NoteRead>) => updateNote(id, note),
|
||||
delete: (id: number) =>
|
||||
client.DELETE(`/notes/{note_id}`, {
|
||||
params: {
|
||||
path: {
|
||||
note_id: 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 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
129
frontend/src/components/contextMenus/FolderContextMenu.tsx
Normal file
129
frontend/src/components/contextMenus/FolderContextMenu.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import React, { useState } from "react";
|
||||
import { FolderTreeNode } from "../../api/folders";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useUpdateFolder,
|
||||
useDeleteFolder,
|
||||
} from "../../hooks/useFolders";
|
||||
|
||||
interface FolderContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
folder: FolderTreeNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||
x,
|
||||
y,
|
||||
folder,
|
||||
onClose,
|
||||
}) => {
|
||||
const createFolderMutation = useCreateFolder();
|
||||
const updateFolderMutation = useUpdateFolder();
|
||||
const deleteFolderMutation = useDeleteFolder();
|
||||
|
||||
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 deleteFolderMutation.mutateAsync(folder.id);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete folder:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (newName.trim() && newName !== folder.name) {
|
||||
try {
|
||||
await updateFolderMutation.mutateAsync({
|
||||
folderId: folder.id,
|
||||
folder: { name: newName },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to rename folder:", error);
|
||||
}
|
||||
}
|
||||
setIsRenaming(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateSubfolder = async () => {
|
||||
try {
|
||||
await createFolderMutation.mutateAsync({
|
||||
name: "New Folder",
|
||||
parentId: folder.id,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create subfolder:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: y,
|
||||
left: x,
|
||||
}}
|
||||
className="bg-overlay0 border border-surface1 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-surface1 border border-surface1 rounded text-sm text-text focus:outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: y,
|
||||
left: x,
|
||||
}}
|
||||
className="bg-overlay0 border border-surface1 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-surface1 text-sm text-text transition-colors"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateSubfolder}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-surface1 text-sm text-text transition-colors"
|
||||
>
|
||||
New Subfolder
|
||||
</button>
|
||||
<div className="border-t border-surface1 my-1" />
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-danger hover:text-base text-sm text-danger transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
frontend/src/components/contextMenus/NoteContextMenu.tsx
Normal file
84
frontend/src/components/contextMenus/NoteContextMenu.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React from "react";
|
||||
import { Note } from "../../api/notes";
|
||||
import { useCreateNote, useDeleteNote } from "../../hooks/useFolders";
|
||||
import { useUIStore } from "../../stores/uiStore";
|
||||
|
||||
interface NoteContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
note: Note;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
|
||||
x,
|
||||
y,
|
||||
note,
|
||||
onClose,
|
||||
}) => {
|
||||
const { setSelectedNote } = useUIStore();
|
||||
const deleteNoteMutation = useDeleteNote();
|
||||
const createNoteMutation = useCreateNote();
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
if (note.id) {
|
||||
await deleteNoteMutation.mutateAsync(note.id);
|
||||
setSelectedNote(null);
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete note:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
try {
|
||||
await createNoteMutation.mutateAsync({
|
||||
title: `${note.title} (Copy)`,
|
||||
content: note.content,
|
||||
folderId: note.folderId || null,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate note:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
setSelectedNote(note);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: y,
|
||||
left: x,
|
||||
}}
|
||||
className="bg-overlay0 border border-surface1 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-surface1 text-sm text-text transition-colors"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDuplicate}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-surface1 text-sm text-text transition-colors"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<div className="border-t border-surface1 my-1" />
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-danger hover:text-base text-sm text-danger transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
frontend/src/contexts/ContextMenuContext.tsx
Normal file
109
frontend/src/contexts/ContextMenuContext.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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,
|
||||
) => {
|
||||
const menuHeight = 200;
|
||||
const menuWidth = 160;
|
||||
|
||||
const adjustedY =
|
||||
y + menuHeight > window.innerHeight
|
||||
? window.innerHeight - menuHeight - 10
|
||||
: y;
|
||||
|
||||
const adjustedX =
|
||||
x + menuWidth > window.innerWidth
|
||||
? window.innerWidth - menuWidth - 10
|
||||
: x;
|
||||
|
||||
setContextMenu({ x: adjustedX, y: adjustedY, type, data });
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
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-surface1/25 z-40 fixed top-0 left-0"
|
||||
></div>
|
||||
)}
|
||||
{children}
|
||||
</ContextMenuContext.Provider>
|
||||
);
|
||||
};
|
||||
310
frontend/src/hooks/useFolders.ts
Normal file
310
frontend/src/hooks/useFolders.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
FolderCreate,
|
||||
FolderTreeNode,
|
||||
FolderTreeResponse,
|
||||
FolderUpdate,
|
||||
folderApi,
|
||||
} from "@/api/folders";
|
||||
import { NoteRead, NoteCreate, notesApi } from "@/api/notes";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
export const useFolderTree = () => {
|
||||
const { encryptionKey } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["folders", "tree"],
|
||||
queryFn: folderApi.tree,
|
||||
enabled: !!encryptionKey,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (folder: FolderCreate) => folderApi.create(folder),
|
||||
|
||||
onMutate: async (newFolder) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const tempFolder: FolderTreeNode = {
|
||||
id: -Date.now(),
|
||||
name: newFolder.name,
|
||||
notes: [],
|
||||
children: [],
|
||||
};
|
||||
if (!newFolder.parentId) {
|
||||
return {
|
||||
...prev,
|
||||
folders: [...prev.folders, tempFolder],
|
||||
};
|
||||
}
|
||||
|
||||
const addToParent = (folders: FolderTreeNode[]): FolderTreeNode[] => {
|
||||
return folders.map((folder) => {
|
||||
if (folder.id === newFolder.parentId) {
|
||||
return {
|
||||
...folder,
|
||||
children: [...folder.children, tempFolder],
|
||||
};
|
||||
}
|
||||
return { ...folder, children: addToParent(folder.children) };
|
||||
});
|
||||
};
|
||||
|
||||
return { ...prev, folders: addToParent(prev.folders) };
|
||||
},
|
||||
);
|
||||
return { previousFolderTree };
|
||||
},
|
||||
onError: (err, newFolder, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
folderId,
|
||||
folder,
|
||||
}: {
|
||||
folderId: number;
|
||||
folder: FolderUpdate;
|
||||
}) => folderApi.update(folderId, folder),
|
||||
|
||||
onMutate: async ({ folderId, folder }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const updateInTree = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders.map((f) => {
|
||||
if (f.id === folderId) {
|
||||
return {
|
||||
...f,
|
||||
...(folder.name !== undefined &&
|
||||
folder.name !== null && { name: folder.name }),
|
||||
...(folder.parentId !== undefined && {
|
||||
parentId: folder.parentId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
children: updateInTree(f.children),
|
||||
};
|
||||
});
|
||||
};
|
||||
return { ...prev, folders: updateInTree(prev.folders) };
|
||||
},
|
||||
);
|
||||
return { previousFolderTree };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateNote = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
noteId,
|
||||
note,
|
||||
}: {
|
||||
noteId: number;
|
||||
note: Partial<NoteRead>;
|
||||
}) => notesApi.update(noteId, note),
|
||||
|
||||
onMutate: async ({ noteId, note }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const updateNoteInTree = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders.map((folder) => ({
|
||||
...folder,
|
||||
notes: folder.notes.map((n) =>
|
||||
n.id === noteId ? { ...n, ...note } : n,
|
||||
),
|
||||
children: updateNoteInTree(folder.children),
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
folders: updateNoteInTree(prev.folders),
|
||||
orphanedNotes: prev.orphanedNotes.map((n) =>
|
||||
n.id === noteId ? { ...n, ...note } : n,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previousFolderTree };
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
console.log(err);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateNote = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (note: NoteCreate) => notesApi.create(note),
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteNote = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (noteId: number) => notesApi.delete(noteId),
|
||||
|
||||
onMutate: async (noteId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const removeNoteFromTree = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders.map((folder) => ({
|
||||
...folder,
|
||||
notes: folder.notes.filter((n) => n.id !== noteId),
|
||||
children: removeNoteFromTree(folder.children),
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
folders: removeNoteFromTree(prev.folders),
|
||||
orphanedNotes: prev.orphanedNotes.filter((n) => n.id !== noteId),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previousFolderTree };
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (folderId: number) => folderApi.delete(folderId),
|
||||
|
||||
onMutate: async (folderId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const removeFolderFromTree = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders
|
||||
.filter((folder) => folder.id !== folderId)
|
||||
.map((folder) => ({
|
||||
...folder,
|
||||
children: removeFolderFromTree(folder.children),
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
folders: removeFolderFromTree(prev.folders),
|
||||
orphanedNotes: prev.orphanedNotes,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previousFolderTree };
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
66
frontend/src/main.css
Normal file
66
frontend/src/main.css
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap");
|
||||
@import "tailwindcss";
|
||||
@import "@catppuccin/tailwindcss/macchiato.css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme {
|
||||
--font-mono: "Jetbrains Mono", monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--black: 15, 18, 25;
|
||||
--gray: 96, 115, 159;
|
||||
--gray-light: 229, 233, 240;
|
||||
--gray-dark: 34, 41, 57;
|
||||
--box-shadow:
|
||||
0 2px 6px rgba(30, 32, 48, 0.4), 0 8px 24px rgba(30, 32, 48, 0.5),
|
||||
0 16px 32px rgba(30, 32, 48, 0.6);
|
||||
|
||||
@apply text-sm;
|
||||
}
|
||||
@theme {
|
||||
--color-base: #24273a;
|
||||
--color-surface0: #1e2030;
|
||||
--color-surface1: #181926;
|
||||
--color-overlay0: #363a4f;
|
||||
--color-overlay1: #494d64;
|
||||
--color-text: #cad3f5;
|
||||
--color-subtext: #b8c0e0;
|
||||
--color-accent-50: #fef6ee;
|
||||
--color-accent-100: #fcebd9;
|
||||
--color-accent-200: #f8d4b2;
|
||||
--color-accent-300: #f3b881;
|
||||
--color-accent-400: #ed954e;
|
||||
--color-accent-500: #e2a16f;
|
||||
--color-accent-600: #d4793c;
|
||||
--color-accent-700: #b05e31;
|
||||
--color-accent-800: #8d4c2d;
|
||||
--color-accent-900: #724027;
|
||||
--color-accent-950: #3d1f12;
|
||||
|
||||
--color-danger: #e26f6f;
|
||||
--color-success: #6fe29b;
|
||||
--color-warn: #e2c56f;
|
||||
}
|
||||
|
||||
/* Override MDXEditor and all its children */
|
||||
[class*="mdxeditor"],
|
||||
._mdxeditor-root-content-editable,
|
||||
.mdxeditor-root-contenteditable,
|
||||
div[contenteditable="true"] {
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
._listItemChecked_1tncs_73::before {
|
||||
--accentSolid: var(--color-accent-500) !important;
|
||||
border-color: var(--color-accent-500) !important;
|
||||
border: 2px;
|
||||
}
|
||||
|
||||
._listItemChecked_1tncs_73::after {
|
||||
border-color: var(--color-accent-500) !important;
|
||||
}
|
||||
|
||||
.standard-input {
|
||||
@apply border border-overlay0 rounded-sm px-2 py-1 w-full focus:outline-none focus:ring-2 focus:ring-accent-500 bg-base text-text placeholder:text-overlay1;
|
||||
}
|
||||
17
frontend/src/main.tsx
Normal file
17
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./main.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
210
frontend/src/pages/Home/Home.tsx
Normal file
210
frontend/src/pages/Home/Home.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import {
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import "../../main.css";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
import { TiptapEditor } from "../TipTap";
|
||||
import { Sidebar } from "./components/sidebar/SideBar";
|
||||
import { StatusIndicator } from "./components/StatusIndicator";
|
||||
import { useUpdateNote } from "@/hooks/useFolders";
|
||||
import { NoteRead } from "@/api/notes";
|
||||
// @ts-ignore
|
||||
import XmarkIcon from "@/assets/fontawesome/svg/xmark.svg?react";
|
||||
// @ts-ignore
|
||||
import PlusIcon from "@/assets/fontawesome/svg/plus.svg?react";
|
||||
|
||||
function Home() {
|
||||
const [newFolder] = useState(false);
|
||||
const [editingNote, setEditingNote] = useState<NoteRead | null>(null);
|
||||
const [lastSavedNote, setLastSavedNote] = useState<{
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
const { encryptionKey } = useAuthStore();
|
||||
const { showModal, setUpdating, selectedNote, editorView } = useUIStore();
|
||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const updateNoteMutation = useUpdateNote();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNote) {
|
||||
setEditingNote(selectedNote);
|
||||
setLastSavedNote({
|
||||
id: selectedNote.id,
|
||||
title: selectedNote.title,
|
||||
content: selectedNote.content,
|
||||
});
|
||||
} else {
|
||||
setEditingNote(null);
|
||||
setLastSavedNote(null);
|
||||
}
|
||||
}, [selectedNote?.id]);
|
||||
useEffect(() => {
|
||||
if (newFolder && newFolderRef.current) {
|
||||
newFolderRef.current.focus();
|
||||
}
|
||||
}, [newFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingNote) return;
|
||||
if (!encryptionKey) return;
|
||||
|
||||
const hasChanges =
|
||||
lastSavedNote &&
|
||||
lastSavedNote.id === editingNote.id &&
|
||||
(lastSavedNote.title !== editingNote.title ||
|
||||
lastSavedNote.content !== editingNote.content);
|
||||
|
||||
if (!hasChanges) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setUpdating(true);
|
||||
await handleUpdate();
|
||||
setLastSavedNote({
|
||||
id: editingNote.id,
|
||||
title: editingNote.title,
|
||||
content: editingNote.content,
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [editingNote?.title, editingNote?.content, encryptionKey]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingNote) return;
|
||||
if (!encryptionKey) {
|
||||
setUpdating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!editingNote.id) throw new Error("Editing note has no id.");
|
||||
await updateNoteMutation.mutateAsync({
|
||||
noteId: editingNote.id,
|
||||
note: {
|
||||
title: editingNote.title,
|
||||
content: editingNote.content,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update note:", error);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setUpdating(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const setTitle = (title: string) => {
|
||||
if (editingNote) {
|
||||
setEditingNote({ ...editingNote, title });
|
||||
}
|
||||
};
|
||||
|
||||
const setContent = (content: string) => {
|
||||
if (editingNote) {
|
||||
setEditingNote({ ...editingNote, content });
|
||||
}
|
||||
};
|
||||
|
||||
const setUnparsedContent = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (editingNote) {
|
||||
setEditingNote({ ...editingNote, content: event.target.value });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex bg-base h-screen text-text overflow-hidden">
|
||||
<AnimatePresence>{showModal && <Modal />}</AnimatePresence>
|
||||
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex flex-col w-full h-screen overflow-hidden">
|
||||
{" "}
|
||||
{editingNote ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
id="noteTitle"
|
||||
placeholder="Untitled note..."
|
||||
value={editingNote.title || ""}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full self-center p-4 pb-2 pt-2 text-3xl font-semibold focus:outline-none border-transparent focus:border-accent-500 transition-colors placeholder:text-overlay0 text-text bg-surface1"
|
||||
/>
|
||||
<div className="h-full w-full overflow-y-hidden">
|
||||
{" "}
|
||||
{editorView == "parsed" ? (
|
||||
<TiptapEditor
|
||||
key={editingNote.id}
|
||||
content={editingNote.content || ""}
|
||||
onChange={setContent}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={editingNote.content || ""}
|
||||
className="w-full font-mono p-4 bg-transparent focus:outline-none resize-none text-text"
|
||||
style={{
|
||||
minHeight: "calc(100vh - 55px)",
|
||||
}}
|
||||
onChange={setUnparsedContent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-overlay0">
|
||||
<div className="text-center">
|
||||
<PlusIcon className="w-16 h-16 mx-auto mb-4 fill-current opacity-50" />
|
||||
<p className="text-lg">Select a note or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StatusIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
|
||||
const Modal = () => {
|
||||
const { setShowModal, modalContent, showModal } = useUIStore();
|
||||
const ModalContent = modalContent;
|
||||
if (!showModal || !ModalContent) return null;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowModal(false)}
|
||||
className="fixed inset-0 h-screen w-screen flex items-center justify-center bg-crust/70 backdrop-blur-sm z-50"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ type: "spring", duration: 0.3 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative w-full max-w-md mx-4 bg-base rounded-xl border-surface1 border p-8 shadow-2xl"
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="absolute top-4 right-4 p-2 hover:bg-surface0 rounded-sm transition-colors group"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<XmarkIcon className="w-5 h-5 fill-overlay0 group-hover:fill-text transition-colors" />
|
||||
</button>
|
||||
<ModalContent />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
46
frontend/src/pages/Home/components/StatusIndicator.tsx
Normal file
46
frontend/src/pages/Home/components/StatusIndicator.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useAuthStore } from "../../../stores/authStore";
|
||||
import { useUIStore } from "../../../stores/uiStore";
|
||||
// @ts-ignore
|
||||
import CheckIcon from "../../../assets/fontawesome/svg/circle-check.svg?react";
|
||||
// @ts-ignore
|
||||
import SpinnerIcon from "../../../assets/fontawesome/svg/rotate.svg?react";
|
||||
// @ts-ignore
|
||||
import WarningIcon from "../../../assets/fontawesome/svg/circle-exclamation.svg?react";
|
||||
import { Login } from "@/pages/Login";
|
||||
|
||||
export const StatusIndicator = () => {
|
||||
const { encryptionKey } = useAuthStore();
|
||||
const { updating, setShowModal, editorView, setEditorView, setModalContent } =
|
||||
useUIStore();
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-2 right-3 bg-surface0 border border-surface1 rounded-sm px-2 py-0.5 flex items-center gap-2.5 shadow-lg backdrop-blur-sm"
|
||||
onClick={() => {
|
||||
if (!encryptionKey) {
|
||||
setModalContent(Login);
|
||||
setShowModal(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="select-none"
|
||||
onClick={() =>
|
||||
setEditorView(editorView == "parsed" ? "unparsed" : "parsed")
|
||||
}
|
||||
>
|
||||
{editorView}
|
||||
</div>
|
||||
{!encryptionKey ? (
|
||||
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-warn [&_.fa-secondary]:fill-orange" />
|
||||
) : updating ? (
|
||||
<>
|
||||
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-warn [&_.fa-secondary]:fill-sapphire" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4 [&_.fa-primary]:fill-success [&_.fa-secondary]:fill-teal" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
251
frontend/src/pages/Home/components/sidebar/SideBar.tsx
Normal file
251
frontend/src/pages/Home/components/sidebar/SideBar.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
// @ts-ignore
|
||||
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
|
||||
// @ts-ignore
|
||||
import TagsIcon from "@/assets/fontawesome/svg/tags.svg?react";
|
||||
import { DraggableNote } from "./subcomponents/DraggableNote";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
import { FolderTree } from "./subcomponents/FolderTree.tsx";
|
||||
import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
|
||||
import { useAuthStore } from "@/stores/authStore.ts";
|
||||
import { useUIStore } from "@/stores/uiStore.ts";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useFolderTree,
|
||||
useUpdateFolder,
|
||||
useUpdateNote,
|
||||
} from "@/hooks/useFolders.ts";
|
||||
|
||||
export const Sidebar = () => {
|
||||
const [newFolder, setNewFolder] = useState(false);
|
||||
const [newFolderText, setNewFolderText] = useState("");
|
||||
const [activeItem, setActiveItem] = useState<{
|
||||
type: "note" | "folder";
|
||||
data: any;
|
||||
} | null>(null);
|
||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: folderTree, isLoading, error } = useFolderTree();
|
||||
const createFolder = useCreateFolder();
|
||||
|
||||
const { encryptionKey } = useAuthStore();
|
||||
|
||||
const { setSideBarResize, sideBarResize, setColourScheme } = useUIStore();
|
||||
useEffect(() => {
|
||||
if (newFolder && newFolderRef.current) {
|
||||
newFolderRef.current.focus();
|
||||
}
|
||||
}, [newFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!encryptionKey) return;
|
||||
}, [encryptionKey]);
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderText.trim()) return;
|
||||
createFolder.mutate({ name: newFolderText, parentId: null });
|
||||
};
|
||||
|
||||
const pointer = useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 30,
|
||||
},
|
||||
});
|
||||
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 updateNote = useUpdateNote();
|
||||
const updateFolder = useUpdateFolder();
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
setActiveItem(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
console.log("Drag ended:", {
|
||||
activeId: active.id,
|
||||
activeType: active.data.current?.type,
|
||||
activeFolder: active.data.current?.folder,
|
||||
overId: over.id,
|
||||
overType: over.data.current?.type,
|
||||
});
|
||||
|
||||
if (active.data.current?.type === "note") {
|
||||
console.log("Updating note ", active.id, "to folder", over.id);
|
||||
updateNote.mutate({
|
||||
noteId: active.id as number,
|
||||
note: { folderId: over.id as number },
|
||||
});
|
||||
} else if (active.data.current?.type === "folder") {
|
||||
if (active.data.current.folder.id === over.id) {
|
||||
console.log("Cannot drop folder into itself");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Updating folder",
|
||||
active.data.current.folder.id,
|
||||
"parent to",
|
||||
over.id,
|
||||
);
|
||||
try {
|
||||
updateFolder.mutate({
|
||||
folderId: active.data.current.folder.id,
|
||||
folder: { parentId: over.id as number },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update folder:", error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsResizing(true);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
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 (
|
||||
<DndContext
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
autoScroll={false}
|
||||
sensors={sensors}
|
||||
>
|
||||
<div className="flex-row-reverse flex h-screen">
|
||||
<div
|
||||
className="h-full bg-surface1 w-0.5 hover:cursor-ew-resize hover:bg-accent-500/50 transition-colors"
|
||||
onMouseDown={handleMouseDown}
|
||||
></div>
|
||||
<div
|
||||
className="flex flex-col h-full"
|
||||
style={{ width: `${sideBarResize}px` }}
|
||||
>
|
||||
<SidebarHeader setNewFolder={setNewFolder} />
|
||||
<div className="flex-1 overflow-y-auto bg-surface1 border-r border-surface1">
|
||||
<>
|
||||
<div
|
||||
className="w-full p-4 sm:block hidden"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onTouchMove={(e) => e.preventDefault()}
|
||||
>
|
||||
{newFolder && (
|
||||
<div className="mb-2">
|
||||
<input
|
||||
onBlur={() => setNewFolder(false)}
|
||||
onChange={(e) => setNewFolderText(e.target.value)}
|
||||
value={newFolderText}
|
||||
type="text"
|
||||
placeholder="Folder name..."
|
||||
className="standard-input"
|
||||
ref={newFolderRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateFolder();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setNewFolder(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-subtext0">
|
||||
<div className="text-sm">Loading folders...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-8 text-danger">
|
||||
<div className="text-sm">Failed to load folders</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
{folderTree?.folders.map((folder) => (
|
||||
<FolderTree key={folder.id} folder={folder} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{folderTree?.orphanedNotes &&
|
||||
folderTree.orphanedNotes.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
{folderTree.orphanedNotes.map((note) => (
|
||||
<DraggableNote key={note.id} note={note} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeItem?.type === "note" && (
|
||||
<div className="bg-surface0 rounded-md px-2 py-1 shadow-lg border border-accent-500">
|
||||
{activeItem.data.title}
|
||||
</div>
|
||||
)}
|
||||
{activeItem?.type === "folder" && (
|
||||
<div className="bg-surface0 rounded-md px-1 py-0.5 shadow-lg flex items-center gap-1 text-sm">
|
||||
<FolderIcon className="w-3 h-3 fill-accent-500 mr-1" />
|
||||
{activeItem.data.name}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
import { NoteRead } from "@/api/notes";
|
||||
|
||||
export const DraggableNote = ({ note }: { note: NoteRead }) => {
|
||||
const { selectedNote, setSelectedNote } = useUIStore();
|
||||
const { openContextMenu } = useContextMenu();
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: note.id,
|
||||
data: { type: "note", note },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
opacity: isDragging ? 0 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="z-20"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, "note", note);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={note.id}
|
||||
onClick={() => {
|
||||
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
|
||||
? "bg-accent-500 text-base font-medium"
|
||||
: "hover:bg-surface1"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedNote?.id == note.id ? selectedNote.title : note.title}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import React from "react";
|
||||
import { useDroppable, useDraggable } from "@dnd-kit/core";
|
||||
// @ts-ignore
|
||||
import CaretRightIcon from "@/assets/fontawesome/svg/caret-right.svg?react";
|
||||
// @ts-ignore
|
||||
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
|
||||
import { FolderTreeNode } from "@/api/folders";
|
||||
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
||||
|
||||
export const DroppableFolder = ({
|
||||
folder,
|
||||
setCollapse,
|
||||
collapse,
|
||||
}: {
|
||||
folder: Partial<FolderTreeNode>;
|
||||
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
collapse: boolean;
|
||||
}) => {
|
||||
const { openContextMenu } = useContextMenu();
|
||||
|
||||
const { isOver, setNodeRef: setDroppableRef } = useDroppable({
|
||||
id: folder.id!,
|
||||
data: { type: "folder", folder },
|
||||
});
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
transform,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
id: `folder-${folder.id}`,
|
||||
data: { type: "folder", folder },
|
||||
});
|
||||
|
||||
const setNodeRef = (node: HTMLElement | null) => {
|
||||
setDroppableRef(node);
|
||||
setDraggableRef(node);
|
||||
};
|
||||
|
||||
const style = {
|
||||
color: isOver ? "green" : undefined,
|
||||
opacity: isDragging ? 0 : 1,
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCollapse(!collapse);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
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 min-w-0`}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{(folder.notes?.length ?? 0) > 0 && (
|
||||
<CaretRightIcon
|
||||
className={`w-4 h-4 min-h-4 min-w-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-accent-500`}
|
||||
/>
|
||||
)}
|
||||
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-accent-500 mr-1" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { DraggableNote } from "./DraggableNote";
|
||||
import { DroppableFolder } from "./DroppableFolder";
|
||||
import { FolderTreeNode } from "../../../../../api/folders";
|
||||
|
||||
interface FolderTreeProps {
|
||||
folder: FolderTreeNode;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export const FolderTree = ({ folder, depth = 0 }: FolderTreeProps) => {
|
||||
const [collapse, setCollapse] = useState(false);
|
||||
|
||||
return (
|
||||
<div key={folder.id} className="flex flex-col relative">
|
||||
<DroppableFolder
|
||||
folder={folder}
|
||||
setCollapse={setCollapse}
|
||||
collapse={collapse}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{collapse && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
<div className="ml-2 pl-3 border-l border-surface1">
|
||||
{/* Notes */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{folder.notes.map((note) => (
|
||||
<DraggableNote key={note.id} note={note} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{folder.children.map((child) => (
|
||||
<FolderTree key={child.id} folder={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { SetStateAction } from "react";
|
||||
// @ts-ignore
|
||||
import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
|
||||
// @ts-ignore
|
||||
import TagsIcon from "@assets/fontawesome/svg/tags.svg?react";
|
||||
// @ts-ignore
|
||||
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
|
||||
// @ts-ignore
|
||||
import GearIcon from "@assets/fontawesome/svg/gear.svg?react";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
import { useCreateNote } from "@/hooks/useFolders";
|
||||
import { NoteCreate } from "@/api/notes";
|
||||
|
||||
const Test = () => {
|
||||
const { colourScheme, setColourScheme } = useUIStore();
|
||||
|
||||
const handleColor = (key: string, value: string) => {
|
||||
setColourScheme({
|
||||
...colourScheme,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(colourScheme).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<label>{key}</label>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => handleColor(key, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarHeader = ({
|
||||
setNewFolder,
|
||||
}: {
|
||||
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const { selectedFolder, setShowModal, setModalContent } = useUIStore();
|
||||
const createNote = useCreateNote();
|
||||
const handleCreate = async () => {
|
||||
createNote.mutate({
|
||||
title: "Untitled",
|
||||
content: "",
|
||||
folder_id: selectedFolder,
|
||||
} as NoteCreate);
|
||||
};
|
||||
|
||||
const handleSettings = () => {
|
||||
setModalContent(Test);
|
||||
setShowModal(true);
|
||||
};
|
||||
return (
|
||||
<div className="w-full p-2 border-b border-surface1 bg-surface1">
|
||||
<div className="flex items-center justify-around bg-surface0 rounded-lg p-1 gap-1">
|
||||
<button
|
||||
onClick={() => setNewFolder(true)}
|
||||
className="hover:bg-accent-500 active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
|
||||
title="New folder"
|
||||
>
|
||||
<FolderPlusIcon className="w-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="hover:bg-accent-500 active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
|
||||
title="New note"
|
||||
>
|
||||
<FileCirclePlusIcon className="w-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSettings}
|
||||
className="hover:bg-accent-500 active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
|
||||
title="New note"
|
||||
>
|
||||
<GearIcon className="w-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
87
frontend/src/pages/Login.tsx
Normal file
87
frontend/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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 as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="gap-4 flex flex-col max-w-md mx-auto"
|
||||
>
|
||||
<h2 className="text-2xl font-semibold text-text mb-2">Welcome Back</h2>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-subtext">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
className="standard-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-subtext">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="standard-input"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger/10 border border-danger text-danger px-3 py-2 rounded-sm text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="accent-accent-500 cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember"
|
||||
className="text-sm text-subtext cursor-pointer"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-500 hover:bg-accent-500/90 text-base font-semibold px-4 py-2.5 rounded-sm transition-colors focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-base"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
80
frontend/src/pages/Register.tsx
Normal file
80
frontend/src/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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);
|
||||
//Add error handling on a unsuccessful register
|
||||
navigate("/");
|
||||
} catch (error) {
|
||||
setError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="gap-4 flex flex-col max-w-md mx-auto"
|
||||
>
|
||||
<h2 className="text-2xl font-semibold text-text mb-2">Create Account</h2>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-subtext">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Choose a username"
|
||||
className="standard-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-subtext">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="standard-input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-subtext">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="standard-input"
|
||||
placeholder="Create a password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger/10 border border-danger text-danger px-3 py-2 rounded-sm text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-500 hover:bg-accent-500/90 text-base font-semibold px-4 py-2.5 rounded-sm transition-colors focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-base"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
208
frontend/src/pages/TipTap.tsx
Normal file
208
frontend/src/pages/TipTap.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
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 CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { createLowlight, all } from "lowlight";
|
||||
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";
|
||||
import "highlight.js/styles/atom-one-dark.css";
|
||||
|
||||
const lowlight = createLowlight(all);
|
||||
|
||||
interface TiptapEditorProps {
|
||||
content: string;
|
||||
onChange: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const TiptapEditor = ({
|
||||
placeholder,
|
||||
content,
|
||||
onChange,
|
||||
}: TiptapEditorProps) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
enableTabIndentation: true,
|
||||
}),
|
||||
ListKit,
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
bulletList: false,
|
||||
codeBlock: 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 pb-4 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 pt-0! overflow-y-scroll lg:px-20 pb-2"
|
||||
style={{
|
||||
minHeight: "calc(100vh - 55px)",
|
||||
}}
|
||||
>
|
||||
{/* 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-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-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
className={editor.isActive("strike") ? "active" : ""}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<StrikethroughIcon className="w-4 h-4 fill-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
className={editor.isActive("code") ? "active" : ""}
|
||||
title="Inline code"
|
||||
>
|
||||
<CodeIcon className="w-4 h-4 fill-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-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={editor.isActive("orderedList") ? "active" : ""}
|
||||
title="Numbered list"
|
||||
>
|
||||
<ListOlIcon className="w-4 h-4 fill-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||
className={editor.isActive("taskList") ? "active" : ""}
|
||||
title="Task list"
|
||||
>
|
||||
<SquareCheckIcon className="w-4 h-4 fill-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
className={editor.isActive("codeBlock") ? "active" : ""}
|
||||
title="Code block"
|
||||
>
|
||||
<CodeBracketIcon className="w-4 h-4 fill-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={editor.isActive("blockquote") ? "active" : ""}
|
||||
title="Quote"
|
||||
>
|
||||
<QuoteLeftIcon className="w-4 h-4 fill-text" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider"></div>
|
||||
</div>*/}
|
||||
|
||||
{/* Editor content */}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="editor-content h-min-screen p-4!"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
173
frontend/src/pages/tiptap.css
Normal file
173
frontend/src/pages/tiptap.css
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/* @tailwind */
|
||||
@reference "../main.css";
|
||||
|
||||
/* Custom Scrollbar */
|
||||
*::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
@apply bg-surface0 rounded-full;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
@apply bg-surface1 rounded-full;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-overlay0;
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-surface1) var(--color-surface0);
|
||||
}
|
||||
|
||||
.tiptap-editor {
|
||||
@apply flex flex-col h-full bg-base;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
@apply text-text;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
@apply flex gap-2 px-4 bg-surface0 border-b border-surface1 flex-wrap items-center;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
@apply flex gap-1;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
@apply w-px h-6 bg-surface1;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
@apply p-2 bg-transparent border-none rounded-sm text-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-surface0;
|
||||
}
|
||||
|
||||
.editor-toolbar button.active {
|
||||
@apply bg-accent-500 text-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-overlay0 pointer-events-none h-0;
|
||||
}
|
||||
.ProseMirror h1 {
|
||||
@apply text-3xl font-bold mt-6 mb-4 text-text;
|
||||
}
|
||||
|
||||
.ProseMirror h2 {
|
||||
@apply text-2xl font-bold text-text mt-5 mb-3;
|
||||
}
|
||||
|
||||
.ProseMirror h3 {
|
||||
@apply text-xl font-semibold text-text mt-4 mb-2;
|
||||
}
|
||||
|
||||
.ProseMirror h4 {
|
||||
@apply text-lg font-semibold text-text mt-4 mb-2;
|
||||
}
|
||||
|
||||
.ProseMirror h5 {
|
||||
@apply font-medium text-text mt-3 mb-2;
|
||||
}
|
||||
|
||||
.ProseMirror strong {
|
||||
@apply text-text font-extrabold;
|
||||
}
|
||||
|
||||
.ProseMirror em {
|
||||
@apply text-subtext;
|
||||
}
|
||||
|
||||
.ProseMirror strong em,
|
||||
.ProseMirror em strong {
|
||||
@apply text-accent-300 font-bold;
|
||||
}
|
||||
|
||||
.ProseMirror code:not(pre code) {
|
||||
@apply bg-surface0 text-accent-400 px-1.5 py-0.5 rounded text-sm border border-surface1;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
@apply bg-surface1 border border-overlay0 rounded-md p-4 my-4 overflow-x-auto;
|
||||
}
|
||||
|
||||
.ProseMirror pre code,
|
||||
.ProseMirror pre code.hljs {
|
||||
@apply bg-transparent p-0 text-sm;
|
||||
background: transparent !important;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
@apply border-l-4 border-accent-600 pl-4 ml-0 my-4 text-subtext italic bg-surface0/30 py-2 rounded-r-sm;
|
||||
}
|
||||
|
||||
.ProseMirror a {
|
||||
@apply text-accent-400 no-underline border-b border-accent-600/50 transition-colors;
|
||||
}
|
||||
|
||||
.ProseMirror a:hover {
|
||||
@apply text-accent-300 border-accent-400;
|
||||
}
|
||||
|
||||
.ProseMirror li::marker {
|
||||
@apply text-accent-600;
|
||||
}
|
||||
|
||||
.ProseMirror ul {
|
||||
@apply list-disc pl-6;
|
||||
}
|
||||
|
||||
.ProseMirror ol {
|
||||
@apply list-decimal pl-6;
|
||||
}
|
||||
|
||||
.ProseMirror hr {
|
||||
border: none;
|
||||
height: 3px;
|
||||
background: var(--color-accent-600);
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 6'%3E%3Cpath d='M0 3 Q 3 0, 6 3 T 12 3 T 18 3 T 24 3' stroke='black' stroke-width='2' fill='none'/%3E%3C/svg%3E")
|
||||
repeat-x;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 6'%3E%3Cpath d='M0 3 Q 3 0, 6 3 T 12 3 T 18 3 T 24 3' stroke='black' stroke-width='2' fill='none'/%3E%3C/svg%3E")
|
||||
repeat-x;
|
||||
margin: 2em 0;
|
||||
}
|
||||
.ProseMirror ul[data-type="taskList"] > li > label input[type="checkbox"] {
|
||||
@apply cursor-pointer m-0 accent-accent-500 w-4 h-4;
|
||||
}
|
||||
|
||||
.ProseMirror li[data-checked="true"] > div > p {
|
||||
@apply line-through text-overlay1;
|
||||
}
|
||||
|
||||
.ProseMirror u {
|
||||
@apply decoration-accent-600 underline-offset-2;
|
||||
text-decoration-style: wavy;
|
||||
}
|
||||
|
||||
.ProseMirror ::selection {
|
||||
@apply bg-accent-700/40;
|
||||
}
|
||||
165
frontend/src/stores/authStore.ts
Normal file
165
frontend/src/stores/authStore.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
encryptionKey: CryptoKey | null;
|
||||
isAuthenticated: boolean;
|
||||
rememberMe: boolean;
|
||||
setRememberMe: (remember: 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>;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
const API_URL = import.meta.env.PROD
|
||||
? "/api" // ← Same domain, different path
|
||||
: "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) => {
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
const kek = await deriveKey(password, user.salt);
|
||||
const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek);
|
||||
|
||||
set({ encryptionKey: masterKey, user, isAuthenticated: true });
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await fetch(`${API_URL}/auth/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
set({
|
||||
user: null,
|
||||
encryptionKey: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
get().clearAll();
|
||||
},
|
||||
|
||||
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();
|
||||
}
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({
|
||||
user: null,
|
||||
encryptionKey: null,
|
||||
isAuthenticated: false,
|
||||
rememberMe: false,
|
||||
});
|
||||
|
||||
localStorage.clear();
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "auth-storage",
|
||||
partialize: (state) => {
|
||||
return {
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).useAuthStore = useAuthStore;
|
||||
}
|
||||
156
frontend/src/stores/uiStore.ts
Normal file
156
frontend/src/stores/uiStore.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { NoteRead } from "@/api/notes";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { generateColorScale } from "@/utils/colorScale";
|
||||
|
||||
interface HSL {
|
||||
H: Number;
|
||||
S: Number;
|
||||
L: Number;
|
||||
}
|
||||
|
||||
export interface ColourState {
|
||||
base: string;
|
||||
surface0: string;
|
||||
surface1: string;
|
||||
overlay0: string;
|
||||
overlay1: string;
|
||||
text: string;
|
||||
subtext: string;
|
||||
accent: string;
|
||||
warn: string;
|
||||
success: string;
|
||||
danger: string;
|
||||
}
|
||||
|
||||
interface ColourScales {
|
||||
accent: Record<string, string>;
|
||||
danger: Record<string, string>;
|
||||
success: Record<string, string>;
|
||||
warn: Record<string, string>;
|
||||
}
|
||||
|
||||
interface UIState {
|
||||
updating: boolean;
|
||||
setUpdating: (update: boolean) => void;
|
||||
|
||||
showModal: boolean;
|
||||
setShowModal: (show: boolean) => void;
|
||||
|
||||
modalContent: React.ComponentType | null;
|
||||
setModalContent: (content: React.ComponentType) => void;
|
||||
|
||||
sideBarResize: number;
|
||||
setSideBarResize: (size: number) => void;
|
||||
|
||||
sideBarView: string;
|
||||
setSideBarView: (view: string) => void;
|
||||
|
||||
editorView: string;
|
||||
setEditorView: (view: string) => void;
|
||||
|
||||
selectedNote: NoteRead | null;
|
||||
setSelectedNote: (note: NoteRead | null) => void;
|
||||
|
||||
selectedFolder: number | null;
|
||||
setSelectedFolder: (id: number | null) => void;
|
||||
|
||||
colourScheme: ColourState;
|
||||
colourScales: ColourScales;
|
||||
setColourScheme: (colors: ColourState) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
updating: false,
|
||||
setUpdating: (update) => {
|
||||
set({ updating: update });
|
||||
},
|
||||
showModal: true,
|
||||
setShowModal: (show) => {
|
||||
set({ showModal: show });
|
||||
},
|
||||
modalContent: null,
|
||||
setModalContent: (content) => {
|
||||
set({ modalContent: content });
|
||||
},
|
||||
sideBarResize: 300,
|
||||
setSideBarResize: (size) => {
|
||||
set({ sideBarResize: size });
|
||||
},
|
||||
sideBarView: "folders",
|
||||
setSideBarView: (view) => {
|
||||
set({ sideBarView: view });
|
||||
},
|
||||
editorView: "parsed",
|
||||
setEditorView: (view) => {
|
||||
set({ editorView: view });
|
||||
},
|
||||
selectedNote: null,
|
||||
|
||||
setSelectedNote: (id: NoteRead | null) => {
|
||||
set({ selectedNote: id });
|
||||
},
|
||||
selectedFolder: null,
|
||||
|
||||
setSelectedFolder: (id: number | null) => {
|
||||
set({ selectedFolder: id });
|
||||
},
|
||||
|
||||
colourScheme: {
|
||||
base: "#24273a",
|
||||
surface0: "#1e2030",
|
||||
surface1: "#181926",
|
||||
overlay0: "#363a4f",
|
||||
overlay1: "#494d64",
|
||||
text: "#cad3f5",
|
||||
subtext: "#b8c0e0",
|
||||
accent: "#e2a16f",
|
||||
danger: "#e26f6f",
|
||||
success: "#6fe29b",
|
||||
warn: "#e2c56f",
|
||||
},
|
||||
|
||||
colourScales: {
|
||||
accent: generateColorScale("#e2a16f"),
|
||||
danger: generateColorScale("#e26f6f"),
|
||||
success: generateColorScale("#6fe29b"),
|
||||
warn: generateColorScale("#e2c56f"),
|
||||
},
|
||||
|
||||
setColourScheme: (colors: ColourState) => {
|
||||
const scales: ColourScales = {
|
||||
accent: generateColorScale(colors.accent),
|
||||
danger: generateColorScale(colors.danger),
|
||||
success: generateColorScale(colors.success),
|
||||
warn: generateColorScale(colors.warn),
|
||||
};
|
||||
|
||||
console.log(scales);
|
||||
set({ colourScheme: colors, colourScales: scales });
|
||||
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
document.documentElement.style.setProperty(`--color-${key}`, value);
|
||||
});
|
||||
|
||||
const scaleColors = ["accent", "danger", "success", "warn"] as const;
|
||||
scaleColors.forEach((colorName) => {
|
||||
Object.entries(scales[colorName]).forEach(([step, value]) => {
|
||||
document.documentElement.style.setProperty(
|
||||
`--color-${colorName}-${step}`,
|
||||
value,
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "ui-store",
|
||||
partialize: (state) => ({
|
||||
sideBarResize: state.sideBarResize,
|
||||
colourScheme: state.colourScheme,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
9
frontend/src/test/setup.ts
Normal file
9
frontend/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { expect, afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import * as matchers from "@testing-library/jest-dom/matchers";
|
||||
|
||||
expect.extend(matchers);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
1192
frontend/src/types/api.d.ts
vendored
Normal file
1192
frontend/src/types/api.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load diff
123
frontend/src/utils/colorScale.ts
Normal file
123
frontend/src/utils/colorScale.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
function hexToOklch(hex: string): { l: number; c: number; h: number } {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const toLinear = (c: number) =>
|
||||
c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
const lr = toLinear(r);
|
||||
const lg = toLinear(g);
|
||||
const lb = toLinear(b);
|
||||
|
||||
const l_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
|
||||
const m_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
|
||||
const s_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
|
||||
|
||||
const l = Math.cbrt(l_);
|
||||
const m = Math.cbrt(m_);
|
||||
const s = Math.cbrt(s_);
|
||||
|
||||
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
||||
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
||||
const bVal = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
||||
|
||||
const C = Math.sqrt(a * a + bVal * bVal);
|
||||
let H = (Math.atan2(bVal, a) * 180) / Math.PI;
|
||||
if (H < 0) H += 360;
|
||||
|
||||
return { l: L, c: C, h: H };
|
||||
}
|
||||
|
||||
function oklchToHex(l: number, c: number, h: number): string {
|
||||
const toSrgb = (x: number) =>
|
||||
x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
|
||||
|
||||
const computeRgb = (l: number, c: number, h: number) => {
|
||||
const a = c * Math.cos((h * Math.PI) / 180);
|
||||
const b = c * Math.sin((h * Math.PI) / 180);
|
||||
|
||||
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = l - 0.0894841775 * a - 1.291485548 * b;
|
||||
|
||||
return {
|
||||
r: toSrgb(
|
||||
4.0767416621 * l_ ** 3 -
|
||||
3.3077115913 * m_ ** 3 +
|
||||
0.2309699292 * s_ ** 3,
|
||||
),
|
||||
g: toSrgb(
|
||||
-1.2684380046 * l_ ** 3 +
|
||||
2.6097574011 * m_ ** 3 -
|
||||
0.3413193965 * s_ ** 3,
|
||||
),
|
||||
b: toSrgb(
|
||||
-0.0041960863 * l_ ** 3 -
|
||||
0.7034186147 * m_ ** 3 +
|
||||
1.707614701 * s_ ** 3,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
let { r, g, b: bVal } = computeRgb(l, c, h);
|
||||
|
||||
if (Math.max(r, g, bVal) > 1 || Math.min(r, g, bVal) < 0) {
|
||||
let lo = 0;
|
||||
let hi = c;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const rgb = computeRgb(l, mid, h);
|
||||
if (
|
||||
Math.max(rgb.r, rgb.g, rgb.b) > 1 ||
|
||||
Math.min(rgb.r, rgb.g, rgb.b) < 0
|
||||
) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
({ r, g, b: bVal } = computeRgb(l, lo, h));
|
||||
}
|
||||
|
||||
const toHex = (x: number) =>
|
||||
Math.round(Math.max(0, Math.min(1, x)) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(bVal)}`;
|
||||
}
|
||||
|
||||
export function generateColorScale(hex: string): Record<string, string> {
|
||||
const { l: inputL, c, h } = hexToOklch(hex);
|
||||
|
||||
const lightnessOffsets: Record<number, number> = {
|
||||
50: +0.42,
|
||||
100: +0.35,
|
||||
200: +0.25,
|
||||
300: +0.15,
|
||||
400: +0.07,
|
||||
500: 0,
|
||||
600: -0.08,
|
||||
700: -0.16,
|
||||
800: -0.24,
|
||||
900: -0.32,
|
||||
950: -0.4,
|
||||
};
|
||||
|
||||
const scale: Record<string, string> = {};
|
||||
|
||||
for (const [step, offset] of Object.entries(lightnessOffsets)) {
|
||||
let lightness = Math.max(0.08, Math.min(0.97, inputL + offset));
|
||||
|
||||
let adjustedChroma = c;
|
||||
if (lightness > 0.88) {
|
||||
adjustedChroma = c * ((0.97 - lightness) / 0.09);
|
||||
} else if (lightness < 0.25) {
|
||||
adjustedChroma = c * (lightness / 0.25);
|
||||
}
|
||||
|
||||
scale[step] = oklchToHex(lightness, Math.max(0, adjustedChroma), h);
|
||||
}
|
||||
|
||||
return scale;
|
||||
}
|
||||
14
frontend/src/vite-env.d.ts
vendored
Normal file
14
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly PROD: boolean;
|
||||
readonly DEV: boolean;
|
||||
readonly MODE: string;
|
||||
readonly BASE_URL: string;
|
||||
readonly SSR: boolean;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
37
frontend/tsconfig.json
Normal file
37
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@components/*": ["./src/components/*"],
|
||||
"@pages/*": ["./src/pages/*"],
|
||||
"@stores/*": ["./src/stores/*"],
|
||||
"@api/*": ["./src/api/*"],
|
||||
"@contexts/*": ["./src/contexts/*"],
|
||||
"@assets/*": ["./src/assets/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.mts"]
|
||||
}
|
||||
34
frontend/vite.config.mts
Normal file
34
frontend/vite.config.mts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import * as path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), react(), svgr()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@components": path.resolve(__dirname, "./src/components"),
|
||||
"@pages": path.resolve(__dirname, "./src/pages"),
|
||||
"@stores": path.resolve(__dirname, "./src/stores"),
|
||||
"@api": path.resolve(__dirname, "./src/api"),
|
||||
"@contexts": path.resolve(__dirname, "./src/contexts"),
|
||||
"@assets": path.resolve(__dirname, "./src/assets"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue