Compare commits

...

39 commits

Author SHA1 Message Date
25af4639bf Update case transformer 2026-01-15 22:45:38 +00:00
eb52756c6d Added theming and fix css.
Added unparsed view
2026-01-15 22:26:08 +00:00
e0a8e503b9 routing fix 2026-01-06 17:00:47 +00:00
6ee055b691 test 2026-01-06 16:52:46 +00:00
4bf58513bc update 2026-01-06 16:51:05 +00:00
12eaeb4351 update 2026-01-06 16:47:18 +00:00
3d344b86ac update 2026-01-06 16:41:39 +00:00
fb6912b30f test 2026-01-06 15:48:41 +00:00
e3eac33abf test 2026-01-06 15:44:53 +00:00
7087f21891 g 2026-01-06 15:38:11 +00:00
bb466fbaab test 2026-01-06 15:37:06 +00:00
a4f260f5a3 test 2026-01-06 15:37:01 +00:00
0e6ce1eec2 test 2026-01-06 15:31:57 +00:00
287c47d53d updates 2026-01-06 15:11:08 +00:00
d8de720f8c update 2026-01-06 15:04:02 +00:00
6378b75bf3 composer 2026-01-06 14:19:33 +00:00
be63cb82e6 update 2026-01-06 14:10:58 +00:00
13de7316ef update 2026-01-06 14:08:13 +00:00
e1ce554705 comp 2026-01-06 13:52:31 +00:00
5196b108c5 compose 2026-01-06 13:51:05 +00:00
1143315258 svgs 2026-01-06 13:49:28 +00:00
82edaf99e8 gitignore 2026-01-06 13:34:23 +00:00
192a6f7b09 update git ignore 2026-01-06 13:33:21 +00:00
e3da8db7c3 docker updates 2026-01-06 13:28:32 +00:00
a8f66644ad update docker 2026-01-06 13:13:59 +00:00
b15f09e604 update dockerfile 2026-01-06 13:10:06 +00:00
7ed4512bbc update yaml 2026-01-06 13:07:16 +00:00
40b74704e7 Added docker support 2026-01-06 12:51:40 +00:00
d1d436b020 Removed notes and removed files that should be ignored. 2025-12-30 22:36:48 +00:00
a62e2d744d Outstanding tag work ready to be moved to feature branch 2025-12-30 22:11:11 +00:00
40f5a3d794 Add tag selector and refactor note editing UI 2025-12-24 14:35:36 +00:00
ffbf485935 Update notes response model and implement error handling 2025-12-22 22:47:28 +00:00
3fe4b9ea88 Huge refactor adding in react query and general clean up. not finsihed
yet. more cleanup needs to be done.

- added react query
- moved to openapi instead of axios
- added case translator from frontend to backend
2025-12-22 15:23:40 +00:00
jamitz440
b6afaf8606
Merge pull request #12 from jamitz440/test/implement-vitest
Add Vitest setup and tests for encryption
2025-12-18 18:14:06 +00:00
jamitz440
03b71c2b64
Merge pull request #11 from jamitz440/feature/add-tags
Feature/add tags
2025-12-18 18:13:21 +00:00
0abeb90cb0 Add tag tree API and frontend tag decryption
- Implement backend tag tree endpoint at /tags/tree with TagTreeNode and
  TagTreeResponse models
- Add frontend tag tree decryption logic and wire it into notes
  decryption flow
- Fetch and decrypt tag tree in tags.tsx; integrate with tag store
- Add UI toggle for folders vs tags and update Sidebar and Header
2025-12-18 18:12:23 +00:00
c01a1fc908 Add Tag Support With Backend Models And UI 2025-12-15 21:33:00 +00:00
jamitz440
c4b47f05ce
Merge pull request #10 from jamitz440/fix/fodler-tree-initial-render
Refactor imports and add auth clearAll
2025-12-13 21:26:54 +00:00
2eb924dc9c Add Vitest setup and tests for encryption
- Set up Vitest with testing-library and jsdom for frontend tests - Add
encryption.test.ts to verify deriveKey, wrapMasterKey, unwrapMasterKey -
Add test/setup.ts to extend jest-dom matchers and cleanup after tests -
Enable Vitest in Vite config and add test scripts in
frontend/package.json
2025-12-13 12:15:14 +00:00
82 changed files with 12785 additions and 8909 deletions

26
.gitignore vendored
View file

@ -1,3 +1,27 @@
node_modules node_modules
frontend/src/assets/fontawesome/svg/* *.svg
frontend/src/assets/fontawesome/svg/0.svg frontend/src/assets/fontawesome/svg/0.svg
*.db
.zed/settings.json
**.pyc
!xmark.svg
!plus.svg
!circle-check.svg
!rotate.svg
!circle-exclamation.svg
!folder.svg
!tags.svg
!caret-right.svg
!folder-plus.svg
!file-circle-plus.svg
!bold.svg
!italic.svg
!strikethrough.svg
!code.svg
!list-ul.svg
!list-ol.svg
!square-check.svg
!code-simple.svg
!quote-left.svg
!gear.svg
frontend/dist/*

32
backend/.dockerignore Normal file
View file

@ -0,0 +1,32 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
ENV/
env/
*.db
*.sqlite
*.sqlite3
.pytest_cache/
.coverage
htmlcov/
.tox/
.mypy_cache/
.dmypy.json
dmypy.json
.vscode/
.idea/
*.log
.DS_Store
.env
.env.local
*.md
!README.md
.git/
.gitignore
Dockerfile
docker-compose*.yml
compose*.yaml

View file

@ -1,10 +1,39 @@
FROM python:3.11-slim # ---- Builder stage ----
FROM python:3.12-slim AS builder
WORKDIR /app WORKDIR /app
# Install dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
COPY ./app ./app # ---- Runtime stage ----
FROM python:3.12-slim
WORKDIR /app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] # Copy installed packages from builder
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 application code
COPY app ./app
# Copy startup script
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
# Create data directory for SQLite and other persistent data
RUN mkdir -p /app/data
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Add healthcheck with longer start period for initialization
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
# Run with startup script
CMD ["/app/start.sh"]

View file

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

View file

@ -1,10 +1,23 @@
import os
from dotenv import load_dotenv
from sqlmodel import Session, SQLModel, create_engine # type: ignore from sqlmodel import Session, SQLModel, create_engine # type: ignore
DATABASE_URL = "sqlite:///./notes.db" load_dotenv()
# Get database URL from environment, with proper fallback
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine( # If DATABASE_URL is not set or empty, use default SQLite
DATABASE_URL, echo=True, connect_args={"check_same_thread": False} 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}")
# Only use check_same_thread for SQLite
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(): def create_db_and_tables():

View file

@ -1,15 +1,17 @@
import os
from fastapi import FastAPI # type: ignore from fastapi import FastAPI # type: ignore
from fastapi.middleware.cors import CORSMiddleware # type:ignore from fastapi.middleware.cors import CORSMiddleware # type:ignore
from app.database import create_db_and_tables from app.database import create_db_and_tables
from app.routes import auth, folders, notes from app.routes import auth, folders, notes, tags
app = FastAPI(title="Notes API") app = FastAPI(title="Notes API")
# CORS - adjust origins for production cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173"], # Vite dev server allow_origins=cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -24,8 +26,15 @@ def on_startup():
app.include_router(notes.router, prefix="/api") app.include_router(notes.router, prefix="/api")
app.include_router(folders.router, prefix="/api") app.include_router(folders.router, prefix="/api")
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(tags.router, prefix="/api")
@app.get("/") @app.get("/")
def root(): def root():
return {"message": "Notes API"} return {"message": "Notes API"}
@app.get("/health")
def health():
"""Health check endpoint for Docker and Coolify"""
return {"status": "healthy"}

View file

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

Binary file not shown.

View file

@ -1,13 +1,12 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
from sqlmodel import Session, SQLModel, select
from app.auth import create_session, hash_password, require_auth, verify_password from app.auth import create_session, hash_password, require_auth, verify_password
from app.database import get_session from app.database import get_session
from app.models import Session as SessionModel from app.models import Session as SessionModel
from app.models import User from app.models import User
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
from sqlmodel import Session, SQLModel, select
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@ -30,8 +29,8 @@ class UserResponse(SQLModel):
id: int id: int
username: str username: str
email: str email: str
salt: str # Client needs this for key derivation salt: str
wrapped_master_key: str # Client needs this to unwrap the master key wrapped_master_key: str
@router.post("/register") @router.post("/register")
@ -72,7 +71,7 @@ def register(
key="session_id", key="session_id",
value=session_id, value=session_id,
httponly=True, httponly=True,
secure=True, # HTTPS only in production secure=True,
samesite="lax", samesite="lax",
max_age=30 * 24 * 60 * 60, # 30 days max_age=30 * 24 * 60 * 60, # 30 days
) )
@ -147,15 +146,15 @@ def list_sessions(
return {"sessions": sessions} return {"sessions": sessions}
@router.delete("/sessions/{session_token}") # Renamed from session_id @router.delete("/sessions/{session_token}")
def revoke_session( def revoke_session(
session_token: str, # Renamed to avoid conflict with Cookie parameter session_token: str,
current_user: User = Depends(require_auth), current_user: User = Depends(require_auth),
db: Session = Depends(get_session), db: Session = Depends(get_session),
): ):
session = db.exec( session = db.exec(
select(SessionModel) select(SessionModel)
.where(SessionModel.session_id == session_token) # Use renamed variable .where(SessionModel.session_id == session_token)
.where(SessionModel.user_id == current_user.id) .where(SessionModel.user_id == current_user.id)
).first() ).first()

View file

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

View file

@ -1,21 +1,21 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.auth import require_auth from app.auth import require_auth
from app.database import get_session from app.database import get_session
from app.models import Note, NoteCreate, NoteUpdate, User from app.models import Note, NoteCreate, NoteRead, NoteUpdate, User
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
router = APIRouter(prefix="/notes", tags=["notes"]) router = APIRouter(prefix="/notes", tags=["notes"])
@router.get("/") @router.get("/", response_model=list[NoteRead])
def list_notes(session: Session = Depends(get_session)): def list_notes(session: Session = Depends(get_session)):
notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() # pyright: ignore[reportAttributeAccessIssue] notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() # pyright: ignore[reportAttributeAccessIssue]
return notes return notes
@router.post("/", response_model=Note) @router.post("/", response_model=Note)
def create_note( def create_note(
note: NoteCreate, note: NoteCreate,

View file

@ -0,0 +1,91 @@
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 APIRouter, Depends, HTTPException
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select
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"}

Binary file not shown.

View file

@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="notes.db" readonly="0" foreign_keys="1" case_sensitive_like="0" temp_store="0" wal_autocheckpoint="1000" synchronous="2"/><attached/><window><main_tabs open="structure browser pragmas query" current="1"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="2087"/><column_width id="4" width="0"/><expanded_item id="0" parent="1"/><expanded_item id="1" parent="1"/><expanded_item id="2" parent="1"/><expanded_item id="3" parent="1"/></tab_structure><tab_browse><table title="user" custom_title="0" dock_id="1" table="4,4:mainuser"/><dock_state state="000000ff00000000fd00000001000000020000030e000004effc0100000001fb000000160064006f0063006b00420072006f007700730065003101000000000000030e0000012000ffffff000002a80000000000000004000000040000000800000008fc00000000"/><default_encoding codec=""/><browse_table_settings><table schema="main" name="folder" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="23"/><column index="2" value="187"/><column index="3" value="64"/><column index="4" value="210"/><column index="5" value="53"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="session" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="23"/><column index="2" value="300"/><column index="3" value="53"/><column index="4" value="210"/><column index="5" value="210"/><column index="6" value="73"/><column index="7" value="300"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="user" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="23"/><column index="2" value="67"/><column index="3" value="124"/><column index="4" value="300"/><column index="5" value="179"/><column index="6" value="210"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table></browse_table_settings></tab_browse><tab_sql><sql name="SQL 1"></sql><current_tab id="0"/></tab_sql></sqlb_project>

21
backend/start.sh Normal file
View file

@ -0,0 +1,21 @@
#!/bin/sh
set -e
echo "========================================="
echo "Starting FastNotes 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 "========================================="
# Create data directory if it doesn't exist
mkdir -p /app/data
echo "Created/verified /app/data directory"
# Start uvicorn
echo "Starting uvicorn..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips "*"

44
compose.yaml Normal file
View file

@ -0,0 +1,44 @@
services:
api:
build:
context: ./backend
container_name: fastnotes-api
environment:
- DATABASE_URL=${DATABASE_URL:-sqlite:////app/data/notes.db}
- SECRET_KEY=${SECRET_KEY:-change-this-in-production}
- CORS_ORIGINS=${CORS_ORIGINS:-*}
# Internal only - accessed via nginx proxy
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:
# Frontend will use /api path (proxied by nginx)
VITE_API_URL: ${VITE_API_URL:-/api}
container_name: fastnotes-ui
# Coolify manages ports via its proxy - expose instead of publish
expose:
- "80"
depends_on:
- api
restart: unless-stopped
volumes:
api_data:

27
frontend/.dockerignore Normal file
View 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
View 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;"]

48
frontend/nginx.conf Normal file
View 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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,9 +3,13 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"generate-types": "openapi-typescript http://localhost:8000/openapi.json -o src/types/api.d.ts"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -14,25 +18,40 @@
"@mdxeditor/editor": "^3.49.3", "@mdxeditor/editor": "^3.49.3",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1",
"@tiptap/extension-placeholder": "^3.12.1", "@tiptap/extension-placeholder": "^3.12.1",
"@tiptap/react": "^3.12.1", "@tiptap/react": "^3.12.1",
"@tiptap/starter-kit": "^3.12.1", "@tiptap/starter-kit": "^3.12.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"framer-motion": "^12.23.25", "framer-motion": "^12.23.25",
"humps": "^2.0.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"openapi-fetch": "^0.15.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.9.6", "react-router-dom": "^7.9.6",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tiptap-markdown": "^0.9.0", "tiptap-markdown": "^0.9.0",
"uuid": "^13.0.0",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@catppuccin/tailwindcss": "^1.0.0", "@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": "^19.2.6",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"vite": "^5.4.21", "@vitest/ui": "^4.0.15",
"vite-plugin-svgr": "^4.5.0" "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"
} }
} }

View 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;

View 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);
});
});

View file

@ -1,4 +1,23 @@
import { FolderTreeResponse, FolderTreeNode } from "./folders"; 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) { export async function deriveKey(password: string, salt: string) {
const enc = new TextEncoder(); const enc = new TextEncoder();
@ -114,6 +133,12 @@ export async function decryptFolderTree(
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})), })),
), ),
children: await Promise.all( children: await Promise.all(
@ -126,12 +151,40 @@ export async function decryptFolderTree(
folders: await Promise.all( folders: await Promise.all(
tree.folders.map((folder) => decryptFolder(folder)), tree.folders.map((folder) => decryptFolder(folder)),
), ),
orphaned_notes: await Promise.all( orphanedNotes: await Promise.all(
tree.orphaned_notes.map(async (note) => ({ tree.orphanedNotes.map(async (note) => ({
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})), })),
), ),
}; };
} }
export const decryptTagTree = async (
tags: 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),
};
}),
);
};

View file

@ -1,60 +1,34 @@
import axios from "axios";
import { decryptFolderTree } from "./encryption"; import { decryptFolderTree } from "./encryption";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import { CamelCasedPropertiesDeep } from "type-fest";
import { components } from "@/types/api";
import client from "./client";
axios.defaults.withCredentials = true; export type Folder = CamelCasedPropertiesDeep<components["schemas"]["Folder"]>;
const API_URL = (import.meta as any).env.PROD export type FolderTreeNode = CamelCasedPropertiesDeep<
? "/api" components["schemas"]["FolderTreeNode"]
: "http://localhost:8000/api"; >;
export interface Folder { export type FolderTreeResponse = CamelCasedPropertiesDeep<
id: number; components["schemas"]["FolderTreeResponse"]
name: string; >;
parent_id: number | null; export type FolderCreate = CamelCasedPropertiesDeep<
created_at: string; components["schemas"]["FolderCreate"]
} >;
export type FolderUpdate = CamelCasedPropertiesDeep<
export interface NoteRead { components["schemas"]["FolderUpdate"]
id: number; >;
title: string;
content: string;
folder_id: number | null;
created_at: string;
updated_at: string;
}
export interface FolderTreeNode {
id: number;
name: string;
notes: NoteRead[];
children: FolderTreeNode[];
}
export interface FolderTreeResponse {
folders: FolderTreeNode[];
orphaned_notes: NoteRead[];
}
export interface FolderCreate {
name: string;
parent_id: number | null;
}
export interface FolderUpdate {
name?: string;
parent_id?: number | null;
}
const getFolderTree = async () => { const getFolderTree = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey; const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated"); if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get<FolderTreeResponse>( const { data, error } = await client.GET("/folders/tree", {});
`${API_URL}/folders/tree`,
);
const decryptedFolderTree = await decryptFolderTree(data, encryptionKey); const newData = data as unknown as FolderTreeResponse;
const decryptedFolderTree = await decryptFolderTree(newData, encryptionKey);
return decryptedFolderTree; return decryptedFolderTree;
}; };
@ -62,7 +36,10 @@ const getFolderTree = async () => {
const updateFolder = async (id: number, folder: FolderUpdate) => { const updateFolder = async (id: number, folder: FolderUpdate) => {
console.log(`Updating folder ${id} with:`, folder); console.log(`Updating folder ${id} with:`, folder);
try { try {
const response = await axios.patch(`${API_URL}/folders/${id}`, folder); const response = await client.PATCH("/folders/{folder_id}", {
params: { path: { folder_id: id } },
body: folder,
});
console.log(`Folder ${id} update response:`, response.data); console.log(`Folder ${id} update response:`, response.data);
return response; return response;
} catch (error) { } catch (error) {
@ -73,10 +50,12 @@ const updateFolder = async (id: number, folder: FolderUpdate) => {
export const folderApi = { export const folderApi = {
tree: () => getFolderTree(), tree: () => getFolderTree(),
list: () => axios.get<Folder[]>(`${API_URL}/folders`), list: () => client.GET("/folders/", {}),
create: (folder: FolderCreate) => create: (folder: FolderCreate) => client.POST("/folders/", { body: folder }),
axios.post<Folder>(`${API_URL}/folders/`, folder), delete: (id: number) =>
delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`), client.DELETE("/folders/{folder_id}", {
params: { path: { folder_id: id } },
}),
update: (id: number, updateData: FolderUpdate) => update: (id: number, updateData: FolderUpdate) =>
updateFolder(id, updateData), updateFolder(id, updateData),
}; };

View file

@ -1,26 +1,16 @@
import axios from "axios";
import { NoteRead } from "./folders";
import { encryptString, decryptString } from "./encryption"; import { encryptString, decryptString } from "./encryption";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
axios.defaults.withCredentials = true; import { CamelCasedPropertiesDeep } from "type-fest";
const API_URL = (import.meta as any).env.PROD import { components } from "@/types/api";
? "/api" import client from "./client";
: "http://localhost:8000/api";
export interface Note { export type NoteRead = CamelCasedPropertiesDeep<
id: number; components["schemas"]["NoteRead"]
title: string; >;
folder_id?: number; export type NoteCreate = CamelCasedPropertiesDeep<
content: string; components["schemas"]["NoteCreate"]
created_at: string; >;
updated_at: string; export type Note = CamelCasedPropertiesDeep<components["schemas"]["Note"]>;
}
export interface NoteCreate {
title: string;
content: string;
folder_id: number | null;
}
const createNote = async (note: NoteCreate) => { const createNote = async (note: NoteCreate) => {
const encryptionKey = useAuthStore.getState().encryptionKey; const encryptionKey = useAuthStore.getState().encryptionKey;
@ -32,52 +22,93 @@ const createNote = async (note: NoteCreate) => {
var encryptedNote = { var encryptedNote = {
title: noteTitle, title: noteTitle,
content: noteContent, content: noteContent,
folder_id: note.folder_id, folderId: note.folderId,
}; };
console.log(encryptedNote); console.log(encryptedNote);
return axios.post(`${API_URL}/notes/`, encryptedNote); return client.POST(`/notes/`, { body: encryptedNote });
}; };
const fetchNotes = async () => { const fetchNotes = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey; const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated"); if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get(`${API_URL}/notes/`); const { data, error } = await client.GET(`/notes/`);
if (error) {
throw new Error(error);
}
console.log(data); console.log(data);
if (data) {
const decryptedNotes = await Promise.all( const decryptedNotes = await Promise.all(
data.map(async (note: Note) => ({ data.map(async (note) => ({
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: note.tags
? await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
)
: [],
})), })),
); );
return decryptedNotes; return decryptedNotes;
}
}; };
const updateNote = async (id: number, note: Partial<Note>) => { const updateNote = async (id: number, note: Partial<NoteRead>) => {
const encryptionKey = useAuthStore.getState().encryptionKey; const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated"); if (!encryptionKey) throw new Error("Not authenticated");
var encryptedNote: Partial<Note> = {}; var encryptedNote: Partial<NoteRead> = {};
if (note.content) { if (note.content) {
encryptedNote.content = await encryptString(note.content, encryptionKey); encryptedNote.content = await encryptString(note.content, encryptionKey);
} }
if (note.title) { if (note.title) {
encryptedNote.title = await encryptString(note.title, encryptionKey); encryptedNote.title = await encryptString(note.title, encryptionKey);
} }
if (note.folder_id) { if (note.folderId) {
encryptedNote.folder_id = note.folder_id; encryptedNote.folderId = note.folderId;
} }
return axios.patch(`${API_URL}/notes/${id}`, encryptedNote); 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 = { export const notesApi = {
list: () => fetchNotes(), list: () => fetchNotes(),
get: (id: number) => axios.get(`${API_URL}/notes/${id}`), get: (id: number) =>
client.GET(`/notes/{note_id}`, {
params: {
path: {
note_id: id,
},
},
}),
create: (note: NoteCreate) => createNote(note), create: (note: NoteCreate) => createNote(note),
update: (id: number, note: Partial<Note>) => updateNote(id, note), update: (id: number, note: Partial<NoteRead>) => updateNote(id, note),
delete: (id: number) => axios.delete(`${API_URL}/notes/${id}`), delete: (id: number) =>
client.DELETE(`/notes/{note_id}`, {
params: {
path: {
note_id: id,
},
},
}),
}; };

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d=""/><path class="fa-primary" d="M0 56C0 42.7 10.7 32 24 32l48 0 16 0 124 0c68.5 0 124 55.5 124 124c0 34.7-14.3 66.2-37.3 88.7C339.7 264.9 368 307.1 368 356c0 68.5-55.5 124-124 124L88 480l-16 0-48 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l24 0 0-176L48 80 24 80C10.7 80 0 69.3 0 56zM212 232c42 0 76-34 76-76s-34-76-76-76L96 80l0 152 116 0zM96 280l0 152 148 0c42 0 76-34 76-76s-34-76-76-76l-32 0L96 280z"/></svg>

After

Width:  |  Height:  |  Size: 718 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M112 166.6l0 178.7L201.4 256 112 166.6z"/><path class="fa-primary" d="M201.4 256L112 166.6l0 178.7L201.4 256zm45.3-22.6c12.5 12.5 12.5 32.8 0 45.3l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9s-19.8-16.6-19.8-29.6l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128z"/></svg>

After

Width:  |  Height:  |  Size: 585 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M48 256a208 208 0 1 0 416 0A208 208 0 1 0 48 256zm240 96a32 32 0 1 1 -64 0 32 32 0 1 1 64 0zM232 152c0-13.3 10.7-24 24-24s24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112z"/><path class="fa-primary" d="M256 48a208 208 0 1 1 0 416 208 208 0 1 1 0-416zm0 464A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"/></svg>

After

Width:  |  Height:  |  Size: 772 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d=""/><path class="fa-primary" d="M216.6 105.4c9.6-9.2 9.9-24.3 .8-33.9s-24.3-9.9-33.9-.8l-176 168C2.7 243.2 0 249.4 0 256s2.7 12.8 7.4 17.4l176 168c9.6 9.2 24.8 8.8 33.9-.8s8.8-24.8-.8-33.9L58.8 256 216.6 105.4zm142.9 0L517.2 256 359.4 406.6c-9.6 9.2-9.9 24.3-.8 33.9s24.3 9.9 33.9 .8l176-168c4.7-4.5 7.4-10.8 7.4-17.4s-2.7-12.8-7.4-17.4l-176-168c-9.6-9.2-24.8-8.8-33.9 .8s-8.8 24.8 .8 33.9z"/></svg>

After

Width:  |  Height:  |  Size: 711 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d=""/><path class="fa-primary" d="M399.1 1.1c-12.7-3.9-26.1 3.1-30 15.8l-144 464c-3.9 12.7 3.1 26.1 15.8 30s26.1-3.1 30-15.8l144-464c3.9-12.7-3.1-26.1-15.8-30zm71.4 118.5c-9.1 9.7-8.6 24.9 1.1 33.9L580.9 256 471.6 358.5c-9.7 9.1-10.2 24.3-1.1 33.9s24.3 10.2 33.9 1.1l128-120c4.8-4.5 7.6-10.9 7.6-17.5s-2.7-13-7.6-17.5l-128-120c-9.7-9.1-24.9-8.6-33.9 1.1zm-301 0c-9.1-9.7-24.3-10.2-33.9-1.1l-128 120C2.7 243 0 249.4 0 256s2.7 13 7.6 17.5l128 120c9.7 9.1 24.9 8.6 33.9-1.1s8.6-24.9-1.1-33.9L59.1 256 168.4 153.5c9.7-9.1 10.2-24.3 1.1-33.9z"/></svg>

After

Width:  |  Height:  |  Size: 856 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M48 64c0-8.8 7.2-16 16-16l160 0 0 80c0 17.7 14.3 32 32 32l80 0 0 60.5c-48.2 31.4-80 85.8-80 147.5c0 35.4 10.5 68.4 28.5 96L64 464c-8.8 0-16-7.2-16-16L48 64z"/><path class="fa-primary" d="M64 464l220.5 0c12 18.4 27.4 34.5 45.3 47.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64C0 28.7 28.7 0 64 0L229.5 0c17 0 33.3 6.7 45.3 18.7l90.5 90.5c12 12 18.7 28.3 18.7 45.3l0 44.1c-17.2 4.9-33.4 12.3-48 21.8l0-60.5-80 0c-17.7 0-32-14.3-32-32l0-80L64 48c-8.8 0-16 7.2-16 16l0 384c0 8.8 7.2 16 16 16zM432 224a144 144 0 1 1 0 288 144 144 0 1 1 0-288zm16 80c0-8.8-7.2-16-16-16s-16 7.2-16 16l0 48-48 0c-8.8 0-16 7.2-16 16s7.2 16 16 16l48 0 0 48c0 8.8 7.2 16 16 16s16-7.2 16-16l0-48 48 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-48 0 0-48z"/></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M48 96c0-8.8 7.2-16 16-16l132.1 0c6.4 0 12.5 2.5 17 7l45.3 45.3c7.5 7.5 17.7 11.7 28.3 11.7L448 144c8.8 0 16 7.2 16 16l0 256c0 8.8-7.2 16-16 16L64 432c-8.8 0-16-7.2-16-16L48 96zm96 192c0 13.3 10.7 24 24 24l64 0 0 64c0 13.3 10.7 24 24 24s24-10.7 24-24l0-64 64 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-64 0 0-64c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 64-64 0c-13.3 0-24 10.7-24 24z"/><path class="fa-primary" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L289.9 96 247 53.1C233.5 39.6 215.2 32 196.1 32L64 32zM48 96c0-8.8 7.2-16 16-16l132.1 0c6.4 0 12.5 2.5 17 7l45.3 45.3c7.5 7.5 17.7 11.7 28.3 11.7L448 144c8.8 0 16 7.2 16 16l0 256c0 8.8-7.2 16-16 16L64 432c-8.8 0-16-7.2-16-16L48 96zM232 376c0 13.3 10.7 24 24 24s24-10.7 24-24l0-64 64 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-64 0 0-64c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 64-64 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l64 0 0 64z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M48 96l0 320c0 8.8 7.2 16 16 16l384 0c8.8 0 16-7.2 16-16l0-256c0-8.8-7.2-16-16-16l-161.4 0c-10.6 0-20.8-4.2-28.3-11.7L213.1 87c-4.5-4.5-10.6-7-17-7L64 80c-8.8 0-16 7.2-16 16z"/><path class="fa-primary" d="M0 96C0 60.7 28.7 32 64 32l132.1 0c19.1 0 37.4 7.6 50.9 21.1L289.9 96 448 96c35.3 0 64 28.7 64 64l0 256c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM64 80c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l384 0c8.8 0 16-7.2 16-16l0-256c0-8.8-7.2-16-16-16l-161.4 0c-10.6 0-20.8-4.2-28.3-11.7L213.1 87c-4.5-4.5-10.6-7-17-7L64 80z"/></svg>

After

Width:  |  Height:  |  Size: 860 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M59.9 186.6l26.2 24.9c24 22.8 24 66.2 0 89L59.9 325.4c8.5 24 21.5 46.4 38 65.7l34.6-10.2c31.8-9.4 69.4 12.3 77.2 44.6l8.5 35.1c24.6 4.5 51.3 4.5 75.9 0l8.5-35.1c7.8-32.3 45.3-53.9 77.2-44.6l34.6 10.2c16.5-19.3 29.5-41.7 38-65.7l-26.2-24.9c-24-22.8-24-66.2 0-89l26.2-24.9c-8.5-24-21.5-46.4-38-65.7l-34.6 10.2c-31.8 9.4-69.4-12.3-77.2-44.6l-8.5-35.1c-24.6-4.5-51.3-4.5-75.9 0l-8.5 35.1c-7.8 32.3-45.3 53.9-77.2 44.6L97.9 120.9c-16.5 19.3-29.5 41.7-38 65.7zM352 256a96 96 0 1 1 -192 0 96 96 0 1 1 192 0z"/><path class="fa-primary" d="M256 0c17 0 33.6 1.7 49.8 4.8c7.9 1.5 21.8 6.1 29.4 20.1c2 3.7 3.6 7.6 4.6 11.8l9.3 38.5C350.5 81 360.3 86.7 366 85l38-11.2c4-1.2 8.1-1.8 12.2-1.9c16.1-.5 27 9.4 32.3 15.4c22.1 25.1 39.1 54.6 49.9 86.3c2.6 7.6 5.6 21.8-2.7 35.4c-2.2 3.6-4.9 7-8 10L459 246.3c-4.2 4-4.2 15.5 0 19.5l28.7 27.3c3.1 3 5.8 6.4 8 10c8.2 13.6 5.2 27.8 2.7 35.4c-10.8 31.7-27.8 61.1-49.9 86.3c-5.3 6-16.3 15.9-32.3 15.4c-4.1-.1-8.2-.8-12.2-1.9L366 427c-5.7-1.7-15.5 4-16.9 9.8l-9.3 38.5c-1 4.2-2.6 8.2-4.6 11.8c-7.7 14-21.6 18.5-29.4 20.1C289.6 510.3 273 512 256 512s-33.6-1.7-49.8-4.8c-7.9-1.5-21.8-6.1-29.4-20.1c-2-3.7-3.6-7.6-4.6-11.8l-9.3-38.5c-1.4-5.8-11.2-11.5-16.9-9.8l-38 11.2c-4 1.2-8.1 1.8-12.2 1.9c-16.1 .5-27-9.4-32.3-15.4c-22-25.1-39.1-54.6-49.9-86.3c-2.6-7.6-5.6-21.8 2.7-35.4c2.2-3.6 4.9-7 8-10L53 265.7c4.2-4 4.2-15.5 0-19.5L24.2 218.9c-3.1-3-5.8-6.4-8-10C8 195.3 11 181.1 13.6 173.6c10.8-31.7 27.8-61.1 49.9-86.3c5.3-6 16.3-15.9 32.3-15.4c4.1 .1 8.2 .8 12.2 1.9L146 85c5.7 1.7 15.5-4 16.9-9.8l9.3-38.5c1-4.2 2.6-8.2 4.6-11.8c7.7-14 21.6-18.5 29.4-20.1C222.4 1.7 239 0 256 0zM218.1 51.4l-8.5 35.1c-7.8 32.3-45.3 53.9-77.2 44.6L97.9 120.9c-16.5 19.3-29.5 41.7-38 65.7l26.2 24.9c24 22.8 24 66.2 0 89L59.9 325.4c8.5 24 21.5 46.4 38 65.7l34.6-10.2c31.8-9.4 69.4 12.3 77.2 44.6l8.5 35.1c24.6 4.5 51.3 4.5 75.9 0l8.5-35.1c7.8-32.3 45.3-53.9 77.2-44.6l34.6 10.2c16.5-19.3 29.5-41.7 38-65.7l-26.2-24.9c-24-22.8-24-66.2 0-89l26.2-24.9c-8.5-24-21.5-46.4-38-65.7l-34.6 10.2c-31.8 9.4-69.4-12.3-77.2-44.6l-8.5-35.1c-24.6-4.5-51.3-4.5-75.9 0zM208 256a48 48 0 1 0 96 0 48 48 0 1 0 -96 0zm48 96a96 96 0 1 1 0-192 96 96 0 1 1 0 192z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d=""/><path class="fa-primary" d="M128 56c0-13.3 10.7-24 24-24l208 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-68.7 0L144.7 432l87.3 0c13.3 0 24 10.7 24 24s-10.7 24-24 24L24 480c-13.3 0-24-10.7-24-24s10.7-24 24-24l68.7 0L239.3 80 152 80c-13.3 0-24-10.7-24-24z"/></svg>

After

Width:  |  Height:  |  Size: 573 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d=""/><path class="fa-primary" d="M24 56c0-13.3 10.7-24 24-24l32 0c13.3 0 24 10.7 24 24l0 120 16 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l16 0 0-96-8 0C34.7 80 24 69.3 24 56zM86.7 341.2c-6.5-7.4-18.3-6.9-24 1.2L51.5 357.9c-7.7 10.8-22.7 13.3-33.5 5.6s-13.3-22.7-5.6-33.5l11.1-15.6c23.7-33.2 72.3-35.6 99.2-4.9c21.3 24.4 20.8 60.9-1.1 84.7L86.8 432l33.2 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-88 0c-9.5 0-18.2-5.6-22-14.4s-2.1-18.9 4.3-25.9l72-78c5.3-5.8 5.4-14.6 .3-20.5zM216 72l272 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-272 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm0 160l272 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-272 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm0 160l272 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-272 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d=""/><path class="fa-primary" d="M64 64a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm120 8c-13.3 0-24 10.7-24 24s10.7 24 24 24l304 0c13.3 0 24-10.7 24-24s-10.7-24-24-24L184 72zm0 160c-13.3 0-24 10.7-24 24s10.7 24 24 24l304 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-304 0zm0 160c-13.3 0-24 10.7-24 24s10.7 24 24 24l304 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-304 0zM96 256a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM64 384a32 32 0 1 0 0 64 32 32 0 1 0 0-64z"/></svg>

After

Width:  |  Height:  |  Size: 755 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d=""/><path class="fa-primary" d="M248 72c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 160L40 232c-13.3 0-24 10.7-24 24s10.7 24 24 24l160 0 0 160c0 13.3 10.7 24 24 24s24-10.7 24-24l0-160 160 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-160 0 0-160z"/></svg>

After

Width:  |  Height:  |  Size: 554 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M48 288c0-8.8 7.2-16 16-16l64 0c8.8 0 16 7.2 16 16l0 64c0 8.8-7.2 16-16 16l-64 0c-8.8 0-16-7.2-16-16l0-32 0-32zm256 0c0-8.8 7.2-16 16-16l64 0c8.8 0 16 7.2 16 16l0 64c0 8.8-7.2 16-16 16l-64 0c-8.8 0-16-7.2-16-16l0-32 0-32z"/><path class="fa-primary" d="M0 216C0 149.7 53.7 96 120 96l16 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-16 0c-39.8 0-72 32.2-72 72l0 10c5.1-1.3 10.5-2 16-2l64 0c35.3 0 64 28.7 64 64l0 64c0 35.3-28.7 64-64 64l-64 0c-35.3 0-64-28.7-64-64l0-32 0-32 0-72zm48 72l0 32 0 32c0 8.8 7.2 16 16 16l64 0c8.8 0 16-7.2 16-16l0-64c0-8.8-7.2-16-16-16l-64 0c-8.8 0-16 7.2-16 16zm336-16l-64 0c-8.8 0-16 7.2-16 16l0 32 0 32c0 8.8 7.2 16 16 16l64 0c8.8 0 16-7.2 16-16l0-64c0-8.8-7.2-16-16-16zM256 320l0-32 0-72c0-66.3 53.7-120 120-120l16 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-16 0c-39.8 0-72 32.2-72 72l0 10c5.1-1.3 10.5-2 16-2l64 0c35.3 0 64 28.7 64 64l0 64c0 35.3-28.7 64-64 64l-64 0c-35.3 0-64-28.7-64-64l0-32z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M48 96l0 320c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-320c0-8.8-7.2-16-16-16L64 80c-8.8 0-16 7.2-16 16zm63 143c9.4-9.4 24.6-9.4 33.9 0l47 47L303 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9L209 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9z"/><path class="fa-primary" d="M64 80c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-320c0-8.8-7.2-16-16-16L64 80zM0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM337 209L209 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L303 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>

After

Width:  |  Height:  |  Size: 979 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d=""/><path class="fa-primary" d="M145.5 138c4-21.5 17.9-37.4 41.7-47.4c24.7-10.4 59.4-13.7 99.9-7.5c12.8 2 52.4 9.5 64.9 12.8c12.8 3.3 25.9-4.3 29.3-17.2s-4.3-25.9-17.2-29.3c-14.7-3.8-56.1-11.7-69.7-13.8c-46.2-7.1-90.4-4.1-125.7 10.7c-36.1 15.1-63.3 43.1-70.5 83.9c-.1 .4-.1 .9-.2 1.3c-2.8 23.4 .5 44.2 9.8 62.2c9.2 17.8 23.2 31.2 38.8 41.5c2.4 1.6 5 3.2 7.5 4.7L24 240c-13.3 0-24 10.7-24 24s10.7 24 24 24l464 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-192.2 0c-9.9-3.1-19.7-6-29.2-8.8l-.3-.1c-37.7-11.1-70.5-20.7-93.3-35.8c-10.9-7.2-18.2-14.9-22.6-23.5c-4.2-8.2-6.6-18.9-4.9-33.8zM364 337.1c3.7 8.6 5.5 20.1 2.6 36.3c-3.8 21.8-17.8 37.9-41.8 48c-24.7 10.4-59.4 13.7-99.8 7.5c-20.1-3.2-54.3-14.6-81.2-23.6c0 0 0 0 0 0s0 0 0 0c-5.9-2-11.4-3.8-16.3-5.4c-12.6-4.1-26.1 2.8-30.3 15.4s2.8 26.2 15.4 30.3c4 1.3 8.8 2.9 14 4.7c26.6 8.9 66.4 22.2 90.9 26.2l.1 0c46.2 7.1 90.4 4.1 125.7-10.7c36.1-15.1 63.3-43.1 70.5-83.9c4-22.9 2.4-43.5-5-61.7l-57.2 0c5.7 5.3 9.7 11 12.3 17.1z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M48 80l149.5 0c4.2 0 8.3 1.7 11.3 4.7l168 168c6.2 6.2 6.2 16.4 0 22.6L243.3 408.8c-6.2 6.2-16.4 6.2-22.6 0l-168-168c-3-3-4.7-7.1-4.7-11.3L48 80zm32 64a32 32 0 1 0 64 0 32 32 0 1 0 -64 0z"/><path class="fa-primary" d="M345 39.1c-9.3-9.4-24.5-9.5-33.9-.2s-9.5 24.5-.2 33.9L438.6 202.1c33.9 34.3 33.9 89.4 0 123.7L326.7 439.1c-9.3 9.4-9.2 24.6 .2 33.9s24.6 9.2 33.9-.2L472.8 359.6c52.4-53 52.4-138.2 0-191.2L345 39.1zM242.7 50.7c-12-12-28.3-18.7-45.3-18.7L48 32C21.5 32 0 53.5 0 80L0 229.5c0 17 6.7 33.3 18.7 45.3l168 168c25 25 65.5 25 90.5 0L410.7 309.3c25-25 25-65.5 0-90.5l-168-168zM48 80l149.5 0c4.2 0 8.3 1.7 11.3 4.7l168 168c6.2 6.2 6.2 16.4 0 22.6L243.3 408.8c-6.2 6.2-16.4 6.2-22.6 0l-168-168c-3-3-4.7-7.1-4.7-11.3L48 80zm96 64a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. --><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d=""/><path class="fa-primary" d="M345 137c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-119 119L73 103c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l119 119L39 375c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l119-119L311 409c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-119-119L345 137z"/></svg>

After

Width:  |  Height:  |  Size: 590 B

View file

@ -1,7 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { FolderTreeNode } from "../../api/folders"; import { FolderTreeNode } from "../../api/folders";
import { useNoteStore } from "../../stores/notesStore"; import {
import { folderApi } from "../../api/folders"; useCreateFolder,
useUpdateFolder,
useDeleteFolder,
} from "../../hooks/useFolders";
interface FolderContextMenuProps { interface FolderContextMenuProps {
x: number; x: number;
@ -16,7 +19,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
folder, folder,
onClose, onClose,
}) => { }) => {
const { loadFolderTree, updateFolder } = useNoteStore(); const createFolderMutation = useCreateFolder();
const updateFolderMutation = useUpdateFolder();
const deleteFolderMutation = useDeleteFolder();
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState(folder.name); const [newName, setNewName] = useState(folder.name);
@ -25,8 +31,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
return; return;
} }
try { try {
await folderApi.delete(folder.id); await deleteFolderMutation.mutateAsync(folder.id);
await loadFolderTree();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to delete folder:", error); console.error("Failed to delete folder:", error);
@ -35,7 +40,14 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
const handleRename = async () => { const handleRename = async () => {
if (newName.trim() && newName !== folder.name) { if (newName.trim() && newName !== folder.name) {
await updateFolder(folder.id, { name: newName }); try {
await updateFolderMutation.mutateAsync({
folderId: folder.id,
folder: { name: newName },
});
} catch (error) {
console.error("Failed to rename folder:", error);
}
} }
setIsRenaming(false); setIsRenaming(false);
onClose(); onClose();
@ -43,11 +55,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
const handleCreateSubfolder = async () => { const handleCreateSubfolder = async () => {
try { try {
await folderApi.create({ await createFolderMutation.mutateAsync({
name: "New Folder", name: "New Folder",
parent_id: folder.id, parentId: folder.id,
}); });
await loadFolderTree();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to create subfolder:", error); console.error("Failed to create subfolder:", error);
@ -62,7 +73,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
top: y, top: y,
left: x, left: x,
}} }}
className="bg-ctp-surface0 border border-ctp-surface2 rounded-md shadow-lg p-2 min-w-[200px] z-50" className="bg-overlay0 border border-surface1 rounded-md shadow-lg p-2 min-w-[200px] z-50"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<input <input
@ -78,7 +89,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
}} }}
onBlur={handleRename} onBlur={handleRename}
autoFocus autoFocus
className="w-full px-2 py-1 bg-ctp-surface1 border border-ctp-surface2 rounded text-sm text-ctp-text focus:outline-none focus:border-ctp-mauve" className="w-full px-2 py-1 bg-surface1 border border-surface1 rounded text-sm text-text focus:outline-none focus:border-accent"
/> />
</div> </div>
); );
@ -91,25 +102,25 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
top: y, top: y,
left: x, left: x,
}} }}
className="bg-ctp-surface0 border border-ctp-surface2 rounded-md shadow-lg py-1 min-w-[160px] z-50" className="bg-overlay0 border border-surface1 rounded-md shadow-lg py-1 min-w-[160px] z-50"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button
onClick={() => setIsRenaming(true)} onClick={() => setIsRenaming(true)}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors" className="w-full text-left px-3 py-1.5 hover:bg-surface1 text-sm text-text transition-colors"
> >
Rename Rename
</button> </button>
<button <button
onClick={handleCreateSubfolder} onClick={handleCreateSubfolder}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors" className="w-full text-left px-3 py-1.5 hover:bg-surface1 text-sm text-text transition-colors"
> >
New Subfolder New Subfolder
</button> </button>
<div className="border-t border-ctp-surface2 my-1" /> <div className="border-t border-surface1 my-1" />
<button <button
onClick={handleDelete} onClick={handleDelete}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-red hover:text-ctp-base text-sm text-ctp-red transition-colors" className="w-full text-left px-3 py-1.5 hover:bg-danger hover:text-base text-sm text-danger transition-colors"
> >
Delete Delete
</button> </button>

View file

@ -1,12 +1,12 @@
import React from "react"; import React from "react";
import { NoteRead } from "../../api/folders"; import { Note } from "../../api/notes";
import { useNoteStore } from "../../stores/notesStore"; import { useCreateNote, useDeleteNote } from "../../hooks/useFolders";
import { notesApi } from "../../api/notes"; import { useUIStore } from "../../stores/uiStore";
interface NoteContextMenuProps { interface NoteContextMenuProps {
x: number; x: number;
y: number; y: number;
note: NoteRead; note: Note;
onClose: () => void; onClose: () => void;
} }
@ -16,12 +16,15 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
note, note,
onClose, onClose,
}) => { }) => {
const { loadFolderTree, setSelectedNote } = useNoteStore(); const { setSelectedNote } = useUIStore();
const deleteNoteMutation = useDeleteNote();
const createNoteMutation = useCreateNote();
const handleDelete = async () => { const handleDelete = async () => {
try { try {
await notesApi.delete(note.id); await deleteNoteMutation.mutateAsync(note.id);
await loadFolderTree(); // Clear selection if this note was selected
setSelectedNote(null);
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to delete note:", error); console.error("Failed to delete note:", error);
@ -30,12 +33,11 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
const handleDuplicate = async () => { const handleDuplicate = async () => {
try { try {
await notesApi.create({ await createNoteMutation.mutateAsync({
title: `${note.title} (Copy)`, title: `${note.title} (Copy)`,
content: note.content, content: note.content,
folder_id: note.folder_id, folderId: note.folderId || null,
}); });
await loadFolderTree();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to duplicate note:", error); console.error("Failed to duplicate note:", error);
@ -55,25 +57,25 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
top: y, top: y,
left: x, left: x,
}} }}
className="bg-ctp-surface0 border border-ctp-surface2 rounded-md shadow-lg py-1 min-w-[160px] z-50" className="bg-overlay0 border border-surface1 rounded-md shadow-lg py-1 min-w-[160px] z-50"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button
onClick={handleRename} onClick={handleRename}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors" className="w-full text-left px-3 py-1.5 hover:bg-surface1 text-sm text-text transition-colors"
> >
Rename Rename
</button> </button>
<button <button
onClick={handleDuplicate} onClick={handleDuplicate}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors" className="w-full text-left px-3 py-1.5 hover:bg-surface1 text-sm text-text transition-colors"
> >
Duplicate Duplicate
</button> </button>
<div className="border-t border-ctp-surface2 my-1" /> <div className="border-t border-surface1 my-1" />
<button <button
onClick={handleDelete} onClick={handleDelete}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-red hover:text-ctp-base text-sm text-ctp-red transition-colors" className="w-full text-left px-3 py-1.5 hover:bg-danger hover:text-base text-sm text-danger transition-colors"
> >
Delete Delete
</button> </button>

View file

@ -105,7 +105,7 @@ export const ContextMenuProvider = ({
e.preventDefault(); e.preventDefault();
closeContextMenu(); closeContextMenu();
}} }}
className=" h-screen w-screen bg-ctp-crust/25 z-40 fixed top-0 left-0" className=" h-screen w-screen bg-surface1/25 z-40 fixed top-0 left-0"
></div> ></div>
)} )}
{children} {children}

View 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"] });
},
});
};

View file

@ -2,44 +2,28 @@
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@import "@catppuccin/tailwindcss/macchiato.css"; @import "@catppuccin/tailwindcss/macchiato.css";
@theme {
--color-base:
;
}
@theme {
/* Map Tailwind classes to CSS variables */
--color-ctp-base: var(--color-ctp-base);
--color-ctp-mantle: var(--color-ctp-mantle);
--color-ctp-crust: var(--color-ctp-crust);
--color-ctp-text: var(--color-ctp-text);
--color-ctp-subtext0: #a5adcb;
--color-ctp-overlay0: #6e738d;
--color-ctp-mauve: var(--color-ctp-mauve);
--color-ctp-blue: var(--color-ctp-blue);
--color-ctp-green: #a6da95;
--color-ctp-red: #ed8796;
--color-ctp-yellow: #eed49f;
--color-ctp-teal: #8bd5ca;
--color-ctp-sapphire: #7dc4e4;
--color-ctp-peach: #f5a97f;
/* Surface colors */
--color-ctp-surface0: #363a4f;
--color-ctp-surface1: #494d64;
--color-ctp-surface2: #5b6078;
}
/* Default values (Macchiato) - injected by JS, but good as fallback */
:root { :root {
--color-ctp-base: #24273a; --black: 15, 18, 25;
--color-ctp-mantle: #1e2030; --gray: 96, 115, 159;
--color-ctp-crust: #181926; --gray-light: 229, 233, 240;
--color-ctp-text: #cad3f5; --gray-dark: 34, 41, 57;
--color-ctp-mauve: #c6a0f6; --box-shadow:
--color-ctp-blue: #8aadf4; 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);
}
@theme {
--color-base: #24273a;
--color-surface0: #1e2030;
--color-surface1: #181926;
--color-overlay0: #363a4f;
--color-overlay1: #494d64;
--color-text: #cad3f5;
--color-subtext: #b8c0e0;
--color-accent: #e2a16f;
--color-danger: #e26f6f;
--color-success: #6fe29b;
--color-warn: #e2c56f;
} }
/* Override MDXEditor and all its children */ /* Override MDXEditor and all its children */
@ -47,19 +31,19 @@
._mdxeditor-root-content-editable, ._mdxeditor-root-content-editable,
.mdxeditor-root-contenteditable, .mdxeditor-root-contenteditable,
div[contenteditable="true"] { div[contenteditable="true"] {
color: var(--color-ctp-text) !important; color: var(--color-text) !important;
} }
._listItemChecked_1tncs_73::before { ._listItemChecked_1tncs_73::before {
--accentSolid: var(--color-ctp-mauve) !important; --accentSolid: var(--color-accent) !important;
border-color: var(--color-ctp-mauve-900) !important; border-color: var(--color-accent) !important;
border: 2px; border: 2px;
} }
._listItemChecked_1tncs_73::after { ._listItemChecked_1tncs_73::after {
border-color: var(--color-ctp-mauve-900) !important; border-color: var(--color-accent) !important;
} }
.standard-input { .standard-input {
@apply border border-ctp-mauve rounded-sm px-2 py-1 w-full focus:outline-none focus:ring-2 focus:ring-ctp-mauve bg-ctp-base text-ctp-text placeholder:text-ctp-overlay0; @apply border border-overlay0 rounded-sm px-2 py-1 w-full focus:outline-none focus:ring-2 focus:ring-accent bg-base text-text placeholder:text-overlay1;
} }

View file

@ -2,11 +2,16 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import "./main.css"; import "./main.css";
// import "./assets/fontawesome/js/fontawesome.min.js"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// import "./assets/fontawesome/js/duotone-regular.js"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}>
<App /> <App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View file

@ -1,62 +1,73 @@
import { useEffect, useRef, useState } from "react"; import {
ChangeEvent,
ChangeEventHandler,
useEffect,
useRef,
useState,
} from "react";
import "../../main.css"; import "../../main.css";
import { motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
import { useNoteStore } from "@/stores/notesStore";
import { useUIStore } from "@/stores/uiStore"; import { useUIStore } from "@/stores/uiStore";
import { Login } from "../Login"; import { Login } from "../Login";
import { TiptapEditor } from "../TipTap"; import { TiptapEditor } from "../TipTap";
import { Sidebar } from "./components/sidebar/SideBar"; import { Sidebar } from "./components/sidebar/SideBar";
import { StatusIndicator } from "./components/StatusIndicator"; import { StatusIndicator } from "./components/StatusIndicator";
import { 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() { function Home() {
const [newFolder] = useState(false); const [newFolder] = useState(false);
// Local state for editing the current note
const [editingNote, setEditingNote] = useState<NoteRead | null>(null);
const [lastSavedNote, setLastSavedNote] = useState<{ const [lastSavedNote, setLastSavedNote] = useState<{
id: number; id: number;
title: string; title: string;
content: string; content: string;
} | null>(null); } | null>(null);
const { loadFolderTree, updateNote, setContent, selectedNote, setTitle } =
useNoteStore();
const { encryptionKey } = useAuthStore(); const { encryptionKey } = useAuthStore();
const { showModal, setUpdating, selectedNote, editorView } = useUIStore();
const { showModal, setUpdating } = useUIStore();
const newFolderRef = useRef<HTMLInputElement>(null); const newFolderRef = useRef<HTMLInputElement>(null);
useEffect(() => { const updateNoteMutation = useUpdateNote();
if (!encryptionKey) return;
loadFolderTree();
}, []);
// Sync editingNote with selectedNote when selection changes
useEffect(() => {
if (selectedNote) {
setEditingNote(selectedNote);
setLastSavedNote({
id: selectedNote.id,
title: selectedNote.title,
content: selectedNote.content,
});
} else {
setEditingNote(null);
setLastSavedNote(null);
}
}, [selectedNote?.id]);
useEffect(() => { useEffect(() => {
if (newFolder && newFolderRef.current) { if (newFolder && newFolderRef.current) {
newFolderRef.current.focus(); newFolderRef.current.focus();
} }
}, [newFolder]); }, [newFolder]);
// Auto-save effect - watches editingNote for changes
useEffect(() => { useEffect(() => {
if (!selectedNote) return; if (!editingNote) return;
if (!encryptionKey) return; // Don't try to save without encryption key if (!encryptionKey) return;
// Check if content or title actually changed (not just selecting a different note) // Check if content or title actually changed
const hasChanges = const hasChanges =
lastSavedNote && lastSavedNote &&
lastSavedNote.id === selectedNote.id && lastSavedNote.id === editingNote.id &&
(lastSavedNote.title !== selectedNote.title || (lastSavedNote.title !== editingNote.title ||
lastSavedNote.content !== selectedNote.content); lastSavedNote.content !== editingNote.content);
// If it's a new note selection, just update lastSavedNote without saving
if (!lastSavedNote || lastSavedNote.id !== selectedNote.id) {
setLastSavedNote({
id: selectedNote.id,
title: selectedNote.title,
content: selectedNote.content,
});
return;
}
if (!hasChanges) return; if (!hasChanges) return;
@ -64,25 +75,31 @@ function Home() {
setUpdating(true); setUpdating(true);
await handleUpdate(); await handleUpdate();
setLastSavedNote({ setLastSavedNote({
id: selectedNote.id, id: editingNote.id,
title: selectedNote.title, title: editingNote.title,
content: selectedNote.content, content: editingNote.content,
}); });
}, 2000); }, 2000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [selectedNote, encryptionKey]); }, [editingNote?.title, editingNote?.content, encryptionKey]);
const handleUpdate = async () => { const handleUpdate = async () => {
if (!selectedNote) return; if (!editingNote) return;
if (!encryptionKey) { if (!encryptionKey) {
setUpdating(false); setUpdating(false);
return; return;
} }
try { try {
await updateNote(selectedNote.id); if (!editingNote.id) throw new Error("Editing note has no id.");
console.log(selectedNote.id); await updateNoteMutation.mutateAsync({
noteId: editingNote.id,
note: {
title: editingNote.title,
content: editingNote.content,
},
});
} catch (error) { } catch (error) {
console.error("Failed to update note:", error); console.error("Failed to update note:", error);
} finally { } finally {
@ -92,28 +109,72 @@ function Home() {
} }
}; };
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 ( return (
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden"> <div className="flex bg-base h-screen text-text overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
{showModal && <Modal />} <AnimatePresence>{showModal && <Modal />}</AnimatePresence>
<Sidebar /> <Sidebar />
{/* Main editor area */} {/* Main editor area */}
<div className="flex flex-col w-full h-screen overflow-hidden"> <div className="flex flex-col w-full h-screen overflow-hidden">
{/*<Editor />*/} {" "}
{editingNote ? (
<>
<input <input
type="text" type="text"
id="noteTitle"
placeholder="Untitled note..." placeholder="Untitled note..."
value={selectedNote?.title || ""} value={editingNote.title || ""}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-3 text-3xl font-semibold bg-transparentfocus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text" className="w-full self-center p-4 pb-2 pt-2 text-3xl font-semibold focus:outline-none border-transparent focus:border-accent transition-colors placeholder:text-overlay0 text-text bg-surface1"
/> />
<div className="h-full lg:w-3xl w-full mx-auto overflow-y-hidden">
{" "}
{editorView == "parsed" ? (
<TiptapEditor <TiptapEditor
key={selectedNote?.id} key={editingNote.id}
content={selectedNote?.content || ""} content={editingNote.content || ""}
onChange={setContent} 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> </div>
<StatusIndicator /> <StatusIndicator />
@ -124,20 +185,35 @@ function Home() {
export default Home; export default Home;
const Modal = () => { const Modal = () => {
const { setShowModal } = useUIStore(); const { setShowModal, modalContent, showModal } = useUIStore();
const ModalContent = modalContent;
if (!showModal || !ModalContent) return null;
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="absolute h-screen w-screen flex items-center justify-center bg-ctp-crust/60 z-50" className="fixed inset-0 h-screen w-screen flex items-center justify-center bg-crust/70 backdrop-blur-sm z-50"
> >
<div <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()} onClick={(e) => e.stopPropagation()}
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5" className="relative w-full max-w-md mx-4 bg-base rounded-xl border-surface1 border p-8 shadow-2xl"
> >
<Login /> <button
</div> 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 />
{/*<Login />*/}
</motion.div>
</motion.div> </motion.div>
); );
}; };

View file

@ -6,32 +6,43 @@ import CheckIcon from "../../../assets/fontawesome/svg/circle-check.svg?react";
import SpinnerIcon from "../../../assets/fontawesome/svg/rotate.svg?react"; import SpinnerIcon from "../../../assets/fontawesome/svg/rotate.svg?react";
// @ts-ignore // @ts-ignore
import WarningIcon from "../../../assets/fontawesome/svg/circle-exclamation.svg?react"; import WarningIcon from "../../../assets/fontawesome/svg/circle-exclamation.svg?react";
import { Login } from "@/pages/Login";
export const StatusIndicator = () => { export const StatusIndicator = () => {
const { encryptionKey } = useAuthStore(); const { encryptionKey } = useAuthStore();
const { updating, setShowModal } = useUIStore(); const { updating, setShowModal, editorView, setEditorView, setModalContent } =
useUIStore();
return ( return (
<div <div
className="fixed bottom-2 right-3 bg-ctp-surface0 border border-ctp-surface2 rounded-sm px-2 py-0.5 flex items-center gap-2.5 shadow-lg backdrop-blur-sm" 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={() => { onClick={() => {
if (!encryptionKey) { if (!encryptionKey) {
setModalContent(Login);
setShowModal(true); setShowModal(true);
} }
}} }}
> >
<div
className="select-none"
onClick={() =>
setEditorView(editorView == "parsed" ? "unparsed" : "parsed")
}
>
{editorView}
</div>
{!encryptionKey ? ( {!encryptionKey ? (
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-ctp-yellow [&_.fa-secondary]:fill-ctp-orange" /> <WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-warn [&_.fa-secondary]:fill-orange" />
) : updating ? ( ) : updating ? (
<> <>
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-ctp-blue [&_.fa-secondary]:fill-ctp-sapphire" /> <SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-warn [&_.fa-secondary]:fill-sapphire" />
<span className="text-sm text-ctp-subtext0 font-medium"> {/*<span className="text-sm text-subtext font-medium">
Saving... Saving...
</span> </span>*/}
</> </>
) : ( ) : (
<> <>
<CheckIcon className="h-4 w-4 [&_.fa-primary]:fill-ctp-green [&_.fa-secondary]:fill-ctp-teal" /> <CheckIcon className="h-4 w-4 [&_.fa-primary]:fill-success [&_.fa-secondary]:fill-teal" />
<span className="text-sm text-ctp-subtext0 font-medium">Saved</span> {/*<span className="text-sm text-subtext font-medium">Saved</span>*/}
</> </>
)} )}
</div> </div>

View file

@ -2,6 +2,8 @@ import React, { useState, useRef, useEffect } from "react";
// @ts-ignore // @ts-ignore
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react"; import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
// @ts-ignore
import TagsIcon from "@/assets/fontawesome/svg/tags.svg?react";
import { DraggableNote } from "./subcomponents/DraggableNote"; import { DraggableNote } from "./subcomponents/DraggableNote";
import { import {
@ -17,8 +19,13 @@ import {
import { FolderTree } from "./subcomponents/FolderTree.tsx"; import { FolderTree } from "./subcomponents/FolderTree.tsx";
import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx"; import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
import { useAuthStore } from "@/stores/authStore.ts"; import { useAuthStore } from "@/stores/authStore.ts";
import { useNoteStore } from "@/stores/notesStore.ts";
import { useUIStore } from "@/stores/uiStore.ts"; import { useUIStore } from "@/stores/uiStore.ts";
import {
useCreateFolder,
useFolderTree,
useUpdateFolder,
useUpdateNote,
} from "@/hooks/useFolders.ts";
export const Sidebar = () => { export const Sidebar = () => {
const [newFolder, setNewFolder] = useState(false); const [newFolder, setNewFolder] = useState(false);
@ -29,17 +36,12 @@ export const Sidebar = () => {
} | null>(null); } | null>(null);
const newFolderRef = useRef<HTMLInputElement>(null); const newFolderRef = useRef<HTMLInputElement>(null);
const { const { data: folderTree, isLoading, error } = useFolderTree();
folderTree, const createFolder = useCreateFolder();
loadFolderTree,
moveNoteToFolder,
moveFolderToFolder,
createFolder,
} = useNoteStore();
const { encryptionKey } = useAuthStore(); const { encryptionKey } = useAuthStore();
const { setSideBarResize, sideBarResize } = useUIStore(); const { setSideBarResize, sideBarResize, setColourScheme } = useUIStore();
useEffect(() => { useEffect(() => {
if (newFolder && newFolderRef.current) { if (newFolder && newFolderRef.current) {
newFolderRef.current.focus(); newFolderRef.current.focus();
@ -48,17 +50,11 @@ export const Sidebar = () => {
useEffect(() => { useEffect(() => {
if (!encryptionKey) return; if (!encryptionKey) return;
loadFolderTree();
}, [encryptionKey]); }, [encryptionKey]);
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (!newFolderText.trim()) return; if (!newFolderText.trim()) return;
await createFolder({ createFolder.mutate({ name: newFolderText, parentId: null });
name: newFolderText,
parent_id: null,
});
setNewFolderText("");
setNewFolder(false);
}; };
const pointer = useSensor(PointerSensor, { const pointer = useSensor(PointerSensor, {
@ -77,6 +73,9 @@ export const Sidebar = () => {
} }
}; };
const updateNote = useUpdateNote();
const updateFolder = useUpdateFolder();
const handleDragEnd = async (event: DragEndEvent) => { const handleDragEnd = async (event: DragEndEvent) => {
setActiveItem(null); setActiveItem(null);
const { active, over } = event; const { active, over } = event;
@ -91,8 +90,11 @@ export const Sidebar = () => {
}); });
if (active.data.current?.type === "note") { if (active.data.current?.type === "note") {
console.log("Updating note", active.id, "to folder", over.id); console.log("Updating note ", active.id, "to folder", over.id);
await moveNoteToFolder(active.id as number, over.id as number); updateNote.mutate({
noteId: active.id as number,
note: { folderId: over.id as number },
});
} else if (active.data.current?.type === "folder") { } else if (active.data.current?.type === "folder") {
// Prevent dropping folder into itself // Prevent dropping folder into itself
if (active.data.current.folder.id === over.id) { if (active.data.current.folder.id === over.id) {
@ -107,10 +109,10 @@ export const Sidebar = () => {
over.id, over.id,
); );
try { try {
await moveFolderToFolder( updateFolder.mutate({
active.data.current.folder.id, folderId: active.data.current.folder.id,
over.id as number, folder: { parentId: over.id as number },
); });
} catch (error) { } catch (error) {
console.error("Failed to update folder:", error); console.error("Failed to update folder:", error);
return; return;
@ -159,18 +161,20 @@ export const Sidebar = () => {
autoScroll={false} autoScroll={false}
sensors={sensors} sensors={sensors}
> >
<div className="flex-row-reverse flex"> <div className="flex-row-reverse flex h-screen">
<div <div
className="h-screen bg-ctp-surface0 w-0.5 hover:cursor-ew-resize hover:bg-ctp-mauve transition-colors" className="h-full bg-surface1 w-0.5 hover:cursor-ew-resize hover:bg-accent/50 transition-colors"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
></div> ></div>
<div <div
className="flex flex-col min-h-full" className="flex flex-col h-full"
style={{ width: `${sideBarResize}px` }} style={{ width: `${sideBarResize}px` }}
> >
<SidebarHeader setNewFolder={setNewFolder} /> <SidebarHeader setNewFolder={setNewFolder} />
<div className="flex-1 overflow-y-auto bg-surface1 border-r border-surface1">
<>
<div <div
className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3" className="w-full p-4 sm:block hidden"
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()} onTouchMove={(e) => e.preventDefault()}
> >
@ -197,7 +201,23 @@ export const Sidebar = () => {
</div> </div>
)} )}
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-8 text-subtext0">
<div className="text-sm">Loading folders...</div>
</div>
)}
{/* Error state */}
{error && (
<div className="flex items-center justify-center py-8 text-danger">
<div className="text-sm">Failed to load folders</div>
</div>
)}
{/* Folder tree */} {/* Folder tree */}
{!isLoading && !error && (
<>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{folderTree?.folders.map((folder) => ( {folderTree?.folders.map((folder) => (
<FolderTree key={folder.id} folder={folder} depth={0} /> <FolderTree key={folder.id} folder={folder} depth={0} />
@ -205,29 +225,36 @@ export const Sidebar = () => {
</div> </div>
{/* Orphaned notes */} {/* Orphaned notes */}
{folderTree?.orphaned_notes && {folderTree?.orphanedNotes &&
folderTree.orphaned_notes.length > 0 && ( folderTree.orphanedNotes.length > 0 && (
<div className="mt-4 flex flex-col gap-1"> <div className="mt-4 flex flex-col gap-1">
{folderTree.orphaned_notes.map((note) => ( {folderTree.orphanedNotes.map((note) => (
<DraggableNote key={note.id} note={note} /> <DraggableNote key={note.id} note={note} />
))} ))}
</div> </div>
)} )}
</>
)}
</div> </div>
{/*<div className="fixed bottom-1 left-2">
<button onClick={setColour}>purple</button>
</div>*/}
<DragOverlay> <DragOverlay>
{activeItem?.type === "note" && ( {activeItem?.type === "note" && (
<div className="bg-ctp-surface0 rounded-md px-2 py-1 shadow-lg border border-ctp-mauve"> <div className="bg-surface0 rounded-md px-2 py-1 shadow-lg border border-accent">
{activeItem.data.title} {activeItem.data.title}
</div> </div>
)} )}
{activeItem?.type === "folder" && ( {activeItem?.type === "folder" && (
<div className="bg-ctp-surface0 rounded-md px-1 py-0.5 shadow-lg flex items-center gap-1 text-sm"> <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-ctp-mauve mr-1" /> <FolderIcon className="w-3 h-3 fill-accent mr-1" />
{activeItem.data.name} {activeItem.data.name}
</div> </div>
)} )}
</DragOverlay> </DragOverlay>
</>
</div>
</div> </div>
</div> </div>
</DndContext> </DndContext>

View file

@ -1,10 +1,10 @@
import { useDraggable } from "@dnd-kit/core"; import { useDraggable } from "@dnd-kit/core";
import { useContextMenu } from "@/contexts/ContextMenuContext"; import { useContextMenu } from "@/contexts/ContextMenuContext";
import { useNoteStore } from "@/stores/notesStore"; import { useUIStore } from "@/stores/uiStore";
import { NoteRead } from "@/api/folders"; import { NoteRead } from "@/api/notes";
export const DraggableNote = ({ note }: { note: NoteRead }) => { export const DraggableNote = ({ note }: { note: NoteRead }) => {
const { selectedNote, setSelectedNote } = useNoteStore(); const { selectedNote, setSelectedNote } = useUIStore();
const { openContextMenu } = useContextMenu(); const { openContextMenu } = useContextMenu();
const { attributes, listeners, setNodeRef, transform, isDragging } = const { attributes, listeners, setNodeRef, transform, isDragging } =
@ -39,8 +39,8 @@ export const DraggableNote = ({ note }: { note: NoteRead }) => {
}} }}
className={` rounded-sm px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${ className={` rounded-sm px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${
selectedNote?.id === note.id selectedNote?.id === note.id
? "bg-ctp-mauve text-ctp-base" ? "bg-accent text-base"
: "hover:bg-ctp-surface1" : "hover:bg-surface1"
}`} }`}
> >
<span className="truncate"> <span className="truncate">

View file

@ -4,7 +4,7 @@ import { useDroppable, useDraggable } from "@dnd-kit/core";
import CaretRightIcon from "@/assets/fontawesome/svg/caret-right.svg?react"; import CaretRightIcon from "@/assets/fontawesome/svg/caret-right.svg?react";
// @ts-ignore // @ts-ignore
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react"; import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
import { Folder } from "@/api/folders"; import { FolderTreeNode } from "@/api/folders";
import { useContextMenu } from "@/contexts/ContextMenuContext"; import { useContextMenu } from "@/contexts/ContextMenuContext";
export const DroppableFolder = ({ export const DroppableFolder = ({
@ -12,7 +12,7 @@ export const DroppableFolder = ({
setCollapse, setCollapse,
collapse, collapse,
}: { }: {
folder: Partial<Folder>; folder: Partial<FolderTreeNode>;
setCollapse: React.Dispatch<React.SetStateAction<boolean>>; setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
collapse: boolean; collapse: boolean;
}) => { }) => {
@ -63,10 +63,12 @@ export const DroppableFolder = ({
{...listeners} {...listeners}
{...attributes} {...attributes}
> >
{(folder.notes?.length ?? 0) > 0 && (
<CaretRightIcon <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-ctp-mauve`} 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`}
/> />
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-ctp-mauve mr-1" /> )}
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-accent mr-1" />
<span className="truncate">{folder.name}</span> <span className="truncate">{folder.name}</span>
</div> </div>
</div> </div>

View file

@ -29,7 +29,7 @@ export const FolderTree = ({ folder, depth = 0 }: FolderTreeProps) => {
className="overflow-hidden flex flex-col" className="overflow-hidden flex flex-col"
> >
{/* The line container */} {/* The line container */}
<div className="ml-2 pl-3 border-l border-ctp-surface2"> <div className="ml-2 pl-3 border-l border-surface1">
{/* Notes */} {/* Notes */}
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{folder.notes.map((note) => ( {folder.notes.map((note) => (

View file

@ -2,38 +2,87 @@ import { SetStateAction } from "react";
// @ts-ignore // @ts-ignore
import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react"; import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
// @ts-ignore // @ts-ignore
import TagsIcon from "@assets/fontawesome/svg/tags.svg?react";
// @ts-ignore
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react"; import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
import { useNoteStore } from "@/stores/notesStore"; // @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";
import { Login } from "@/pages/Login";
import { ColourState } from "@/stores/uiStore";
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 = ({ export const SidebarHeader = ({
setNewFolder, setNewFolder,
}: { }: {
setNewFolder: React.Dispatch<SetStateAction<boolean>>; setNewFolder: React.Dispatch<SetStateAction<boolean>>;
}) => { }) => {
const { createNote, selectedFolder } = useNoteStore(); const { selectedFolder, setShowModal, setModalContent } = useUIStore();
const createNote = useCreateNote();
const handleCreate = async () => { const handleCreate = async () => {
await createNote({ createNote.mutate({
title: "Untitled", title: "Untitled",
content: "", content: "",
folder_id: selectedFolder, folder_id: selectedFolder,
}); } as NoteCreate);
};
const handleSettings = () => {
setModalContent(Test);
setShowModal(true);
}; };
return ( return (
<div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1"> <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 <button
onClick={() => setNewFolder(true)} onClick={() => setNewFolder(true)}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2" className="hover:bg-accent active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
title="New folder" title="New folder"
> >
<FolderPlusIcon className="w-4 h-4 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" /> <FolderPlusIcon className="w-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent" />
</button> </button>
<button <button
onClick={handleCreate} onClick={handleCreate}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2 fill-ctp-mauve hover:fill-ctp-base" className="hover:bg-accent active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
title="New note" title="New note"
> >
<FileCirclePlusIcon className="w-4 h-4 text-ctp-mauve group-hover:text-ctp-base transition-colors" /> <FileCirclePlusIcon className="w-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent" />
</button> </button>
<button
onClick={handleSettings}
className="hover:bg-accent 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" />
</button>
</div>
</div> </div>
); );
}; };

View file

@ -26,31 +26,62 @@ export const Login = () => {
}; };
return ( return (
<form onSubmit={handleSubmit} className="gap-2 flex flex-col"> <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 <input
type="text" type="text"
placeholder="Username" placeholder="Enter your username"
className="standard-input" className="standard-input"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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 <input
type="password" type="password"
className="standard-input" className="standard-input"
placeholder="Password" placeholder="Enter your password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
{error && <div>{error}</div>} </div>
<button type="submit">Login</button>
<div className="flex gap-2"> {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 <input
type="check box" type="checkbox"
id="remember"
checked={remember} checked={remember}
onChange={(e) => setRemember(e.target.checked)} onChange={(e) => setRemember(e.target.checked)}
className="accent-accent cursor-pointer"
/> />
<div>Remember me?</div> <label
htmlFor="remember"
className="text-sm text-subtext cursor-pointer"
>
Remember me
</label>
</div> </div>
<button
type="submit"
className="bg-accent hover:bg-accent/90 text-base font-semibold px-4 py-2.5 rounded-sm transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-base"
>
Login
</button>
</form> </form>
); );
}; };

View file

@ -23,29 +23,57 @@ export const Register = () => {
}; };
return ( return (
<form onSubmit={handleSubmit}> <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 <input
type="text" type="text"
placeholder="Username" placeholder="Choose a username"
className="standard-input"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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 <input
type="email" type="email"
placeholder="Email" placeholder="Enter your email"
className="standard-input"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} 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 <input
type="password" type="password"
placeholder="Password" className="standard-input"
placeholder="Create a password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
{error && <div>{error}</div>} </div>
<button type="submit">Login</button>
{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 hover:bg-accent/90 text-base font-semibold px-4 py-2.5 rounded-sm transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-base"
>
Register
</button>
</form> </form>
); );
}; };
// Similar pattern for Register.tsx

View file

@ -1,6 +1,6 @@
export const Test = () => { export const Test = () => {
return ( return (
<div className="h-screen w-screen flex items-center justify-center bg-ctp-base p-4"> <div className="h-screen w-screen flex items-center justify-center bg-base p-4">
<input <input
type="text" type="text"
placeholder="Folder name..." placeholder="Folder name..."

View file

@ -79,7 +79,12 @@ export const TiptapEditor = ({
} }
return ( return (
<div className="tiptap-editor h-full"> <div
className="tiptap-editor pt-0! overflow-y-scroll"
style={{
minHeight: "calc(100vh - 55px)",
}}
>
{/* Toolbar */} {/* Toolbar */}
{/*<div className="editor-toolbar"> {/*<div className="editor-toolbar">
<div className="toolbar-group"> <div className="toolbar-group">
@ -88,28 +93,28 @@ export const TiptapEditor = ({
className={editor.isActive("bold") ? "active" : ""} className={editor.isActive("bold") ? "active" : ""}
title="Bold (Ctrl+B)" title="Bold (Ctrl+B)"
> >
<BoldIcon className="w-4 h-4 fill-ctp-text" /> <BoldIcon className="w-4 h-4 fill-text" />
</button> </button>
<button <button
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "active" : ""} className={editor.isActive("italic") ? "active" : ""}
title="Italic (Ctrl+I)" title="Italic (Ctrl+I)"
> >
<ItalicIcon className="w-4 h-4 fill-ctp-text" /> <ItalicIcon className="w-4 h-4 fill-text" />
</button> </button>
<button <button
onClick={() => editor.chain().focus().toggleStrike().run()} onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive("strike") ? "active" : ""} className={editor.isActive("strike") ? "active" : ""}
title="Strikethrough" title="Strikethrough"
> >
<StrikethroughIcon className="w-4 h-4 fill-ctp-text" /> <StrikethroughIcon className="w-4 h-4 fill-text" />
</button> </button>
<button <button
onClick={() => editor.chain().focus().toggleCode().run()} onClick={() => editor.chain().focus().toggleCode().run()}
className={editor.isActive("code") ? "active" : ""} className={editor.isActive("code") ? "active" : ""}
title="Inline code" title="Inline code"
> >
<CodeIcon className="w-4 h-4 fill-ctp-text" /> <CodeIcon className="w-4 h-4 fill-text" />
</button> </button>
</div> </div>
@ -153,35 +158,35 @@ export const TiptapEditor = ({
className={editor.isActive("bulletList") ? "active" : ""} className={editor.isActive("bulletList") ? "active" : ""}
title="Bullet list" title="Bullet list"
> >
<ListUlIcon className="w-4 h-4 fill-ctp-text" /> <ListUlIcon className="w-4 h-4 fill-text" />
</button> </button>
<button <button
onClick={() => editor.chain().focus().toggleOrderedList().run()} onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "active" : ""} className={editor.isActive("orderedList") ? "active" : ""}
title="Numbered list" title="Numbered list"
> >
<ListOlIcon className="w-4 h-4 fill-ctp-text" /> <ListOlIcon className="w-4 h-4 fill-text" />
</button> </button>
<button <button
onClick={() => editor.chain().focus().toggleTaskList().run()} onClick={() => editor.chain().focus().toggleTaskList().run()}
className={editor.isActive("taskList") ? "active" : ""} className={editor.isActive("taskList") ? "active" : ""}
title="Task list" title="Task list"
> >
<SquareCheckIcon className="w-4 h-4 fill-ctp-text" /> <SquareCheckIcon className="w-4 h-4 fill-text" />
</button> </button>
<button <button
onClick={() => editor.chain().focus().toggleCodeBlock().run()} onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editor.isActive("codeBlock") ? "active" : ""} className={editor.isActive("codeBlock") ? "active" : ""}
title="Code block" title="Code block"
> >
<CodeBracketIcon className="w-4 h-4 fill-ctp-text" /> <CodeBracketIcon className="w-4 h-4 fill-text" />
</button> </button>
<button <button
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "active" : ""} className={editor.isActive("blockquote") ? "active" : ""}
title="Quote" title="Quote"
> >
<QuoteLeftIcon className="w-4 h-4 fill-ctp-text" /> <QuoteLeftIcon className="w-4 h-4 fill-text" />
</button> </button>
</div> </div>
@ -191,7 +196,7 @@ export const TiptapEditor = ({
{/* Editor content */} {/* Editor content */}
<EditorContent <EditorContent
editor={editor} editor={editor}
className="editor-content h-min-screen p-4!" className="editor-content h-min-screen p-4! pt-0!"
/> />
</div> </div>
); );

View file

@ -7,33 +7,37 @@
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
@apply bg-ctp-mantle rounded-full; @apply bg-surface0 rounded-full;
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
@apply bg-ctp-surface2 rounded-full; @apply bg-surface1 rounded-full;
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover {
@apply bg-ctp-overlay0; @apply bg-overlay0;
} }
/* Firefox scrollbar */ /* Firefox scrollbar */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--color-ctp-surface2) var(--color-ctp-mantle); scrollbar-color: var(--color-surface1) var(--color-surface0);
} }
.tiptap-editor { .tiptap-editor {
@apply flex flex-col h-full bg-ctp-base; @apply flex flex-col h-full bg-base;
} }
.ProseMirror { .ProseMirror {
@apply text-ctp-text; @apply text-text;
} }
.editor-toolbar { .editor-toolbar {
@apply flex gap-2 px-4 bg-ctp-mantle border-b border-ctp-surface2 flex-wrap items-center; @apply flex gap-2 px-4 bg-surface0 border-b border-surface1 flex-wrap items-center;
}
.editor-content {
@apply h-full;
} }
.toolbar-group { .toolbar-group {
@ -41,19 +45,19 @@
} }
.toolbar-divider { .toolbar-divider {
@apply w-px h-6 bg-ctp-surface2; @apply w-px h-6 bg-surface1;
} }
.editor-toolbar button { .editor-toolbar button {
@apply p-2 bg-transparent border-none rounded-sm text-ctp-text cursor-pointer transition-all duration-150 text-sm font-semibold min-w-8 flex items-center justify-center; @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) { .editor-toolbar button:hover:not(:disabled) {
@apply bg-ctp-surface0; @apply bg-surface0;
} }
.editor-toolbar button.active { .editor-toolbar button.active {
@apply bg-ctp-mauve text-ctp-base; @apply bg-accent text-base;
} }
.editor-toolbar button:disabled { .editor-toolbar button:disabled {
@ -66,59 +70,59 @@
.ProseMirror p.is-editor-empty:first-child::before { .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder); content: attr(data-placeholder);
@apply float-left text-ctp-overlay0 pointer-events-none h-0; @apply float-left text-overlay0 pointer-events-none h-0;
} }
.ProseMirror ul { .ProseMirror ul {
@apply mb-0!; @apply mb-0!;
} }
.ProseMirror h1 { .ProseMirror h1 {
@apply text-3xl font-bold text-ctp-mauve mt-8 mb-4; @apply text-3xl font-bold mt-6 mb-4 text-accent;
} }
.ProseMirror h2 { .ProseMirror h2 {
@apply text-2xl font-semibold text-ctp-blue mt-6 mb-3; @apply text-2xl font-semibold text-accent mt-4 mb-3;
} }
.ProseMirror h3 { .ProseMirror h3 {
@apply text-xl font-semibold text-ctp-teal mt-5 mb-2; @apply text-xl font-semibold text-accent mt-5 mb-2;
} }
.ProseMirror code { .ProseMirror code {
@apply bg-ctp-surface0 text-ctp-peach px-1.5 py-0.5 rounded text-sm; @apply bg-surface0 text-accent px-1.5 py-0.5 rounded text-sm;
font-family: "JetBrains Mono", "Fira Code", monospace; font-family: "JetBrains Mono", "Fira Code", monospace;
} }
.ProseMirror .code-block { .ProseMirror .code-block {
@apply bg-ctp-surface0 border border-ctp-surface2 rounded-sm p-4 my-4 overflow-x-auto; @apply bg-surface0 border border-surface1 rounded-sm p-4 my-4 overflow-x-auto;
font-family: "JetBrains Mono", "Fira Code", monospace; font-family: "JetBrains Mono", "Fira Code", monospace;
} }
.ProseMirror .code-block code { .ProseMirror .code-block code {
@apply bg-transparent p-0 text-ctp-text; @apply bg-transparent p-0 text-text;
} }
.ProseMirror blockquote { .ProseMirror blockquote {
@apply border-l-4 border-ctp-mauve pl-4 ml-0 text-ctp-subtext0 italic; @apply border-l-4 border-accent pl-4 ml-0 text-subtext italic;
} }
.ProseMirror hr { .ProseMirror hr {
@apply border-none border-t-2 border-ctp-surface2 my-8; @apply border-none border-t-2 border-surface1 my-8;
} }
.ProseMirror a { .ProseMirror a {
@apply text-ctp-blue underline; @apply text-accent underline;
} }
.ProseMirror a:hover { .ProseMirror a:hover {
@apply text-ctp-sapphire; @apply text-accent;
} }
.ProseMirror strong { .ProseMirror strong {
@apply text-ctp-peach font-semibold; @apply text-accent font-semibold;
} }
.ProseMirror em { .ProseMirror em {
@apply text-ctp-yellow; @apply text-accent;
} }
/* Task List (Checkboxes) */ /* Task List (Checkboxes) */
@ -135,7 +139,7 @@
} }
.ProseMirror ul[data-type="taskList"] > li > label input[type="checkbox"] { .ProseMirror ul[data-type="taskList"] > li > label input[type="checkbox"] {
@apply cursor-pointer m-0 accent-ctp-mauve; @apply cursor-pointer m-0 accent-accent;
} }
.ProseMirror ul[data-type="taskList"] > li > div { .ProseMirror ul[data-type="taskList"] > li > div {
@ -147,17 +151,18 @@
} }
.ProseMirror li[data-checked="true"] > div > p { .ProseMirror li[data-checked="true"] > div > p {
@apply line-through text-ctp-overlay0; @apply line-through text-text/40;
text-decoration-style: wavy; text-decoration-style: wavy;
text-decoration-thickness: 1px;
} }
.ProseMirror u { .ProseMirror u {
@apply decoration-ctp-mauve; @apply decoration-accent;
text-decoration-style: wavy; /*text-decoration-style: wavy;*/
} }
.ProseMirror li::marker { .ProseMirror li::marker {
@apply text-ctp-mauve; @apply text-accent;
} }
/* tiptap.css */ /* tiptap.css */
@ -171,3 +176,19 @@
margin-top: 0 !important; margin-top: 0 !important;
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
hr {
border: none;
height: 3px;
background: repeating-linear-gradient(
90deg,
var(--color-accent) 0px,
var(--color-accent) 8px,
var(--color-accent) 16px
);
-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;
}

View file

@ -1,27 +1,25 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { useNoteStore } from "./notesStore";
import { import {
deriveKey, deriveKey,
generateMasterKey, generateMasterKey,
unwrapMasterKey, unwrapMasterKey,
wrapMasterKey, wrapMasterKey,
} from "../api/encryption"; } from "../api/encryption";
import { FolderTree } from "@/pages/Home/components/sidebar/subcomponents/FolderTree";
interface User { interface User {
id: number; id: number;
username: string; username: string;
email: string; email: string;
salt: string; // For key derivation salt: string;
} }
interface AuthState { interface AuthState {
user: User | null; user: User | null;
encryptionKey: CryptoKey | null; // Memory only! encryptionKey: CryptoKey | null;
isAuthenticated: boolean; isAuthenticated: boolean;
rememberMe: boolean; rememberMe: boolean;
setRememberMe: (boolean) => void; setRememberMe: (remember: boolean) => void;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
register: ( register: (
@ -35,7 +33,9 @@ interface AuthState {
clearAll: () => void; clearAll: () => void;
} }
const API_URL = "http://localhost:8000/api"; const API_URL = import.meta.env.PROD
? "/api" // ← Same domain, different path
: "http://localhost:8000/api";
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
@ -48,7 +48,6 @@ export const useAuthStore = create<AuthState>()(
set({ rememberMe: bool }); set({ rememberMe: bool });
}, },
initEncryptionKey: async (password: string, salt: string) => { initEncryptionKey: async (password: string, salt: string) => {
// Use user-specific salt instead of hardcoded
const key = await deriveKey(password, salt); const key = await deriveKey(password, salt);
set({ encryptionKey: key }); set({ encryptionKey: key });
}, },
@ -79,7 +78,6 @@ export const useAuthStore = create<AuthState>()(
const data = await response.json(); const data = await response.json();
// Store the master key directly (not derived from password)
set({ set({
user: data.user, user: data.user,
isAuthenticated: true, isAuthenticated: true,
@ -102,11 +100,9 @@ export const useAuthStore = create<AuthState>()(
const { user } = await response.json(); const { user } = await response.json();
// Derive KEK and unwrap master key
const kek = await deriveKey(password, user.salt); const kek = await deriveKey(password, user.salt);
const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek); const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek);
// Store master key in memory
set({ encryptionKey: masterKey, user, isAuthenticated: true }); set({ encryptionKey: masterKey, user, isAuthenticated: true });
}, },
@ -151,11 +147,6 @@ export const useAuthStore = create<AuthState>()(
}); });
localStorage.clear(); localStorage.clear();
useNoteStore.setState({
folderTree: null,
selectedFolder: null,
selectedNote: null,
});
}, },
}), }),
{ {

View file

@ -1,352 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import {
folderApi,
FolderCreate,
FolderTreeNode,
FolderTreeResponse,
FolderUpdate,
NoteRead,
} from "../api/folders";
import { Note, NoteCreate, notesApi } from "../api/notes";
const updateNoteInTree = (
tree: FolderTreeResponse | null,
updatedNote: NoteRead,
): FolderTreeResponse | null => {
if (!tree) return null;
const updateNotesInFolder = (folder: FolderTreeNode): FolderTreeNode => ({
...folder,
notes: folder.notes.map((note) =>
note.id === updatedNote.id ? updatedNote : note,
),
children: folder.children.map(updateNotesInFolder),
});
return {
folders: tree.folders.map(updateNotesInFolder),
orphaned_notes: tree.orphaned_notes.map((note) =>
note.id === updatedNote.id ? updatedNote : note,
),
};
};
const updateFolder = (
id: number,
folder: FolderTreeNode,
newFolder: FolderUpdate,
): FolderTreeNode => {
if (folder.id === id) {
return { ...folder, ...newFolder };
}
if (folder.children) {
return {
...folder,
children: folder.children.map((child) =>
updateFolder(id, child, newFolder),
),
};
}
return folder;
};
interface NoteState {
loadFolderTree: () => Promise<void>;
folderTree: FolderTreeResponse | null;
selectedFolder: number | null;
setSelectedFolder: (id: number | null) => void;
selectedNote: NoteRead | null;
setSelectedNote: (id: NoteRead | null) => void;
setContent: (content: string) => void;
setTitle: (title: string) => void;
createNote: (note: NoteCreate) => Promise<void>;
updateNote: (id: number) => Promise<void>;
createFolder: (folder: FolderCreate) => Promise<void>;
updateFolder: (id: number, newFolder: FolderUpdate) => Promise<void>;
moveNoteToFolder: (noteId: number, folderId: number) => Promise<void>;
moveFolderToFolder: (folderId: number, newParentId: number) => Promise<void>;
}
export const useNoteStore = create<NoteState>()(
persist(
(set, get) => ({
loadFolderTree: async () => {
const data = await folderApi.tree();
console.log("getting tree");
set({ folderTree: data });
},
folderTree: null,
selectedFolder: null,
setSelectedFolder: (id: number | null) => {
set({ selectedFolder: id });
},
selectedNote: null,
setSelectedNote: (id: NoteRead | null) => {
set({ selectedNote: id });
},
setContent: (content) => {
const currentNote = get().selectedNote;
if (currentNote) {
const updatedNote = { ...currentNote, content: content };
set({
selectedNote: updatedNote,
folderTree: updateNoteInTree(get().folderTree, updatedNote),
});
}
},
setTitle: (title) => {
const currentNote = get().selectedNote;
if (currentNote) {
const updatedNote = { ...currentNote, title: title };
set({
selectedNote: updatedNote,
folderTree: updateNoteInTree(get().folderTree, updatedNote),
});
}
},
createNote: async (note: Partial<NoteRead>) => {
const response = await notesApi.create(note as NoteCreate);
const newNote = response.data as NoteRead;
console.log(newNote.id);
const noteToAppend: NoteRead = {
...newNote,
title: note.title || "Untitled",
content: note.content || "",
};
const tree = get().folderTree;
if (!tree) return;
if (note.folder_id) {
const addNoteToFolder = (folder: FolderTreeNode): FolderTreeNode => {
if (folder.id === note.folder_id) {
return {
...folder,
notes: [...folder.notes, noteToAppend],
children: folder.children.map(addNoteToFolder),
};
}
return {
...folder,
children: folder.children.map(addNoteToFolder),
};
};
set({
folderTree: {
folders: tree.folders.map(addNoteToFolder),
orphaned_notes: tree.orphaned_notes,
},
});
} else {
// Add to orphaned notes
set({
folderTree: {
folders: tree.folders,
orphaned_notes: [...tree.orphaned_notes, noteToAppend],
},
});
}
},
updateNote: async (id: number) => {
const note = get().selectedNote as Partial<Note>;
await notesApi.update(id, note);
},
createFolder: async (folder: FolderCreate) => {
const response = await folderApi.create(folder);
const newFolder = response.data;
const tree = get().folderTree;
if (!tree) return;
const newFolderNode: FolderTreeNode = {
id: newFolder.id,
name: newFolder.name,
notes: [],
children: [],
};
if (folder.parent_id) {
// Add as child of parent folder
const addToParent = (f: FolderTreeNode): FolderTreeNode => {
if (f.id === folder.parent_id) {
return {
...f,
children: [...f.children, newFolderNode],
};
}
return {
...f,
children: f.children.map(addToParent),
};
};
set({
folderTree: {
folders: tree.folders.map(addToParent),
orphaned_notes: tree.orphaned_notes,
},
});
} else {
// Add as top-level folder
set({
folderTree: {
folders: [...tree.folders, newFolderNode],
orphaned_notes: tree.orphaned_notes,
},
});
}
},
updateFolder: async (id: number, newFolder: FolderUpdate) => {
const tree = get().folderTree as FolderTreeResponse;
const newFolders = tree.folders.map((folder) =>
updateFolder(id, folder, newFolder),
);
set({
folderTree: {
folders: newFolders,
orphaned_notes: tree.orphaned_notes,
},
});
await folderApi.update(id, newFolder);
},
moveNoteToFolder: async (noteId: number, folderId: number) => {
const tree = get().folderTree;
if (!tree) return;
// Find and remove the note from its current location
let noteToMove: NoteRead | null = null;
// Check orphaned notes
const orphanedIndex = tree.orphaned_notes.findIndex(
(n) => n.id === noteId,
);
if (orphanedIndex !== -1) {
noteToMove = tree.orphaned_notes[orphanedIndex];
}
// Check folders recursively
const findAndRemoveNote = (folder: FolderTreeNode): FolderTreeNode => {
const noteIndex = folder.notes.findIndex((n) => n.id === noteId);
if (noteIndex !== -1) {
noteToMove = folder.notes[noteIndex];
return {
...folder,
notes: folder.notes.filter((n) => n.id !== noteId),
children: folder.children.map(findAndRemoveNote),
};
}
return {
...folder,
children: folder.children.map(findAndRemoveNote),
};
};
// Add note to target folder
const addNoteToFolder = (folder: FolderTreeNode): FolderTreeNode => {
if (folder.id === folderId && noteToMove) {
return {
...folder,
notes: [...folder.notes, { ...noteToMove, folder_id: folderId }],
children: folder.children.map(addNoteToFolder),
};
}
return {
...folder,
children: folder.children.map(addNoteToFolder),
};
};
// Update local tree
let newFolders = tree.folders.map(findAndRemoveNote);
let newOrphaned = tree.orphaned_notes.filter((n) => n.id !== noteId);
newFolders = newFolders.map(addNoteToFolder);
set({
folderTree: {
folders: newFolders,
orphaned_notes: newOrphaned,
},
});
// Update backend
await notesApi.update(noteId, { folder_id: folderId });
},
moveFolderToFolder: async (folderId: number, newParentId: number) => {
const tree = get().folderTree;
if (!tree) return;
let folderToMove: FolderTreeNode | null = null;
const findAndRemoveFolder = (
folders: FolderTreeNode[],
): FolderTreeNode[] => {
return folders
.filter((f) => {
if (f.id === folderId) {
folderToMove = f;
return false;
}
return true;
})
.map((f) => ({
...f,
children: findAndRemoveFolder(f.children),
}));
};
const addFolderToParent = (
folders: FolderTreeNode[],
): FolderTreeNode[] => {
return folders.map((f) => {
if (f.id === newParentId && folderToMove) {
return {
...f,
children: [...f.children, folderToMove],
};
}
return {
...f,
children: addFolderToParent(f.children),
};
});
};
let newFolders = findAndRemoveFolder(tree.folders);
newFolders = addFolderToParent(newFolders);
set({
folderTree: {
folders: newFolders,
orphaned_notes: tree.orphaned_notes,
},
});
await folderApi.update(folderId, { parent_id: newParentId });
},
}),
{
name: "notes-storage",
partialize: (state) => ({
folderTree: state.folderTree,
}),
},
),
);

View file

@ -1,5 +1,27 @@
import { Note, NoteRead } from "@/api/notes";
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { Login } from "@/pages/Login";
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 UIState { interface UIState {
updating: boolean; updating: boolean;
@ -8,8 +30,26 @@ interface UIState {
showModal: boolean; showModal: boolean;
setShowModal: (show: boolean) => void; setShowModal: (show: boolean) => void;
modalContent: React.ComponentType | null;
setModalContent: (content: React.ComponentType) => void;
sideBarResize: number; sideBarResize: number;
setSideBarResize: (size: number) => void; 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;
setColourScheme: (colors: ColourState) => void;
} }
export const useUIStore = create<UIState>()( export const useUIStore = create<UIState>()(
@ -19,15 +59,60 @@ export const useUIStore = create<UIState>()(
setUpdating: (update) => { setUpdating: (update) => {
set({ updating: update }); set({ updating: update });
}, },
showModal: false, showModal: true,
setShowModal: (show) => { setShowModal: (show) => {
set({ showModal: show }); set({ showModal: show });
}, },
modalContent: null,
setModalContent: (content) => {
set({ modalContent: content });
},
sideBarResize: 300, sideBarResize: 300,
setSideBarResize: (size) => { setSideBarResize: (size) => {
set({ sideBarResize: 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",
},
setColourScheme: (colors: ColourState) => {
set({ colourScheme: colors });
Object.entries(colors).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--color-${key}`, value);
});
},
}), }),
{ {
name: "ui-store", name: "ui-store",
partialize: (state) => { partialize: (state) => {

View file

@ -0,0 +1,11 @@
// src/test/setup.ts
import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import * as matchers from "@testing-library/jest-dom/matchers";
expect.extend(matchers);
// Cleanup after each test
afterEach(() => {
cleanup();
});

1192
frontend/src/types/api.d.ts vendored Normal file

File diff suppressed because it is too large Load diff

14
frontend/src/vite-env.d.ts vendored Normal file
View 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;
}

View file

@ -2,10 +2,15 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import svgr from "vite-plugin-svgr"; import svgr from "vite-plugin-svgr";
import path from "path"; import * as path from "path";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), react(), svgr()], plugins: [tailwindcss(), react(), svgr()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
},
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),