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
0a23332a09 Refactor imports and add auth clearAll
- Remove unused SetStateAction import in SideBar - Add clearAll to
authStore to wipe state, localStorage, and reset notes - Expose
useAuthStore on window for debugging - Update notesStore.updateFolder to
return FolderTreeNode and fix recursion by using the correct child
variable
2025-12-13 13:04:13 +00:00
80 changed files with 12721 additions and 10752 deletions

26
.gitignore vendored
View file

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

View file

@ -1,10 +1,23 @@
import os
from dotenv import load_dotenv
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(
DATABASE_URL, echo=True, connect_args={"check_same_thread": False}
)
# If DATABASE_URL is not set or empty, use default SQLite
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():

View file

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

View file

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

Binary file not shown.

View file

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

View file

@ -1,8 +1,5 @@
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.database import get_session
from app.models import (
@ -15,6 +12,9 @@ from app.models import (
NoteRead,
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"])
@ -35,21 +35,20 @@ def get_folder_tree(
):
"""Get complete folder tree with notes"""
# Get all top-level folders (parent_id is None) for current user
top_level_folders = session.exec(
select(Folder)
.options(selectinload(Folder.notes).selectinload(Note.tags))
.where(Folder.parent_id == None)
.where(Folder.user_id == current_user.id)
).all()
# Get all orphaned notes (folder_id is None) for current user
orphaned_notes = session.exec(
select(Note)
.options(selectinload(Note.tags))
.where(Note.folder_id == None)
.where(Note.user_id == current_user.id)
).all()
# Build tree recursively
tree = [build_folder_tree_node(folder) for folder in top_level_folders]
return FolderTreeResponse(

View file

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

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

19602
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,49 +1,57 @@
{
"name": "note-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@mdxeditor/editor": "^3.49.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@tiptap/extension-placeholder": "^3.12.1",
"@tiptap/react": "^3.12.1",
"@tiptap/starter-kit": "^3.12.1",
"axios": "^1.13.2",
"framer-motion": "^12.23.25",
"jszip": "^3.10.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.6",
"tailwindcss": "^4.1.17",
"tiptap-markdown": "^0.9.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@catppuccin/tailwindcss": "^1.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/ui": "^4.0.15",
"jsdom": "^27.3.0",
"vite": "^5.4.21",
"vite-plugin-svgr": "^4.5.0",
"vitest": "^4.0.15"
},
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
"name": "note-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"generate-types": "openapi-typescript http://localhost:8000/openapi.json -o src/types/api.d.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@mdxeditor/editor": "^3.49.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1",
"@tiptap/extension-placeholder": "^3.12.1",
"@tiptap/react": "^3.12.1",
"@tiptap/starter-kit": "^3.12.1",
"axios": "^1.13.2",
"framer-motion": "^12.23.25",
"humps": "^2.0.1",
"jszip": "^3.10.1",
"openapi-fetch": "^0.15.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.6",
"tailwindcss": "^4.1.17",
"tiptap-markdown": "^0.9.0",
"uuid": "^13.0.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@catppuccin/tailwindcss": "^1.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/humps": "^2.0.6",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/ui": "^4.0.15",
"jsdom": "^27.3.0",
"openapi-typescript": "^7.10.1",
"type-fest": "^5.3.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-svgr": "^4.5.0",
"vitest": "^4.0.15"
}
}

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

@ -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) {
const enc = new TextEncoder();
@ -114,6 +133,12 @@ export async function decryptFolderTree(
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})),
),
children: await Promise.all(
@ -126,12 +151,40 @@ export async function decryptFolderTree(
folders: await Promise.all(
tree.folders.map((folder) => decryptFolder(folder)),
),
orphaned_notes: await Promise.all(
tree.orphaned_notes.map(async (note) => ({
orphanedNotes: await Promise.all(
tree.orphanedNotes.map(async (note) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})),
),
};
}
export const decryptTagTree = async (
tags: TagTreeNode[],
key: CryptoKey,
parentPath = "",
): Promise<DecryptedTagNode[]> => {
return Promise.all(
tags.map(async (tag) => {
const decryptedName = await decryptString(tag.name, key);
const currentPath = parentPath
? `${parentPath} ${decryptedName}`
: decryptedName;
return {
...tag,
name: decryptedName,
parentPath: parentPath,
children: await decryptTagTree(tag.children, key, currentPath),
};
}),
);
};

View file

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

View file

@ -1,26 +1,16 @@
import axios from "axios";
import { NoteRead } from "./folders";
import { encryptString, decryptString } from "./encryption";
import { useAuthStore } from "../stores/authStore";
axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD
? "/api"
: "http://localhost:8000/api";
import { CamelCasedPropertiesDeep } from "type-fest";
import { components } from "@/types/api";
import client from "./client";
export interface Note {
id: number;
title: string;
folder_id?: number;
content: string;
created_at: string;
updated_at: string;
}
export interface NoteCreate {
title: string;
content: string;
folder_id: number | null;
}
export type NoteRead = CamelCasedPropertiesDeep<
components["schemas"]["NoteRead"]
>;
export type NoteCreate = CamelCasedPropertiesDeep<
components["schemas"]["NoteCreate"]
>;
export type Note = CamelCasedPropertiesDeep<components["schemas"]["Note"]>;
const createNote = async (note: NoteCreate) => {
const encryptionKey = useAuthStore.getState().encryptionKey;
@ -32,52 +22,93 @@ const createNote = async (note: NoteCreate) => {
var encryptedNote = {
title: noteTitle,
content: noteContent,
folder_id: note.folder_id,
folderId: note.folderId,
};
console.log(encryptedNote);
return axios.post(`${API_URL}/notes/`, encryptedNote);
return client.POST(`/notes/`, { body: encryptedNote });
};
const fetchNotes = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey;
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);
const decryptedNotes = await Promise.all(
data.map(async (note: Note) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
})),
);
return decryptedNotes;
if (data) {
const decryptedNotes = await Promise.all(
data.map(async (note) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
tags: note.tags
? await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
)
: [],
})),
);
return decryptedNotes;
}
};
const updateNote = async (id: number, note: Partial<Note>) => {
const updateNote = async (id: number, note: Partial<NoteRead>) => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
var encryptedNote: Partial<Note> = {};
var encryptedNote: Partial<NoteRead> = {};
if (note.content) {
encryptedNote.content = await encryptString(note.content, encryptionKey);
}
if (note.title) {
encryptedNote.title = await encryptString(note.title, encryptionKey);
}
if (note.folder_id) {
encryptedNote.folder_id = note.folder_id;
if (note.folderId) {
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 = {
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),
update: (id: number, note: Partial<Note>) => updateNote(id, note),
delete: (id: number) => axios.delete(`${API_URL}/notes/${id}`),
update: (id: number, note: Partial<NoteRead>) => updateNote(id, note),
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 { FolderTreeNode } from "../../api/folders";
import { useNoteStore } from "../../stores/notesStore";
import { folderApi } from "../../api/folders";
import {
useCreateFolder,
useUpdateFolder,
useDeleteFolder,
} from "../../hooks/useFolders";
interface FolderContextMenuProps {
x: number;
@ -16,7 +19,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
folder,
onClose,
}) => {
const { loadFolderTree, updateFolder } = useNoteStore();
const createFolderMutation = useCreateFolder();
const updateFolderMutation = useUpdateFolder();
const deleteFolderMutation = useDeleteFolder();
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState(folder.name);
@ -25,8 +31,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
return;
}
try {
await folderApi.delete(folder.id);
await loadFolderTree();
await deleteFolderMutation.mutateAsync(folder.id);
onClose();
} catch (error) {
console.error("Failed to delete folder:", error);
@ -35,7 +40,14 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
const handleRename = async () => {
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);
onClose();
@ -43,11 +55,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
const handleCreateSubfolder = async () => {
try {
await folderApi.create({
await createFolderMutation.mutateAsync({
name: "New Folder",
parent_id: folder.id,
parentId: folder.id,
});
await loadFolderTree();
onClose();
} catch (error) {
console.error("Failed to create subfolder:", error);
@ -62,7 +73,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
top: y,
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()}
>
<input
@ -78,7 +89,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
}}
onBlur={handleRename}
autoFocus
className="w-full px-2 py-1 bg-ctp-surface1 border border-ctp-surface2 rounded text-sm text-ctp-text focus:outline-none focus:border-ctp-mauve"
className="w-full px-2 py-1 bg-surface1 border border-surface1 rounded text-sm text-text focus:outline-none focus:border-accent"
/>
</div>
);
@ -91,25 +102,25 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
top: y,
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()}
>
<button
onClick={() => setIsRenaming(true)}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors"
className="w-full text-left px-3 py-1.5 hover:bg-surface1 text-sm text-text transition-colors"
>
Rename
</button>
<button
onClick={handleCreateSubfolder}
className="w-full text-left px-3 py-1.5 hover:bg-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
</button>
<div className="border-t border-ctp-surface2 my-1" />
<div className="border-t border-surface1 my-1" />
<button
onClick={handleDelete}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-red hover:text-ctp-base text-sm text-ctp-red transition-colors"
className="w-full text-left px-3 py-1.5 hover:bg-danger hover:text-base text-sm text-danger transition-colors"
>
Delete
</button>

View file

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

View file

@ -105,7 +105,7 @@ export const ContextMenuProvider = ({
e.preventDefault();
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>
)}
{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";
@import "@catppuccin/tailwindcss/macchiato.css";
@theme {
--color-base:
;
}
@theme {
/* Map Tailwind classes to CSS variables */
--color-ctp-base: var(--color-ctp-base);
--color-ctp-mantle: var(--color-ctp-mantle);
--color-ctp-crust: var(--color-ctp-crust);
--color-ctp-text: var(--color-ctp-text);
--color-ctp-subtext0: #a5adcb;
--color-ctp-overlay0: #6e738d;
--color-ctp-mauve: var(--color-ctp-mauve);
--color-ctp-blue: var(--color-ctp-blue);
--color-ctp-green: #a6da95;
--color-ctp-red: #ed8796;
--color-ctp-yellow: #eed49f;
--color-ctp-teal: #8bd5ca;
--color-ctp-sapphire: #7dc4e4;
--color-ctp-peach: #f5a97f;
/* Surface colors */
--color-ctp-surface0: #363a4f;
--color-ctp-surface1: #494d64;
--color-ctp-surface2: #5b6078;
}
/* Default values (Macchiato) - injected by JS, but good as fallback */
:root {
--color-ctp-base: #24273a;
--color-ctp-mantle: #1e2030;
--color-ctp-crust: #181926;
--color-ctp-text: #cad3f5;
--color-ctp-mauve: #c6a0f6;
--color-ctp-blue: #8aadf4;
--black: 15, 18, 25;
--gray: 96, 115, 159;
--gray-light: 229, 233, 240;
--gray-dark: 34, 41, 57;
--box-shadow:
0 2px 6px rgba(30, 32, 48, 0.4), 0 8px 24px rgba(30, 32, 48, 0.5),
0 16px 32px rgba(30, 32, 48, 0.6);
}
@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 */
@ -47,19 +31,19 @@
._mdxeditor-root-content-editable,
.mdxeditor-root-contenteditable,
div[contenteditable="true"] {
color: var(--color-ctp-text) !important;
color: var(--color-text) !important;
}
._listItemChecked_1tncs_73::before {
--accentSolid: var(--color-ctp-mauve) !important;
border-color: var(--color-ctp-mauve-900) !important;
--accentSolid: var(--color-accent) !important;
border-color: var(--color-accent) !important;
border: 2px;
}
._listItemChecked_1tncs_73::after {
border-color: var(--color-ctp-mauve-900) !important;
border-color: var(--color-accent) !important;
}
.standard-input {
@apply border border-ctp-mauve rounded-sm px-2 py-1 w-full focus:outline-none focus:ring-2 focus:ring-ctp-mauve bg-ctp-base text-ctp-text placeholder:text-ctp-overlay0;
@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 App from "./App.tsx";
import "./main.css";
// import "./assets/fontawesome/js/fontawesome.min.js";
// import "./assets/fontawesome/js/duotone-regular.js";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</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 { motion } from "framer-motion";
import { AnimatePresence, motion } from "framer-motion";
import { useAuthStore } from "@/stores/authStore";
import { useNoteStore } from "@/stores/notesStore";
import { useUIStore } from "@/stores/uiStore";
import { Login } from "../Login";
import { TiptapEditor } from "../TipTap";
import { Sidebar } from "./components/sidebar/SideBar";
import { StatusIndicator } from "./components/StatusIndicator";
import { useUpdateNote } from "@/hooks/useFolders";
import { NoteRead } from "@/api/notes";
// @ts-ignore
import XmarkIcon from "@/assets/fontawesome/svg/xmark.svg?react";
// @ts-ignore
import PlusIcon from "@/assets/fontawesome/svg/plus.svg?react";
function Home() {
const [newFolder] = useState(false);
// Local state for editing the current note
const [editingNote, setEditingNote] = useState<NoteRead | null>(null);
const [lastSavedNote, setLastSavedNote] = useState<{
id: number;
title: string;
content: string;
} | null>(null);
const { loadFolderTree, updateNote, setContent, selectedNote, setTitle } =
useNoteStore();
const { encryptionKey } = useAuthStore();
const { showModal, setUpdating } = useUIStore();
const { showModal, setUpdating, selectedNote, editorView } = useUIStore();
const newFolderRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!encryptionKey) return;
loadFolderTree();
}, []);
const updateNoteMutation = useUpdateNote();
// 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(() => {
if (newFolder && newFolderRef.current) {
newFolderRef.current.focus();
}
}, [newFolder]);
// Auto-save effect - watches editingNote for changes
useEffect(() => {
if (!selectedNote) return;
if (!encryptionKey) return; // Don't try to save without encryption key
if (!editingNote) return;
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 =
lastSavedNote &&
lastSavedNote.id === selectedNote.id &&
(lastSavedNote.title !== selectedNote.title ||
lastSavedNote.content !== selectedNote.content);
// If it's a new note selection, just update lastSavedNote without saving
if (!lastSavedNote || lastSavedNote.id !== selectedNote.id) {
setLastSavedNote({
id: selectedNote.id,
title: selectedNote.title,
content: selectedNote.content,
});
return;
}
lastSavedNote.id === editingNote.id &&
(lastSavedNote.title !== editingNote.title ||
lastSavedNote.content !== editingNote.content);
if (!hasChanges) return;
@ -64,25 +75,31 @@ function Home() {
setUpdating(true);
await handleUpdate();
setLastSavedNote({
id: selectedNote.id,
title: selectedNote.title,
content: selectedNote.content,
id: editingNote.id,
title: editingNote.title,
content: editingNote.content,
});
}, 2000);
return () => clearTimeout(timer);
}, [selectedNote, encryptionKey]);
}, [editingNote?.title, editingNote?.content, encryptionKey]);
const handleUpdate = async () => {
if (!selectedNote) return;
if (!editingNote) return;
if (!encryptionKey) {
setUpdating(false);
return;
}
try {
await updateNote(selectedNote.id);
console.log(selectedNote.id);
if (!editingNote.id) throw new Error("Editing note has no id.");
await updateNoteMutation.mutateAsync({
noteId: editingNote.id,
note: {
title: editingNote.title,
content: editingNote.content,
},
});
} catch (error) {
console.error("Failed to update note:", error);
} finally {
@ -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 (
<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 */}
{showModal && <Modal />}
<AnimatePresence>{showModal && <Modal />}</AnimatePresence>
<Sidebar />
{/* Main editor area */}
<div className="flex flex-col w-full h-screen overflow-hidden">
{/*<Editor />*/}
<input
type="text"
placeholder="Untitled note..."
value={selectedNote?.title || ""}
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"
/>
<TiptapEditor
key={selectedNote?.id}
content={selectedNote?.content || ""}
onChange={setContent}
/>
{" "}
{editingNote ? (
<>
<input
type="text"
id="noteTitle"
placeholder="Untitled note..."
value={editingNote.title || ""}
onChange={(e) => setTitle(e.target.value)}
className="w-full self-center p-4 pb-2 pt-2 text-3xl font-semibold focus:outline-none border-transparent focus:border-accent 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
key={editingNote.id}
content={editingNote.content || ""}
onChange={setContent}
/>
) : (
<textarea
value={editingNote.content || ""}
className="w-full font-mono p-4 bg-transparent focus:outline-none resize-none text-text"
style={{
minHeight: "calc(100vh - 55px)",
}}
onChange={setUnparsedContent}
/>
)}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-overlay0">
<div className="text-center">
<PlusIcon className="w-16 h-16 mx-auto mb-4 fill-current opacity-50" />
<p className="text-lg">Select a note or create a new one</p>
</div>
</div>
)}
</div>
<StatusIndicator />
@ -124,20 +185,35 @@ function Home() {
export default Home;
const Modal = () => {
const { setShowModal } = useUIStore();
const { setShowModal, modalContent, showModal } = useUIStore();
const ModalContent = modalContent;
if (!showModal || !ModalContent) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowModal(false)}
className="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()}
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 />
</div>
<button
onClick={() => setShowModal(false)}
className="absolute top-4 right-4 p-2 hover:bg-surface0 rounded-sm transition-colors group"
aria-label="Close modal"
>
<XmarkIcon className="w-5 h-5 fill-overlay0 group-hover:fill-text transition-colors" />
</button>
<ModalContent />
{/*<Login />*/}
</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";
// @ts-ignore
import WarningIcon from "../../../assets/fontawesome/svg/circle-exclamation.svg?react";
import { Login } from "@/pages/Login";
export const StatusIndicator = () => {
const { encryptionKey } = useAuthStore();
const { updating, setShowModal } = useUIStore();
const { updating, setShowModal, editorView, setEditorView, setModalContent } =
useUIStore();
return (
<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={() => {
if (!encryptionKey) {
setModalContent(Login);
setShowModal(true);
}
}}
>
<div
className="select-none"
onClick={() =>
setEditorView(editorView == "parsed" ? "unparsed" : "parsed")
}
>
{editorView}
</div>
{!encryptionKey ? (
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-ctp-yellow [&_.fa-secondary]:fill-ctp-orange" />
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-warn [&_.fa-secondary]:fill-orange" />
) : updating ? (
<>
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-ctp-blue [&_.fa-secondary]:fill-ctp-sapphire" />
<span className="text-sm text-ctp-subtext0 font-medium">
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-warn [&_.fa-secondary]:fill-sapphire" />
{/*<span className="text-sm text-subtext font-medium">
Saving...
</span>
</span>*/}
</>
) : (
<>
<CheckIcon className="h-4 w-4 [&_.fa-primary]:fill-ctp-green [&_.fa-secondary]:fill-ctp-teal" />
<span className="text-sm text-ctp-subtext0 font-medium">Saved</span>
<CheckIcon className="h-4 w-4 [&_.fa-primary]:fill-success [&_.fa-secondary]:fill-teal" />
{/*<span className="text-sm text-subtext font-medium">Saved</span>*/}
</>
)}
</div>

View file

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

View file

@ -1,10 +1,10 @@
import { useDraggable } from "@dnd-kit/core";
import { useContextMenu } from "@/contexts/ContextMenuContext";
import { useNoteStore } from "@/stores/notesStore";
import { NoteRead } from "@/api/folders";
import { useUIStore } from "@/stores/uiStore";
import { NoteRead } from "@/api/notes";
export const DraggableNote = ({ note }: { note: NoteRead }) => {
const { selectedNote, setSelectedNote } = useNoteStore();
const { selectedNote, setSelectedNote } = useUIStore();
const { openContextMenu } = useContextMenu();
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 ${
selectedNote?.id === note.id
? "bg-ctp-mauve text-ctp-base"
: "hover:bg-ctp-surface1"
? "bg-accent text-base"
: "hover:bg-surface1"
}`}
>
<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";
// @ts-ignore
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
import { Folder } from "@/api/folders";
import { FolderTreeNode } from "@/api/folders";
import { useContextMenu } from "@/contexts/ContextMenuContext";
export const DroppableFolder = ({
@ -12,7 +12,7 @@ export const DroppableFolder = ({
setCollapse,
collapse,
}: {
folder: Partial<Folder>;
folder: Partial<FolderTreeNode>;
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
collapse: boolean;
}) => {
@ -63,10 +63,12 @@ export const DroppableFolder = ({
{...listeners}
{...attributes}
>
<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`}
/>
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-ctp-mauve mr-1" />
{(folder.notes?.length ?? 0) > 0 && (
<CaretRightIcon
className={`w-4 h-4 min-h-4 min-w-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-accent`}
/>
)}
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-accent mr-1" />
<span className="truncate">{folder.name}</span>
</div>
</div>

View file

@ -29,7 +29,7 @@ export const FolderTree = ({ folder, depth = 0 }: FolderTreeProps) => {
className="overflow-hidden flex flex-col"
>
{/* 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 */}
<div className="flex flex-col gap-0.5">
{folder.notes.map((note) => (

View file

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

View file

@ -26,31 +26,62 @@ export const Login = () => {
};
return (
<form onSubmit={handleSubmit} className="gap-2 flex flex-col">
<input
type="text"
placeholder="Username"
className="standard-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
className="standard-input"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div>{error}</div>}
<button type="submit">Login</button>
<div className="flex gap-2">
<form
onSubmit={handleSubmit}
className="gap-4 flex flex-col max-w-md mx-auto"
>
<h2 className="text-2xl font-semibold text-text mb-2">Welcome Back</h2>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-subtext">Username</label>
<input
type="check box"
type="text"
placeholder="Enter your username"
className="standard-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-subtext">Password</label>
<input
type="password"
className="standard-input"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<div className="bg-danger/10 border border-danger text-danger px-3 py-2 rounded-sm text-sm">
{error}
</div>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="remember"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="accent-accent cursor-pointer"
/>
<div>Remember me?</div>
<label
htmlFor="remember"
className="text-sm text-subtext cursor-pointer"
>
Remember me
</label>
</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>
);
};

View file

@ -23,29 +23,57 @@ export const Register = () => {
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div>{error}</div>}
<button type="submit">Login</button>
<form
onSubmit={handleSubmit}
className="gap-4 flex flex-col max-w-md mx-auto"
>
<h2 className="text-2xl font-semibold text-text mb-2">Create Account</h2>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-subtext">Username</label>
<input
type="text"
placeholder="Choose a username"
className="standard-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-subtext">Email</label>
<input
type="email"
placeholder="Enter your email"
className="standard-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-subtext">Password</label>
<input
type="password"
className="standard-input"
placeholder="Create a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<div className="bg-danger/10 border border-danger text-danger px-3 py-2 rounded-sm text-sm">
{error}
</div>
)}
<button
type="submit"
className="bg-accent 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>
);
};
// Similar pattern for Register.tsx

View file

@ -1,6 +1,6 @@
export const Test = () => {
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
type="text"
placeholder="Folder name..."

View file

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

View file

@ -7,33 +7,37 @@
}
*::-webkit-scrollbar-track {
@apply bg-ctp-mantle rounded-full;
@apply bg-surface0 rounded-full;
}
*::-webkit-scrollbar-thumb {
@apply bg-ctp-surface2 rounded-full;
@apply bg-surface1 rounded-full;
}
*::-webkit-scrollbar-thumb:hover {
@apply bg-ctp-overlay0;
@apply bg-overlay0;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-ctp-surface2) var(--color-ctp-mantle);
scrollbar-color: var(--color-surface1) var(--color-surface0);
}
.tiptap-editor {
@apply flex flex-col h-full bg-ctp-base;
@apply flex flex-col h-full bg-base;
}
.ProseMirror {
@apply text-ctp-text;
@apply text-text;
}
.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 {
@ -41,19 +45,19 @@
}
.toolbar-divider {
@apply w-px h-6 bg-ctp-surface2;
@apply w-px h-6 bg-surface1;
}
.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) {
@apply bg-ctp-surface0;
@apply bg-surface0;
}
.editor-toolbar button.active {
@apply bg-ctp-mauve text-ctp-base;
@apply bg-accent text-base;
}
.editor-toolbar button:disabled {
@ -66,59 +70,59 @@
.ProseMirror p.is-editor-empty:first-child::before {
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 {
@apply mb-0!;
}
.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 {
@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 {
@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 {
@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;
}
.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;
}
.ProseMirror .code-block code {
@apply bg-transparent p-0 text-ctp-text;
@apply bg-transparent p-0 text-text;
}
.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 {
@apply border-none border-t-2 border-ctp-surface2 my-8;
@apply border-none border-t-2 border-surface1 my-8;
}
.ProseMirror a {
@apply text-ctp-blue underline;
@apply text-accent underline;
}
.ProseMirror a:hover {
@apply text-ctp-sapphire;
@apply text-accent;
}
.ProseMirror strong {
@apply text-ctp-peach font-semibold;
@apply text-accent font-semibold;
}
.ProseMirror em {
@apply text-ctp-yellow;
@apply text-accent;
}
/* Task List (Checkboxes) */
@ -135,7 +139,7 @@
}
.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 {
@ -147,17 +151,18 @@
}
.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-thickness: 1px;
}
.ProseMirror u {
@apply decoration-ctp-mauve;
text-decoration-style: wavy;
@apply decoration-accent;
/*text-decoration-style: wavy;*/
}
.ProseMirror li::marker {
@apply text-ctp-mauve;
@apply text-accent;
}
/* tiptap.css */
@ -171,3 +176,19 @@
margin-top: 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

@ -11,15 +11,15 @@ interface User {
id: number;
username: string;
email: string;
salt: string; // For key derivation
salt: string;
}
interface AuthState {
user: User | null;
encryptionKey: CryptoKey | null; // Memory only!
encryptionKey: CryptoKey | null;
isAuthenticated: boolean;
rememberMe: boolean;
setRememberMe: (boolean) => void;
setRememberMe: (remember: boolean) => void;
login: (username: string, password: string) => Promise<void>;
register: (
@ -30,9 +30,12 @@ interface AuthState {
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
initEncryptionKey: (password: string, salt: string) => Promise<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>()(
persist(
@ -45,7 +48,6 @@ export const useAuthStore = create<AuthState>()(
set({ rememberMe: bool });
},
initEncryptionKey: async (password: string, salt: string) => {
// Use user-specific salt instead of hardcoded
const key = await deriveKey(password, salt);
set({ encryptionKey: key });
},
@ -76,7 +78,6 @@ export const useAuthStore = create<AuthState>()(
const data = await response.json();
// Store the master key directly (not derived from password)
set({
user: data.user,
isAuthenticated: true,
@ -99,11 +100,9 @@ export const useAuthStore = create<AuthState>()(
const { user } = await response.json();
// Derive KEK and unwrap master key
const kek = await deriveKey(password, user.salt);
const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek);
// Store master key in memory
set({ encryptionKey: masterKey, user, isAuthenticated: true });
},
@ -115,9 +114,10 @@ export const useAuthStore = create<AuthState>()(
set({
user: null,
encryptionKey: null, // Wipe from memory
encryptionKey: null,
isAuthenticated: false,
});
get().clearAll();
},
checkAuth: async () => {
@ -137,6 +137,17 @@ export const useAuthStore = create<AuthState>()(
get().logout();
}
},
clearAll: () => {
set({
user: null,
encryptionKey: null,
isAuthenticated: false,
rememberMe: false,
});
localStorage.clear();
},
}),
{
name: "auth-storage",
@ -149,3 +160,6 @@ export const useAuthStore = create<AuthState>()(
},
),
);
if (typeof window !== "undefined") {
(window as any).useAuthStore = useAuthStore;
}

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,
) => {
if (folder.id === id) {
return { ...folder, ...newFolder };
}
if (folder.children) {
return {
...folder,
children: folder.children.map((folder) =>
updateFolder(id, folder, 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 { 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 {
updating: boolean;
@ -8,8 +30,26 @@ interface UIState {
showModal: boolean;
setShowModal: (show: boolean) => void;
modalContent: React.ComponentType | null;
setModalContent: (content: React.ComponentType) => void;
sideBarResize: number;
setSideBarResize: (size: number) => void;
sideBarView: string;
setSideBarView: (view: string) => void;
editorView: string;
setEditorView: (view: string) => void;
selectedNote: NoteRead | null;
setSelectedNote: (note: NoteRead | null) => void;
selectedFolder: number | null;
setSelectedFolder: (id: number | null) => void;
colourScheme: ColourState;
setColourScheme: (colors: ColourState) => void;
}
export const useUIStore = create<UIState>()(
@ -19,15 +59,60 @@ export const useUIStore = create<UIState>()(
setUpdating: (update) => {
set({ updating: update });
},
showModal: false,
showModal: true,
setShowModal: (show) => {
set({ showModal: show });
},
modalContent: null,
setModalContent: (content) => {
set({ modalContent: content });
},
sideBarResize: 300,
setSideBarResize: (size) => {
set({ sideBarResize: size });
},
sideBarView: "folders",
setSideBarView: (view) => {
set({ sideBarView: view });
},
editorView: "parsed",
setEditorView: (view) => {
set({ editorView: view });
},
selectedNote: null,
setSelectedNote: (id: NoteRead | null) => {
set({ selectedNote: id });
},
selectedFolder: null,
setSelectedFolder: (id: number | null) => {
set({ selectedFolder: id });
},
colourScheme: {
base: "#24273a",
surface0: "#1e2030",
surface1: "#181926",
overlay0: "#363a4f",
overlay1: "#494d64",
text: "#cad3f5",
subtext: "#b8c0e0",
accent: "#e2a16f",
danger: "#e26f6f",
success: "#6fe29b",
warn: "#e2c56f",
},
setColourScheme: (colors: ColourState) => {
set({ colourScheme: colors });
Object.entries(colors).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--color-${key}`, value);
});
},
}),
{
name: "ui-store",
partialize: (state) => {

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,7 +2,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import svgr from "vite-plugin-svgr";
import path from "path";
import * as path from "path";
export default defineConfig({
plugins: [tailwindcss(), react(), svgr()],