Compare commits

..

No commits in common. "main" and "test/implement-vitest" have entirely different histories.

80 changed files with 10751 additions and 12720 deletions

26
.gitignore vendored
View file

@ -1,27 +1,3 @@
node_modules
*.svg
frontend/src/assets/fontawesome/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/*

View file

@ -1,32 +0,0 @@
__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,39 +1,10 @@
# ---- Builder stage ----
FROM python:3.12-slim AS builder
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# ---- Runtime stage ----
FROM python:3.12-slim
WORKDIR /app
COPY ./app ./app
# 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"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2,7 +2,7 @@ import secrets
from datetime import datetime, timedelta
from typing import Optional
import bcrypt
import bcrypt # Use bcrypt directly instead of passlib
from fastapi import Cookie, Depends, HTTPException, Request, status
from sqlmodel import Session, select
@ -11,6 +11,7 @@ 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()
@ -24,11 +25,12 @@ 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.now() + timedelta(days=expires_in_days)
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
db_session = SessionModel(
session_id=session_id,
@ -51,12 +53,13 @@ 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.now():
if not session or session.expires_at < datetime.utcnow():
return None
return session.user
# Dependency for protected routes
async def require_auth(
session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session)
) -> User:

View file

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

View file

@ -1,17 +1,15 @@
import os
from fastapi import FastAPI # type: ignore
from fastapi.middleware.cors import CORSMiddleware # type:ignore
from app.database import create_db_and_tables
from app.routes import auth, folders, notes, tags
from app.routes import auth, folders, notes
app = FastAPI(title="Notes API")
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
# CORS - adjust origins for production
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_origins=["http://localhost:5173"], # Vite dev server
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@ -26,15 +24,8 @@ 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,27 +4,26 @@ from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel # type: ignore
class User(SQLModel, table=True): # type: ignore
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True)
email: str = Field(unique=True, index=True)
hashed_password: str
salt: str
wrapped_master_key: str
created_at: datetime = Field(default_factory=datetime.now)
created_at: datetime = Field(default_factory=datetime.utcnow)
# Add relationships to existing models
notes: List["Note"] = Relationship(back_populates="user")
folders: List["Folder"] = Relationship(back_populates="user")
sessions: List["Session"] = Relationship(back_populates="user")
tags: List["Tag"] = Relationship(back_populates="user")
class Session(SQLModel, table=True): # type: ignore
class Session(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
session_id: str = Field(unique=True, index=True)
user_id: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.now)
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: datetime
ip_address: Optional[str] = None
user_agent: Optional[str] = None
@ -36,7 +35,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.now)
created_at: datetime = Field(default_factory=datetime.utcnow)
user_id: int = Field(foreign_key="user.id")
# Relationships
@ -48,75 +47,20 @@ 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.now)
updated_at: datetime = Field(default_factory=datetime.now)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
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
@ -124,7 +68,6 @@ class NoteRead(SQLModel):
folder_id: Optional[int] = None
created_at: datetime
updated_at: datetime
tags: List[TagRead] = []
class FolderTreeNode(SQLModel):

View file

@ -1,12 +1,13 @@
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"])
@ -29,8 +30,8 @@ class UserResponse(SQLModel):
id: int
username: str
email: str
salt: str
wrapped_master_key: str
salt: str # Client needs this for key derivation
wrapped_master_key: str # Client needs this to unwrap the master key
@router.post("/register")
@ -71,7 +72,7 @@ def register(
key="session_id",
value=session_id,
httponly=True,
secure=True,
secure=True, # HTTPS only in production
samesite="lax",
max_age=30 * 24 * 60 * 60, # 30 days
)
@ -146,15 +147,15 @@ def list_sessions(
return {"sessions": sessions}
@router.delete("/sessions/{session_token}")
@router.delete("/sessions/{session_token}") # Renamed from session_id
def revoke_session(
session_token: str,
session_token: str, # Renamed to avoid conflict with Cookie parameter
current_user: User = Depends(require_auth),
db: Session = Depends(get_session),
):
session = db.exec(
select(SessionModel)
.where(SessionModel.session_id == session_token)
.where(SessionModel.session_id == session_token) # Use renamed variable
.where(SessionModel.user_id == current_user.id)
).first()

View file

@ -1,5 +1,8 @@
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 (
@ -12,9 +15,6 @@ 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,20 +35,21 @@ 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, NoteRead, NoteUpdate, User
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.models import Note, NoteCreate, NoteUpdate, User
router = APIRouter(prefix="/notes", tags=["notes"])
@router.get("/", response_model=list[NoteRead])
@router.get("/")
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

@ -1,91 +0,0 @@
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"}

BIN
backend/notes.db Normal file

Binary file not shown.

1
backend/notes.sqbpro Normal file
View file

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

View file

@ -1,21 +0,0 @@
#!/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 "*"

View file

@ -1,44 +0,0 @@
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:

View file

@ -1,27 +0,0 @@
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

View file

@ -1,31 +0,0 @@
# ---------- 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;"]

View file

@ -1,48 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Static files with caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API proxy - routes /api requests to backend service
location /api {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,13 +3,9 @@
"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"
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -18,22 +14,17 @@
"@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": {
@ -41,17 +32,18 @@
"@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": "^5.4.21",
"vite-plugin-svgr": "^4.5.0",
"vitest": "^4.0.15"
},
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}

View file

@ -1,67 +0,0 @@
// 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,23 +1,4 @@
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[];
}
import { FolderTreeResponse, FolderTreeNode } from "./folders";
export async function deriveKey(password: string, salt: string) {
const enc = new TextEncoder();
@ -133,12 +114,6 @@ 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(
@ -151,40 +126,12 @@ export async function decryptFolderTree(
folders: await Promise.all(
tree.folders.map((folder) => decryptFolder(folder)),
),
orphanedNotes: await Promise.all(
tree.orphanedNotes.map(async (note) => ({
orphaned_notes: await Promise.all(
tree.orphaned_notes.map(async (note) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})),
),
};
}
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,34 +1,60 @@
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";
export type Folder = CamelCasedPropertiesDeep<components["schemas"]["Folder"]>;
axios.defaults.withCredentials = true;
export type FolderTreeNode = CamelCasedPropertiesDeep<
components["schemas"]["FolderTreeNode"]
>;
const API_URL = (import.meta as any).env.PROD
? "/api"
: "http://localhost:8000/api";
export type FolderTreeResponse = CamelCasedPropertiesDeep<
components["schemas"]["FolderTreeResponse"]
>;
export type FolderCreate = CamelCasedPropertiesDeep<
components["schemas"]["FolderCreate"]
>;
export type FolderUpdate = CamelCasedPropertiesDeep<
components["schemas"]["FolderUpdate"]
>;
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;
}
const getFolderTree = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const { data, error } = await client.GET("/folders/tree", {});
const { data } = await axios.get<FolderTreeResponse>(
`${API_URL}/folders/tree`,
);
const newData = data as unknown as FolderTreeResponse;
const decryptedFolderTree = await decryptFolderTree(newData, encryptionKey);
const decryptedFolderTree = await decryptFolderTree(data, encryptionKey);
return decryptedFolderTree;
};
@ -36,10 +62,7 @@ const getFolderTree = async () => {
const updateFolder = async (id: number, folder: FolderUpdate) => {
console.log(`Updating folder ${id} with:`, folder);
try {
const response = await client.PATCH("/folders/{folder_id}", {
params: { path: { folder_id: id } },
body: folder,
});
const response = await axios.patch(`${API_URL}/folders/${id}`, folder);
console.log(`Folder ${id} update response:`, response.data);
return response;
} catch (error) {
@ -50,12 +73,10 @@ const updateFolder = async (id: number, folder: FolderUpdate) => {
export const folderApi = {
tree: () => getFolderTree(),
list: () => client.GET("/folders/", {}),
create: (folder: FolderCreate) => client.POST("/folders/", { body: folder }),
delete: (id: number) =>
client.DELETE("/folders/{folder_id}", {
params: { path: { folder_id: id } },
}),
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}`),
update: (id: number, updateData: FolderUpdate) =>
updateFolder(id, updateData),
};

View file

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

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 718 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 585 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 772 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 711 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 856 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 860 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 573 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 755 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 554 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 979 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 590 B

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
import { useDraggable } from "@dnd-kit/core";
import { useContextMenu } from "@/contexts/ContextMenuContext";
import { useUIStore } from "@/stores/uiStore";
import { NoteRead } from "@/api/notes";
import { useNoteStore } from "@/stores/notesStore";
import { NoteRead } from "@/api/folders";
export const DraggableNote = ({ note }: { note: NoteRead }) => {
const { selectedNote, setSelectedNote } = useUIStore();
const { selectedNote, setSelectedNote } = useNoteStore();
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-accent text-base"
: "hover:bg-surface1"
? "bg-ctp-mauve text-ctp-base"
: "hover:bg-ctp-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 { FolderTreeNode } from "@/api/folders";
import { Folder } from "@/api/folders";
import { useContextMenu } from "@/contexts/ContextMenuContext";
export const DroppableFolder = ({
@ -12,7 +12,7 @@ export const DroppableFolder = ({
setCollapse,
collapse,
}: {
folder: Partial<FolderTreeNode>;
folder: Partial<Folder>;
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
collapse: boolean;
}) => {
@ -63,12 +63,10 @@ export const DroppableFolder = ({
{...listeners}
{...attributes}
>
{(folder.notes?.length ?? 0) > 0 && (
<CaretRightIcon
className={`w-4 h-4 min-h-4 min-w-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-accent`}
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-accent mr-1" />
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-ctp-mauve 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-surface1">
<div className="ml-2 pl-3 border-l border-ctp-surface2">
{/* Notes */}
<div className="flex flex-col gap-0.5">
{folder.notes.map((note) => (

View file

@ -2,87 +2,38 @@ import { SetStateAction } from "react";
// @ts-ignore
import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
// @ts-ignore
import TagsIcon from "@assets/fontawesome/svg/tags.svg?react";
// @ts-ignore
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
// @ts-ignore
import GearIcon from "@assets/fontawesome/svg/gear.svg?react";
import { useUIStore } from "@/stores/uiStore";
import { useCreateNote } from "@/hooks/useFolders";
import { NoteCreate } from "@/api/notes";
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>
))}
</>
);
};
import { useNoteStore } from "@/stores/notesStore";
export const SidebarHeader = ({
setNewFolder,
}: {
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
}) => {
const { selectedFolder, setShowModal, setModalContent } = useUIStore();
const createNote = useCreateNote();
const { createNote, selectedFolder } = useNoteStore();
const handleCreate = async () => {
createNote.mutate({
await createNote({
title: "Untitled",
content: "",
folder_id: selectedFolder,
} as NoteCreate);
};
const handleSettings = () => {
setModalContent(Test);
setShowModal(true);
});
};
return (
<div className="w-full p-2 border-b border-surface1 bg-surface1">
<div className="flex items-center justify-around bg-surface0 rounded-lg p-1 gap-1">
<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-accent active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2"
title="New folder"
>
<FolderPlusIcon className="w-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent" />
<FolderPlusIcon className="w-4 h-4 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button>
<button
onClick={handleCreate}
className="hover:bg-accent active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
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-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent" />
<FileCirclePlusIcon className="w-4 h-4 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
</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,62 +26,31 @@ export const Login = () => {
};
return (
<form
onSubmit={handleSubmit}
className="gap-4 flex flex-col max-w-md mx-auto"
>
<h2 className="text-2xl font-semibold text-text mb-2">Welcome Back</h2>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-subtext">Username</label>
<form onSubmit={handleSubmit} className="gap-2 flex flex-col">
<input
type="text"
placeholder="Enter your username"
placeholder="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"
placeholder="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">
{error && <div>{error}</div>}
<button type="submit">Login</button>
<div className="flex gap-2">
<input
type="checkbox"
id="remember"
type="check box"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="accent-accent cursor-pointer"
/>
<label
htmlFor="remember"
className="text-sm text-subtext cursor-pointer"
>
Remember me
</label>
<div>Remember me?</div>
</div>
<button
type="submit"
className="bg-accent hover:bg-accent/90 text-base font-semibold px-4 py-2.5 rounded-sm transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-base"
>
Login
</button>
</form>
);
};

View file

@ -23,57 +23,29 @@ export const Register = () => {
};
return (
<form
onSubmit={handleSubmit}
className="gap-4 flex flex-col max-w-md mx-auto"
>
<h2 className="text-2xl font-semibold text-text mb-2">Create Account</h2>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-subtext">Username</label>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Choose a username"
className="standard-input"
placeholder="Username"
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"
placeholder="Email"
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"
placeholder="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>
{error && <div>{error}</div>}
<button type="submit">Login</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-base p-4">
<div className="h-screen w-screen flex items-center justify-center bg-ctp-base p-4">
<input
type="text"
placeholder="Folder name..."

View file

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

View file

@ -7,37 +7,33 @@
}
*::-webkit-scrollbar-track {
@apply bg-surface0 rounded-full;
@apply bg-ctp-mantle rounded-full;
}
*::-webkit-scrollbar-thumb {
@apply bg-surface1 rounded-full;
@apply bg-ctp-surface2 rounded-full;
}
*::-webkit-scrollbar-thumb:hover {
@apply bg-overlay0;
@apply bg-ctp-overlay0;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-surface1) var(--color-surface0);
scrollbar-color: var(--color-ctp-surface2) var(--color-ctp-mantle);
}
.tiptap-editor {
@apply flex flex-col h-full bg-base;
@apply flex flex-col h-full bg-ctp-base;
}
.ProseMirror {
@apply text-text;
@apply text-ctp-text;
}
.editor-toolbar {
@apply flex gap-2 px-4 bg-surface0 border-b border-surface1 flex-wrap items-center;
}
.editor-content {
@apply h-full;
@apply flex gap-2 px-4 bg-ctp-mantle border-b border-ctp-surface2 flex-wrap items-center;
}
.toolbar-group {
@ -45,19 +41,19 @@
}
.toolbar-divider {
@apply w-px h-6 bg-surface1;
@apply w-px h-6 bg-ctp-surface2;
}
.editor-toolbar button {
@apply p-2 bg-transparent border-none rounded-sm text-text cursor-pointer transition-all duration-150 text-sm font-semibold min-w-8 flex items-center justify-center;
@apply p-2 bg-transparent border-none rounded-sm text-ctp-text cursor-pointer transition-all duration-150 text-sm font-semibold min-w-8 flex items-center justify-center;
}
.editor-toolbar button:hover:not(:disabled) {
@apply bg-surface0;
@apply bg-ctp-surface0;
}
.editor-toolbar button.active {
@apply bg-accent text-base;
@apply bg-ctp-mauve text-ctp-base;
}
.editor-toolbar button:disabled {
@ -70,59 +66,59 @@
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
@apply float-left text-overlay0 pointer-events-none h-0;
@apply float-left text-ctp-overlay0 pointer-events-none h-0;
}
.ProseMirror ul {
@apply mb-0!;
}
.ProseMirror h1 {
@apply text-3xl font-bold mt-6 mb-4 text-accent;
@apply text-3xl font-bold text-ctp-mauve mt-8 mb-4;
}
.ProseMirror h2 {
@apply text-2xl font-semibold text-accent mt-4 mb-3;
@apply text-2xl font-semibold text-ctp-blue mt-6 mb-3;
}
.ProseMirror h3 {
@apply text-xl font-semibold text-accent mt-5 mb-2;
@apply text-xl font-semibold text-ctp-teal mt-5 mb-2;
}
.ProseMirror code {
@apply bg-surface0 text-accent px-1.5 py-0.5 rounded text-sm;
@apply bg-ctp-surface0 text-ctp-peach px-1.5 py-0.5 rounded text-sm;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.ProseMirror .code-block {
@apply bg-surface0 border border-surface1 rounded-sm p-4 my-4 overflow-x-auto;
@apply bg-ctp-surface0 border border-ctp-surface2 rounded-sm p-4 my-4 overflow-x-auto;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.ProseMirror .code-block code {
@apply bg-transparent p-0 text-text;
@apply bg-transparent p-0 text-ctp-text;
}
.ProseMirror blockquote {
@apply border-l-4 border-accent pl-4 ml-0 text-subtext italic;
@apply border-l-4 border-ctp-mauve pl-4 ml-0 text-ctp-subtext0 italic;
}
.ProseMirror hr {
@apply border-none border-t-2 border-surface1 my-8;
@apply border-none border-t-2 border-ctp-surface2 my-8;
}
.ProseMirror a {
@apply text-accent underline;
@apply text-ctp-blue underline;
}
.ProseMirror a:hover {
@apply text-accent;
@apply text-ctp-sapphire;
}
.ProseMirror strong {
@apply text-accent font-semibold;
@apply text-ctp-peach font-semibold;
}
.ProseMirror em {
@apply text-accent;
@apply text-ctp-yellow;
}
/* Task List (Checkboxes) */
@ -139,7 +135,7 @@
}
.ProseMirror ul[data-type="taskList"] > li > label input[type="checkbox"] {
@apply cursor-pointer m-0 accent-accent;
@apply cursor-pointer m-0 accent-ctp-mauve;
}
.ProseMirror ul[data-type="taskList"] > li > div {
@ -151,18 +147,17 @@
}
.ProseMirror li[data-checked="true"] > div > p {
@apply line-through text-text/40;
@apply line-through text-ctp-overlay0;
text-decoration-style: wavy;
text-decoration-thickness: 1px;
}
.ProseMirror u {
@apply decoration-accent;
/*text-decoration-style: wavy;*/
@apply decoration-ctp-mauve;
text-decoration-style: wavy;
}
.ProseMirror li::marker {
@apply text-accent;
@apply text-ctp-mauve;
}
/* tiptap.css */
@ -176,19 +171,3 @@
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;
salt: string; // For key derivation
}
interface AuthState {
user: User | null;
encryptionKey: CryptoKey | null;
encryptionKey: CryptoKey | null; // Memory only!
isAuthenticated: boolean;
rememberMe: boolean;
setRememberMe: (remember: boolean) => void;
setRememberMe: (boolean) => void;
login: (username: string, password: string) => Promise<void>;
register: (
@ -30,12 +30,9 @@ interface AuthState {
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
initEncryptionKey: (password: string, salt: string) => Promise<void>;
clearAll: () => void;
}
const API_URL = import.meta.env.PROD
? "/api" // ← Same domain, different path
: "http://localhost:8000/api";
const API_URL = "http://localhost:8000/api";
export const useAuthStore = create<AuthState>()(
persist(
@ -48,6 +45,7 @@ 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 });
},
@ -78,6 +76,7 @@ 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,
@ -100,9 +99,11 @@ 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 });
},
@ -114,10 +115,9 @@ export const useAuthStore = create<AuthState>()(
set({
user: null,
encryptionKey: null,
encryptionKey: null, // Wipe from memory
isAuthenticated: false,
});
get().clearAll();
},
checkAuth: async () => {
@ -137,17 +137,6 @@ export const useAuthStore = create<AuthState>()(
get().logout();
}
},
clearAll: () => {
set({
user: null,
encryptionKey: null,
isAuthenticated: false,
rememberMe: false,
});
localStorage.clear();
},
}),
{
name: "auth-storage",
@ -160,6 +149,3 @@ export const useAuthStore = create<AuthState>()(
},
),
);
if (typeof window !== "undefined") {
(window as any).useAuthStore = useAuthStore;
}

View file

@ -0,0 +1,352 @@
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,27 +1,5 @@
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;
@ -30,26 +8,8 @@ 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>()(
@ -59,60 +19,15 @@ export const useUIStore = create<UIState>()(
setUpdating: (update) => {
set({ updating: update });
},
showModal: true,
showModal: false,
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) => {

File diff suppressed because it is too large Load diff

View file

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