Implement user auth and session management

This commit is contained in:
james fitzsimons 2025-12-08 22:08:30 +00:00
parent 89fecc5c08
commit 5e6764b026
43 changed files with 3047 additions and 584 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
node_modules node_modules
frontend/src/assets/fontawesome/svg/*
frontend/src/assets/fontawesome/svg/0.svg

Binary file not shown.

71
backend/app/auth.py Normal file
View file

@ -0,0 +1,71 @@
import secrets
from datetime import datetime, timedelta
from typing import Optional
import bcrypt # Use bcrypt directly instead of passlib
from fastapi import Cookie, Depends, HTTPException, Request, status
from sqlmodel import Session, select
from app.database import get_session
from app.models import Session as SessionModel
from app.models import User
# Password hashing with bcrypt directly
def hash_password(password: str) -> str:
password_bytes = password.encode("utf-8")
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
password_bytes = plain_password.encode("utf-8")
hashed_bytes = hashed_password.encode("utf-8")
return bcrypt.checkpw(password_bytes, hashed_bytes)
# Session management
def create_session(
user_id: int, request: Request, db: Session, expires_in_days: int = 30
) -> str:
session_id = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
db_session = SessionModel(
session_id=session_id,
user_id=user_id,
expires_at=expires_at,
ip_address=request.client.host,
user_agent=request.headers.get("user-agent"),
)
db.add(db_session)
db.commit()
return session_id
def get_session_user(session_id: Optional[str], db: Session) -> Optional[User]:
if not session_id:
return None
session = db.exec(
select(SessionModel).where(SessionModel.session_id == session_id)
).first()
if not session or session.expires_at < datetime.utcnow():
return None
return session.user
# Dependency for protected routes
async def require_auth(
session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session)
) -> User:
user = get_session_user(session_id, db)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
)
return user

View file

@ -2,7 +2,7 @@ from fastapi import FastAPI # type: ignore
from fastapi.middleware.cors import CORSMiddleware # type:ignore from fastapi.middleware.cors import CORSMiddleware # type:ignore
from app.database import create_db_and_tables from app.database import create_db_and_tables
from app.routes import folders, notes from app.routes import auth, folders, notes
app = FastAPI(title="Notes API") app = FastAPI(title="Notes API")
@ -23,6 +23,7 @@ def on_startup():
app.include_router(notes.router, prefix="/api") app.include_router(notes.router, prefix="/api")
app.include_router(folders.router, prefix="/api") app.include_router(folders.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
@app.get("/") @app.get("/")

View file

@ -4,11 +4,39 @@ from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel # type: ignore from sqlmodel import Field, Relationship, SQLModel # type: ignore
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True)
email: str = Field(unique=True, index=True)
hashed_password: str
salt: str
wrapped_master_key: str
created_at: datetime = Field(default_factory=datetime.utcnow)
# Add relationships to existing models
notes: List["Note"] = Relationship(back_populates="user")
folders: List["Folder"] = Relationship(back_populates="user")
sessions: List["Session"] = Relationship(back_populates="user")
class Session(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
session_id: str = Field(unique=True, index=True)
user_id: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: datetime
ip_address: Optional[str] = None
user_agent: Optional[str] = None
user: User = Relationship(back_populates="sessions")
class Folder(SQLModel, table=True): # type: ignore class Folder(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(max_length=255) name: str = Field(max_length=255)
parent_id: Optional[int] = Field(default=None, foreign_key="folder.id") parent_id: Optional[int] = Field(default=None, foreign_key="folder.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
user_id: int = Field(foreign_key="user.id")
# Relationships # Relationships
parent: Optional["Folder"] = Relationship( parent: Optional["Folder"] = Relationship(
@ -16,6 +44,7 @@ class Folder(SQLModel, table=True): # type: ignore
) )
children: List["Folder"] = Relationship(back_populates="parent") children: List["Folder"] = Relationship(back_populates="parent")
notes: List["Note"] = Relationship(back_populates="folder") notes: List["Note"] = Relationship(back_populates="folder")
user: User = Relationship(back_populates="folders")
class Note(SQLModel, table=True): # type: ignore class Note(SQLModel, table=True): # type: ignore
@ -25,8 +54,10 @@ class Note(SQLModel, table=True): # type: ignore
folder_id: Optional[int] = Field(default=None, foreign_key="folder.id") folder_id: Optional[int] = Field(default=None, foreign_key="folder.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow)
user_id: int = Field(foreign_key="user.id")
folder: Optional[Folder] = Relationship(back_populates="notes") folder: Optional[Folder] = Relationship(back_populates="notes")
user: User = Relationship(back_populates="notes")
# API Response models (what gets sent to frontend) # API Response models (what gets sent to frontend)

Binary file not shown.

166
backend/app/routes/auth.py Normal file
View file

@ -0,0 +1,166 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
from sqlmodel import Session, SQLModel, select
from app.auth import create_session, hash_password, require_auth, verify_password
from app.database import get_session
from app.models import Session as SessionModel
from app.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
# Request/Response models
class RegisterRequest(SQLModel):
username: str
email: str
password: str
salt: str
wrappedMasterKey: str
class LoginRequest(SQLModel):
username: str
password: str
class UserResponse(SQLModel):
id: int
username: str
email: str
salt: str # Client needs this for key derivation
wrapped_master_key: str # Client needs this to unwrap the master key
@router.post("/register")
def register(
data: RegisterRequest,
request: Request,
response: Response,
db: Session = Depends(get_session),
):
# Check existing user
existing = db.exec(
select(User).where(
(User.username == data.username) | (User.email == data.email)
)
).first()
if existing:
raise HTTPException(status_code=400, detail="User already exists")
# Create user
user = User(
username=data.username,
email=data.email,
hashed_password=hash_password(data.password),
salt=data.salt,
wrapped_master_key=data.wrappedMasterKey,
)
db.add(user)
db.commit()
db.refresh(user)
# Create session
assert user.id is not None
session_id = create_session(user.id, request, db)
# Set cookie
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
secure=True, # HTTPS only in production
samesite="lax",
max_age=30 * 24 * 60 * 60, # 30 days
)
return {"user": UserResponse.model_validate(user)}
@router.post("/login")
def login(
data: LoginRequest,
request: Request,
response: Response,
db: Session = Depends(get_session),
):
# Find user
user = db.exec(select(User).where(User.username == data.username)).first()
if not user or not verify_password(data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
# Create session
assert user.id is not None
session_id = create_session(user.id, request, db)
# Set cookie
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
secure=True,
samesite="lax",
max_age=30 * 24 * 60 * 60,
)
return {"user": UserResponse.model_validate(user)}
@router.post("/logout")
def logout(
response: Response,
session_id: Optional[str] = Cookie(None),
db: Session = Depends(get_session),
):
# Delete session from database
if session_id:
session = db.exec(
select(SessionModel).where(SessionModel.session_id == session_id)
).first()
if session:
db.delete(session)
db.commit()
# Clear cookie
response.delete_cookie("session_id")
return {"message": "Logged out"}
@router.get("/me")
def get_current_user(current_user: User = Depends(require_auth)):
return {"user": UserResponse.from_orm(current_user)}
@router.get("/sessions")
def list_sessions(
current_user: User = Depends(require_auth), db: Session = Depends(get_session)
):
sessions = db.exec(
select(SessionModel)
.where(SessionModel.user_id == current_user.id)
.where(SessionModel.expires_at > datetime.utcnow())
).all()
return {"sessions": sessions}
@router.delete("/sessions/{session_token}") # Renamed from session_id
def revoke_session(
session_token: str, # Renamed to avoid conflict with Cookie parameter
current_user: User = Depends(require_auth),
db: Session = Depends(get_session),
):
session = db.exec(
select(SessionModel)
.where(SessionModel.session_id == session_token) # Use renamed variable
.where(SessionModel.user_id == current_user.id)
).first()
if session:
db.delete(session)
db.commit()
return {"message": "Session revoked"}

View file

@ -3,6 +3,7 @@ from typing import List
from fastapi import APIRouter, Depends, HTTPException # type: ignore from fastapi import APIRouter, Depends, HTTPException # type: ignore
from sqlmodel import Session, select # type: ignore from sqlmodel import Session, select # type: ignore
from app.auth import require_auth
from app.database import get_session from app.database import get_session
from app.models import ( from app.models import (
Folder, Folder,
@ -12,6 +13,7 @@ from app.models import (
FolderUpdate, FolderUpdate,
Note, Note,
NoteRead, NoteRead,
User,
) )
router = APIRouter(prefix="/folders", tags=["folders"]) router = APIRouter(prefix="/folders", tags=["folders"])
@ -20,7 +22,7 @@ router = APIRouter(prefix="/folders", tags=["folders"])
def build_folder_tree_node(folder: Folder) -> FolderTreeNode: def build_folder_tree_node(folder: Folder) -> FolderTreeNode:
"""Recursively build a folder tree node with notes and children""" """Recursively build a folder tree node with notes and children"""
return FolderTreeNode( return FolderTreeNode(
id=folder.id, id=folder.id, # pyright: ignore[reportArgumentType]
name=folder.name, name=folder.name,
notes=[NoteRead.model_validate(note) for note in folder.notes], notes=[NoteRead.model_validate(note) for note in folder.notes],
children=[build_folder_tree_node(child) for child in folder.children], children=[build_folder_tree_node(child) for child in folder.children],
@ -28,16 +30,24 @@ def build_folder_tree_node(folder: Folder) -> FolderTreeNode:
@router.get("/tree", response_model=FolderTreeResponse) @router.get("/tree", response_model=FolderTreeResponse)
def get_folder_tree(session: Session = Depends(get_session)): def get_folder_tree(
current_user: User = Depends(require_auth), session: Session = Depends(get_session)
):
"""Get complete folder tree with notes""" """Get complete folder tree with notes"""
# Get all top-level folders (parent_id is None) # Get all top-level folders (parent_id is None) for current user
top_level_folders = session.exec( top_level_folders = session.exec(
select(Folder).where(Folder.parent_id == None) select(Folder)
.where(Folder.parent_id == None)
.where(Folder.user_id == current_user.id)
).all() ).all()
# Get all orphaned notes (folder_id is None) # Get all orphaned notes (folder_id is None) for current user
orphaned_notes = session.exec(select(Note).where(Note.folder_id == None)).all() orphaned_notes = session.exec(
select(Note)
.where(Note.folder_id == None)
.where(Note.user_id == current_user.id)
).all()
# Build tree recursively # Build tree recursively
tree = [build_folder_tree_node(folder) for folder in top_level_folders] tree = [build_folder_tree_node(folder) for folder in top_level_folders]
@ -56,9 +66,15 @@ def list_folders(session: Session = Depends(get_session)):
@router.post("/", response_model=Folder) @router.post("/", response_model=Folder)
def create_folder(folder: FolderCreate, session: Session = Depends(get_session)): def create_folder(
folder: FolderCreate,
current_user: User = Depends(require_auth),
session: Session = Depends(get_session),
):
"""Create a new folder""" """Create a new folder"""
db_folder = Folder.model_validate(folder) folder_data = folder.model_dump()
folder_data["user_id"] = current_user.id
db_folder = Folder.model_validate(folder_data)
session.add(db_folder) session.add(db_folder)
session.commit() session.commit()
session.refresh(db_folder) session.refresh(db_folder)

View file

@ -1,22 +1,30 @@
from datetime import datetime from datetime import datetime
from app.database import get_session
from app.models import Note, NoteCreate, NoteUpdate
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select from sqlmodel import Session, select
from app.auth import require_auth
from app.database import get_session
from app.models import Note, NoteCreate, NoteUpdate, User
router = APIRouter(prefix="/notes", tags=["notes"]) router = APIRouter(prefix="/notes", tags=["notes"])
@router.get("/") @router.get("/")
def list_notes(session: Session = Depends(get_session)): def list_notes(session: Session = Depends(get_session)):
notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() # pyright: ignore[reportAttributeAccessIssue]
return notes return notes
@router.post("/", response_model=Note) @router.post("/", response_model=Note)
def create_note(note: NoteCreate, session: Session = Depends(get_session)): def create_note(
db_note = Note.model_validate(note) note: NoteCreate,
current_user: User = Depends(require_auth),
session: Session = Depends(get_session),
):
note_data = note.model_dump()
note_data["user_id"] = current_user.id
db_note = Note.model_validate(note_data)
session.add(db_note) session.add(db_note)
session.commit() session.commit()
session.refresh(db_note) session.refresh(db_note)

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,3 +0,0 @@
{
"reportGeneralTypeIssues": "warning"
}

File diff suppressed because it is too large Load diff

View file

@ -14,12 +14,17 @@
"@mdxeditor/editor": "^3.49.3", "@mdxeditor/editor": "^3.49.3",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tiptap/extension-placeholder": "^3.12.1",
"@tiptap/react": "^3.12.1",
"@tiptap/starter-kit": "^3.12.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"framer-motion": "^12.23.25",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.9.6", "react-router-dom": "^7.9.6",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tiptap-markdown": "^0.9.0",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,9 +1,24 @@
// src/App.tsx // src/App.tsx
import { useEffect } from "react";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom"; import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import Home from "./pages/Home"; // existing home page import Home from "./pages/Home"; // existing home page
import { MarkdownPage } from "./pages/Markdown";
import { Import } from "./pages/Import"; import { Import } from "./pages/Import";
const App = () => ( import { Login } from "./pages/Login";
import { Register } from "./pages/Register";
import { Test } from "./pages/Test";
import { useAuthStore } from "./stores/authStore";
import { ContextMenuProvider } from "./contexts/ContextMenuContext";
import { ContextMenuRenderer } from "./components/contextMenus/ContextMenuRenderer";
const App = () => {
const { checkAuth } = useAuthStore();
useEffect(() => {
checkAuth();
}, []);
return (
<ContextMenuProvider>
<BrowserRouter> <BrowserRouter>
{/* Simple nav you can replace with your own UI later */} {/* Simple nav you can replace with your own UI later */}
{/*<nav style={{ marginBottom: "1rem" }}> {/*<nav style={{ marginBottom: "1rem" }}>
@ -12,10 +27,15 @@ const App = () => (
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/markdown" element={<MarkdownPage />} />
<Route path="/import" element={<Import />} /> <Route path="/import" element={<Import />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/test" element={<Test />} />
</Routes> </Routes>
<ContextMenuRenderer />
</BrowserRouter> </BrowserRouter>
); </ContextMenuProvider>
);
};
export default App; export default App;

View file

@ -1,6 +1,6 @@
import { FolderTreeResponse, FolderTreeNode } from "./folders"; import { FolderTreeResponse, FolderTreeNode } from "./folders";
export async function deriveKey(password: string) { export async function deriveKey(password: string, salt: string) {
const enc = new TextEncoder(); const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
"raw", "raw",
@ -13,13 +13,55 @@ export async function deriveKey(password: string) {
return crypto.subtle.deriveKey( return crypto.subtle.deriveKey(
{ {
name: "PBKDF2", name: "PBKDF2",
salt: enc.encode("your-app-salt"), // Store this somewhere consistent salt: enc.encode(salt),
iterations: 100000, iterations: 100000,
hash: "SHA-256", hash: "SHA-256",
}, },
keyMaterial, keyMaterial,
{ name: "AES-GCM", length: 256 }, { name: "AES-GCM", length: 256 },
false, false,
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
);
}
export async function generateMasterKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
"encrypt",
"decrypt",
]);
}
export async function wrapMasterKey(
masterKey: CryptoKey,
kek: CryptoKey,
): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapped = await crypto.subtle.wrapKey("raw", masterKey, kek, {
name: "AES-GCM",
iv,
});
const combined = new Uint8Array(iv.length + wrapped.byteLength);
combined.set(iv);
combined.set(new Uint8Array(wrapped), iv.length);
return btoa(String.fromCharCode(...combined));
}
export async function unwrapMasterKey(
wrappedKey: string,
kek: CryptoKey,
): Promise<CryptoKey> {
const combined = Uint8Array.from(atob(wrappedKey), (c) => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const wrapped = combined.slice(12);
return crypto.subtle.unwrapKey(
"raw",
wrapped,
kek,
{ name: "AES-GCM", iv },
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"], ["encrypt", "decrypt"],
); );
} }

View file

@ -1,7 +1,12 @@
import axios from "axios"; import axios from "axios";
import { decryptFolderTree, deriveKey } from "./encryption"; import { decryptFolderTree } from "./encryption";
import { useAuthStore } from "../stores/authStore";
const API_URL = import.meta.env.PROD ? "/api" : "http://localhost:8000/api"; axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD
? "/api"
: "http://localhost:8000/api";
export interface Folder { export interface Folder {
id: number; id: number;
@ -42,11 +47,14 @@ export interface FolderUpdate {
} }
const getFolderTree = async () => { const getFolderTree = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get<FolderTreeResponse>( const { data } = await axios.get<FolderTreeResponse>(
`${API_URL}/folders/tree`, `${API_URL}/folders/tree`,
); );
var key = await deriveKey("Test");
const decryptedFolderTree = await decryptFolderTree(data, key); const decryptedFolderTree = await decryptFolderTree(data, encryptionKey);
return decryptedFolderTree; return decryptedFolderTree;
}; };
@ -67,7 +75,7 @@ export const folderApi = {
tree: () => getFolderTree(), tree: () => getFolderTree(),
list: () => axios.get<Folder[]>(`${API_URL}/folders`), list: () => axios.get<Folder[]>(`${API_URL}/folders`),
create: (folder: FolderCreate) => create: (folder: FolderCreate) =>
axios.post<Folder>(`${API_URL}/folders`, folder), axios.post<Folder>(`${API_URL}/folders/`, folder),
delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`), delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`),
update: (id: number, updateData: FolderUpdate) => update: (id: number, updateData: FolderUpdate) =>
updateFolder(id, updateData), updateFolder(id, updateData),

View file

@ -1,8 +1,11 @@
import axios from "axios"; import axios from "axios";
import { NoteRead } from "./folders"; import { NoteRead } from "./folders";
import { deriveKey, encryptString, decryptString } from "./encryption"; import { encryptString, decryptString } from "./encryption";
import { useAuthStore } from "../stores/authStore";
const API_URL = import.meta.env.PROD ? "/api" : "http://localhost:8000/api"; axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD
? "/api"
: "http://localhost:8000/api";
export interface Note { export interface Note {
id: number; id: number;
@ -20,9 +23,11 @@ export interface NoteCreate {
} }
const createNote = async (note: NoteCreate) => { const createNote = async (note: NoteCreate) => {
var key = await deriveKey("Test"); const encryptionKey = useAuthStore.getState().encryptionKey;
var noteContent = await encryptString(note.content, key); if (!encryptionKey) throw new Error("Not authenticated");
var noteTitle = await encryptString(note.title, key);
var noteContent = await encryptString(note.content, encryptionKey);
var noteTitle = await encryptString(note.title, encryptionKey);
var encryptedNote = { var encryptedNote = {
title: noteTitle, title: noteTitle,
@ -31,19 +36,20 @@ const createNote = async (note: NoteCreate) => {
}; };
console.log(encryptedNote); console.log(encryptedNote);
return axios.post(`${API_URL}/notes`, encryptedNote); return axios.post(`${API_URL}/notes/`, encryptedNote);
}; };
const fetchNotes = async () => { const fetchNotes = async () => {
const { data } = await axios.get(`${API_URL}/notes`); const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get(`${API_URL}/notes/`);
console.log(data); console.log(data);
var key = await deriveKey("Test");
const decryptedNotes = await Promise.all( const decryptedNotes = await Promise.all(
data.map(async (note: Note) => ({ data.map(async (note: Note) => ({
...note, ...note,
title: await decryptString(note.title, key), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, key), content: await decryptString(note.content, encryptionKey),
})), })),
); );
@ -51,13 +57,15 @@ const fetchNotes = async () => {
}; };
const updateNote = async (id: number, note: Partial<Note>) => { const updateNote = async (id: number, note: Partial<Note>) => {
var key = await deriveKey("Test"); const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
var encryptedNote: Partial<Note> = {}; var encryptedNote: Partial<Note> = {};
if (note.content) { if (note.content) {
encryptedNote.content = await encryptString(note.content, key); encryptedNote.content = await encryptString(note.content, encryptionKey);
} }
if (note.title) { if (note.title) {
encryptedNote.title = await encryptString(note.title, key); encryptedNote.title = await encryptString(note.title, encryptionKey);
} }
if (note.folder_id) { if (note.folder_id) {
encryptedNote.folder_id = note.folder_id; encryptedNote.folder_id = note.folder_id;

View file

@ -0,0 +1,31 @@
import React, { useEffect } from "react";
import { useContextMenu } from "../../contexts/ContextMenuContext";
import { NoteContextMenu } from "./NoteContextMenu";
import { FolderContextMenu } from "./FolderContextMenu";
export const ContextMenuRenderer: React.FC = () => {
const { contextMenu, closeContextMenu } = useContextMenu();
if (!contextMenu) return null;
return (
<>
{contextMenu.type === "note" && (
<NoteContextMenu
x={contextMenu.x}
y={contextMenu.y}
note={contextMenu.data}
onClose={closeContextMenu}
/>
)}
{contextMenu.type === "folder" && (
<FolderContextMenu
x={contextMenu.x}
y={contextMenu.y}
folder={contextMenu.data}
onClose={closeContextMenu}
/>
)}
</>
);
};

View file

@ -0,0 +1,123 @@
import React, { useState } from "react";
import { FolderTreeNode } from "../../api/folders";
import { useNoteStore } from "../../stores/notesStore";
import { folderApi } from "../../api/folders";
interface FolderContextMenuProps {
x: number;
y: number;
folder: FolderTreeNode;
onClose: () => void;
}
export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
x,
y,
folder,
onClose,
}) => {
const { loadFolderTree } = useNoteStore();
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState(folder.name);
const handleDelete = async () => {
if (!confirm(`Delete "${folder.name}" and all its contents?`)) {
return;
}
try {
await folderApi.delete(folder.id);
await loadFolderTree();
onClose();
} catch (error) {
console.error("Failed to delete folder:", error);
}
};
const handleRename = async () => {
if (newName.trim() && newName !== folder.name) {
try {
await folderApi.update(folder.id, { name: newName.trim() });
await loadFolderTree();
} catch (error) {
console.error("Failed to rename folder:", error);
}
}
setIsRenaming(false);
onClose();
};
const handleCreateSubfolder = async () => {
try {
await folderApi.create({
name: "New Folder",
parent_id: folder.id,
});
await loadFolderTree();
onClose();
} catch (error) {
console.error("Failed to create subfolder:", error);
}
};
if (isRenaming) {
return (
<div
style={{
position: "fixed",
top: y,
left: x,
}}
className="bg-ctp-surface0 border border-ctp-surface2 rounded-md shadow-lg p-2 min-w-[200px] z-50"
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename();
if (e.key === "Escape") {
setIsRenaming(false);
onClose();
}
}}
onBlur={handleRename}
autoFocus
className="w-full px-2 py-1 bg-ctp-surface1 border border-ctp-surface2 rounded text-sm text-ctp-text focus:outline-none focus:border-ctp-mauve"
/>
</div>
);
}
return (
<div
style={{
position: "fixed",
top: y,
left: x,
}}
className="bg-ctp-surface0 border border-ctp-surface2 rounded-md shadow-lg py-1 min-w-[160px] z-50"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setIsRenaming(true)}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors"
>
Rename
</button>
<button
onClick={handleCreateSubfolder}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors"
>
New Subfolder
</button>
<div className="border-t border-ctp-surface2 my-1" />
<button
onClick={handleDelete}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-red hover:text-ctp-base text-sm text-ctp-red transition-colors"
>
Delete
</button>
</div>
);
};

View file

@ -0,0 +1,82 @@
import React from "react";
import { NoteRead } from "../../api/folders";
import { useNoteStore } from "../../stores/notesStore";
import { notesApi } from "../../api/notes";
interface NoteContextMenuProps {
x: number;
y: number;
note: NoteRead;
onClose: () => void;
}
export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
x,
y,
note,
onClose,
}) => {
const { loadFolderTree, setSelectedNote } = useNoteStore();
const handleDelete = async () => {
try {
await notesApi.delete(note.id);
await loadFolderTree();
onClose();
} catch (error) {
console.error("Failed to delete note:", error);
}
};
const handleDuplicate = async () => {
try {
await notesApi.create({
title: `${note.title} (Copy)`,
content: note.content,
folder_id: note.folder_id,
});
await loadFolderTree();
onClose();
} catch (error) {
console.error("Failed to duplicate note:", error);
}
};
const handleRename = () => {
setSelectedNote(note);
onClose();
// Focus will be handled by the editor
};
return (
<div
style={{
position: "fixed",
top: y,
left: x,
}}
className="bg-ctp-surface0 border border-ctp-surface2 rounded-md shadow-lg py-1 min-w-[160px] z-50"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleRename}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors"
>
Rename
</button>
<button
onClick={handleDuplicate}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-surface1 text-sm text-ctp-text transition-colors"
>
Duplicate
</button>
<div className="border-t border-ctp-surface2 my-1" />
<button
onClick={handleDelete}
className="w-full text-left px-3 py-1.5 hover:bg-ctp-red hover:text-ctp-base text-sm text-ctp-red transition-colors"
>
Delete
</button>
</div>
);
};

View file

@ -1,119 +0,0 @@
import {
BoldItalicUnderlineToggles,
codeBlockPlugin,
codeMirrorPlugin,
diffSourcePlugin,
headingsPlugin,
imagePlugin,
linkPlugin,
listsPlugin,
markdownShortcutPlugin,
MDXEditor,
quotePlugin,
SandpackConfig,
sandpackPlugin,
tablePlugin,
thematicBreakPlugin,
toolbarPlugin,
UndoRedo,
} from "@mdxeditor/editor";
import { useNoteStore } from "../../stores/notesStore";
import { useEffect } from "react";
import { useUIStore } from "../../stores/uiStore";
const simpleSandpackConfig: SandpackConfig = {
defaultPreset: "react",
presets: [
{
label: "React",
name: "react",
meta: "live react",
sandpackTemplate: "react",
sandpackTheme: "dark",
snippetFileName: "/App.js",
snippetLanguage: "jsx",
},
],
};
export const Editor = () => {
const { selectedNote, setContent, setTitle, updateNote } = useNoteStore();
const { updating, setUpdating } = useUIStore();
useEffect(() => {
if (!selectedNote) return;
const timer = setTimeout(async () => {
setUpdating(true);
handleUpdate();
}, 2000);
return () => clearTimeout(timer);
}, [selectedNote]);
const handleUpdate = async () => {
if (!selectedNote) return;
await updateNote(selectedNote.id);
console.log(selectedNote.id);
setTimeout(() => {
setUpdating(false);
}, 1000);
};
return (
<div className="flex-1 flex flex-col overflow-y-auto px-8 py-6">
{/* Title input */}
<input
type="text"
placeholder="Untitled note..."
value={selectedNote?.title || ""}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-0 py-3 mb-4 text-3xl font-semibold bg-transparent border-b border-ctp-surface2 focus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text"
/>
<div className="flex-1">
<MDXEditor
markdown={selectedNote?.content || ""}
key={selectedNote?.id || "new"}
onChange={setContent}
className="prose prose-invert max-w-none text-ctp-text h-full dark-editor dark-mode"
plugins={[
headingsPlugin(),
toolbarPlugin({
toolbarClassName: "toolbar",
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
</>
),
}),
tablePlugin(),
listsPlugin(),
quotePlugin(),
thematicBreakPlugin(),
linkPlugin(),
codeBlockPlugin({ defaultCodeBlockLanguage: "js" }),
sandpackPlugin({ sandpackConfig: simpleSandpackConfig }),
codeMirrorPlugin({
codeBlockLanguages: {
js: "JavaScript",
css: "CSS",
python: "Python",
typescript: "TypeScript",
html: "HTML",
},
}),
imagePlugin(),
markdownShortcutPlugin(),
diffSourcePlugin({
viewMode: "rich-text",
diffMarkdown: "boo",
}),
]}
/>
</div>
</div>
);
};

View file

@ -1,28 +1,44 @@
import React from "react"; import React from "react";
import { useDraggable } from "@dnd-kit/core"; import { useDraggable } from "@dnd-kit/core";
import { Note } from "../../api/notes";
import { NoteRead } from "../../api/folders"; import { NoteRead } from "../../api/folders";
import { useNoteStore } from "../../stores/notesStore"; import { useNoteStore } from "../../stores/notesStore";
import { useContextMenu } from "../../contexts/ContextMenuContext";
export const DraggableNote = ({ note }: { note: NoteRead }) => { export const DraggableNote = ({ note }: { note: NoteRead }) => {
const { selectedNote, setSelectedNote } = useNoteStore(); const { selectedNote, setSelectedNote } = useNoteStore();
const { openContextMenu } = useContextMenu();
const { attributes, listeners, setNodeRef, transform } = useDraggable({ const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: note.id, id: note.id,
data: { type: "note", note }, data: { type: "note", note },
}); });
const style = transform
? { const style = {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, transform: transform
} ? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined; : undefined,
opacity: isDragging ? 0 : 1,
};
return ( return (
<button ref={setNodeRef} style={style} {...listeners} {...attributes}> <button
className="z-20"
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, "note", note);
}}
>
<div <div
key={note.id} key={note.id}
onClick={() => setSelectedNote(note)} onClick={(e) => {
className={` rounded-md px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${ setSelectedNote(note);
}}
className={` rounded-sm px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${
selectedNote?.id === note.id selectedNote?.id === note.id
? "bg-ctp-mauve text-ctp-base" ? "bg-ctp-mauve text-ctp-base"
: "hover:bg-ctp-surface1" : "hover:bg-ctp-surface1"

View file

@ -1,7 +1,11 @@
import React from "react"; import React from "react";
import { useDroppable, useDraggable } from "@dnd-kit/core"; import { useDroppable, useDraggable } from "@dnd-kit/core";
import { Folder, NoteRead } from "../../api/folders"; import { Folder } from "../../api/folders";
import { useNoteStore } from "../../stores/notesStore"; import { useContextMenu } from "../../contexts/ContextMenuContext";
// @ts-ignore
import CaretRightIcon from "../../assets/fontawesome/svg/caret-right.svg?react";
// @ts-ignore
import FolderIcon from "../../assets/fontawesome/svg/folder.svg?react";
export const DroppableFolder = ({ export const DroppableFolder = ({
folder, folder,
@ -12,7 +16,7 @@ export const DroppableFolder = ({
setCollapse: React.Dispatch<React.SetStateAction<boolean>>; setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
collapse: boolean; collapse: boolean;
}) => { }) => {
const { setSelectedFolder, selectedFolder, selectedNote } = useNoteStore(); const { openContextMenu } = useContextMenu();
const { isOver, setNodeRef: setDroppableRef } = useDroppable({ const { isOver, setNodeRef: setDroppableRef } = useDroppable({
id: folder.id!, id: folder.id!,
@ -37,7 +41,7 @@ export const DroppableFolder = ({
const style = { const style = {
color: isOver ? "green" : undefined, color: isOver ? "green" : undefined,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0 : 1,
transform: transform transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)` ? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined, : undefined,
@ -46,27 +50,24 @@ export const DroppableFolder = ({
return ( return (
<div ref={setNodeRef} style={style}> <div ref={setNodeRef} style={style}>
<div <div
onClick={() => setSelectedFolder(folder.id as number)} onClick={(e) => {
className={`font-semibold mb-1 flex items-center gap-1 px-2 py-1 rounded cursor-pointer ${ e.stopPropagation();
selectedFolder === folder.id && setCollapse(!collapse);
(selectedNote?.folder_id == folder.id || selectedNote == null) }}
? "bg-ctp-surface1" onContextMenu={(e) => {
: "hover:bg-ctp-surface0" e.preventDefault();
}`} e.stopPropagation();
openContextMenu(e.clientX, e.clientY, "folder", folder);
}}
className={`font-semibold mb-1 flex items-center gap-1 pr-1 py-1 rounded cursor-pointer select-none`}
{...listeners} {...listeners}
{...attributes} {...attributes}
> >
<i className="fadr fa-folder text-sm"></i> <CaretRightIcon
className={`w-4 h-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-ctp-mauve`}
/>
<FolderIcon className="w-4 h-4 fill-ctp-mauve mr-1" />
{folder.name} {folder.name}
<div
onClick={(e) => {
e.stopPropagation(); // Prevent dragging when clicking the collapse button
setCollapse(!collapse);
}}
className="ml-auto"
>
x
</div>
</div> </div>
</div> </div>
); );

View file

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { FolderTreeNode } from "../../api/folders"; import { motion, AnimatePresence } from "framer-motion";
import { FolderTreeNode, NoteRead } from "../../api/folders";
import { DraggableNote } from "./DraggableNote"; import { DraggableNote } from "./DraggableNote";
import { DroppableFolder } from "./DroppableFolder"; import { DroppableFolder } from "./DroppableFolder";
@ -15,28 +16,42 @@ export const RecursiveFolder = ({
const [collapse, setCollapse] = useState(false); const [collapse, setCollapse] = useState(false);
return ( return (
<div <div key={folder.id} className="flex flex-col relative">
key={folder.id}
className="flex flex-col"
style={{ marginLeft: depth > 0 ? "1.5rem" : "0" }}
>
<DroppableFolder <DroppableFolder
folder={folder} folder={folder}
setCollapse={setCollapse} setCollapse={setCollapse}
collapse={collapse} collapse={collapse}
/> />
<AnimatePresence>
{collapse && ( {collapse && (
<> <motion.div
<div className="flex flex-col gap-0.5 ml-6"> initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden flex flex-col"
>
{/* The line container */}
<div className="ml-2 pl-3 border-l border-ctp-surface2">
{/* Notes */}
<div className="flex flex-col gap-0.5">
{folder.notes.map((note) => ( {folder.notes.map((note) => (
<DraggableNote key={note.id} note={note} /> <DraggableNote key={note.id} note={note} />
))} ))}
</div> </div>
{/* Child Folders */}
{folder.children.map((child) => ( {folder.children.map((child) => (
<RecursiveFolder key={child.id} folder={child} depth={depth + 1} /> <RecursiveFolder
key={child.id}
folder={child}
depth={depth + 1}
/>
))} ))}
</> </div>
</motion.div>
)} )}
</AnimatePresence>
</div> </div>
); );
}; };

View file

@ -1,39 +1,47 @@
import React, { useState, useRef, useEffect, SetStateAction } from "react"; import React, { useState, useRef, useEffect, SetStateAction } from "react";
import { // @ts-ignore
FolderCreate, import FolderPlusIcon from "../../assets/fontawesome/svg/folder-plus.svg?react";
FolderTreeNode, // @ts-ignore
FolderTreeResponse, import FileCirclePlusIcon from "../../assets/fontawesome/svg/file-circle-plus.svg?react";
NoteRead, // @ts-ignore
folderApi, import FolderIcon from "../../assets/fontawesome/svg/folder.svg?react";
} from "../../api/folders";
import { DraggableNote } from "./DraggableNote"; import { DraggableNote } from "./DraggableNote";
import { DroppableFolder } from "./DroppableFolder";
import { useNoteStore } from "../../stores/notesStore"; import { useNoteStore } from "../../stores/notesStore";
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { notesApi } from "../../api/notes";
import { RecursiveFolder } from "./RecursiveFolder"; import { RecursiveFolder } from "./RecursiveFolder";
import { useAuthStore } from "../../stores/authStore";
import { useUIStore } from "../../stores/uiStore";
import { NoteRead } from "../../api/folders";
export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => { export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
// const [folderTree, setFolderTree] = useState<FolderTreeResponse | null>(null);
const [newFolder, setNewFolder] = useState(false); const [newFolder, setNewFolder] = useState(false);
const [newFolderText, setNewFolderText] = useState(""); const [newFolderText, setNewFolderText] = useState("");
const [activeItem, setActiveItem] = useState<{
type: "note" | "folder";
data: any;
} | null>(null);
const newFolderRef = useRef<HTMLInputElement>(null); const newFolderRef = useRef<HTMLInputElement>(null);
const { const {
setSelectedFolder,
selectedFolder,
folderTree, folderTree,
loadFolderTree, loadFolderTree,
selectedNote, moveNoteToFolder,
setSelectedNote, moveFolderToFolder,
createFolder,
} = useNoteStore(); } = useNoteStore();
const { isAuthenticated } = useAuthStore();
const { setSideBarResize, sideBarResize } = useUIStore();
useEffect(() => { useEffect(() => {
if (newFolder && newFolderRef.current) { if (newFolder && newFolderRef.current) {
newFolderRef.current.focus(); newFolderRef.current.focus();
@ -41,18 +49,17 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
}, [newFolder]); }, [newFolder]);
useEffect(() => { useEffect(() => {
// if (!isAuthenticated) return;
loadFolderTree(); loadFolderTree();
}, []); }, []);
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (!newFolderText.trim()) return; if (!newFolderText.trim()) return;
const newFolderData: FolderCreate = { await createFolder({
name: newFolderText, name: newFolderText,
parent_id: null, parent_id: null,
}; });
await folderApi.create(newFolderData);
setNewFolderText(""); setNewFolderText("");
loadFolderTree();
setNewFolder(false); setNewFolder(false);
}; };
@ -63,7 +70,17 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
}); });
const sensors = useSensors(pointer); const sensors = useSensors(pointer);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
if (active.data.current?.type === "note") {
setActiveItem({ type: "note", data: active.data.current.note });
} else if (active.data.current?.type === "folder") {
setActiveItem({ type: "folder", data: active.data.current.folder });
}
};
const handleDragEnd = async (event: DragEndEvent) => { const handleDragEnd = async (event: DragEndEvent) => {
setActiveItem(null);
const { active, over } = event; const { active, over } = event;
if (!over) return; if (!over) return;
@ -77,9 +94,7 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
if (active.data.current?.type === "note") { if (active.data.current?.type === "note") {
console.log("Updating note", active.id, "to folder", over.id); console.log("Updating note", active.id, "to folder", over.id);
await notesApi.update(active.id as number, { await moveNoteToFolder(active.id as number, over.id as number);
folder_id: over.id as number,
});
} else if (active.data.current?.type === "folder") { } else if (active.data.current?.type === "folder") {
// Prevent dropping folder into itself // Prevent dropping folder into itself
if (active.data.current.folder.id === over.id) { if (active.data.current.folder.id === over.id) {
@ -94,30 +109,76 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
over.id, over.id,
); );
try { try {
const response = await folderApi.update(active.data.current.folder.id, { await moveFolderToFolder(
parent_id: over.id as number, active.data.current.folder.id,
}); over.id as number,
console.log("Folder update response:", response); );
} catch (error) { } catch (error) {
console.error("Failed to update folder:", error); console.error("Failed to update folder:", error);
return; return;
} }
} }
loadFolderTree();
}; };
const [isResizing, setIsResizing] = useState(false);
const handleMouseDown = (e: React.MouseEvent) => {
setIsResizing(true);
e.preventDefault();
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
// Calculate new width based on mouse position from the left edge
const newWidth = e.clientX;
if (newWidth >= 200 && newWidth <= 500) {
setSideBarResize(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing]);
return ( return (
<DndContext onDragEnd={handleDragEnd} autoScroll={false} sensors={sensors}> <DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
autoScroll={false}
sensors={sensors}
>
<div className="flex-row-reverse flex">
<div <div
className="bg-ctp-mantle border-r border-ctp-surface2 w-[300px] p-4 overflow-y-auto sm:block hidden flex-col gap-3" className="h-screen bg-ctp-surface0 w-0.5 hover:cursor-ew-resize hover:bg-ctp-mauve transition-colors"
onDragOver={(e) => e.preventDefault()} onMouseDown={handleMouseDown}
onTouchMove={(e) => e.preventDefault()} ></div>
<div
className="flex flex-col min-h-full"
style={{ width: `${sideBarResize}px` }}
> >
<SidebarHeader <SidebarHeader
clearSelection={clearSelection} clearSelection={clearSelection}
setNewFolder={setNewFolder} setNewFolder={setNewFolder}
/> />
<div
className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3"
onDragOver={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()}
>
{/* New folder input */} {/* New folder input */}
{newFolder && ( {newFolder && (
<div className="mb-2"> <div className="mb-2">
@ -127,7 +188,7 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
value={newFolderText} value={newFolderText}
type="text" type="text"
placeholder="Folder name..." placeholder="Folder name..."
className="border border-ctp-mauve rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-ctp-mauve bg-ctp-base text-ctp-text placeholder:text-ctp-overlay0" className="standard-input"
ref={newFolderRef} ref={newFolderRef}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
@ -149,17 +210,31 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
</div> </div>
{/* Orphaned notes */} {/* Orphaned notes */}
{folderTree?.orphaned_notes && folderTree.orphaned_notes.length > 0 && ( {folderTree?.orphaned_notes &&
folderTree.orphaned_notes.length > 0 && (
<div className="mt-4 flex flex-col gap-1"> <div className="mt-4 flex flex-col gap-1">
{/*<div className="text-ctp-subtext0 text-sm font-medium mb-1 px-2">
Unsorted
</div>*/}
{folderTree.orphaned_notes.map((note) => ( {folderTree.orphaned_notes.map((note) => (
<DraggableNote key={note.id} note={note} /> <DraggableNote key={note.id} note={note} />
))} ))}
</div> </div>
)} )}
</div> </div>
<DragOverlay>
{activeItem?.type === "note" && (
<div className="bg-ctp-surface0 rounded-md px-2 py-1 shadow-lg border border-ctp-mauve">
{activeItem.data.title}
</div>
)}
{activeItem?.type === "folder" && (
<div className="bg-ctp-surface0 rounded-md px-1 py-0.5 shadow-lg flex items-center gap-1 text-sm">
<FolderIcon className="w-3 h-3 fill-ctp-mauve mr-1" />
{activeItem.data.name}
</div>
)}
</DragOverlay>
</div>
</div>
</DndContext> </DndContext>
); );
}; };
@ -171,25 +246,30 @@ export const SidebarHeader = ({
clearSelection: () => void; clearSelection: () => void;
setNewFolder: React.Dispatch<SetStateAction<boolean>>; setNewFolder: React.Dispatch<SetStateAction<boolean>>;
}) => { }) => {
const { createNote, selectedFolder } = useNoteStore();
const handleCreate = async () => {
await createNote({
title: "Untitled",
content: "",
folder_id: selectedFolder,
});
};
return ( return (
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1">
<h2 className="text-lg font-semibold text-ctp-text">FastNotes</h2>
<div className="flex gap-2">
<button <button
onClick={() => setNewFolder(true)} onClick={() => setNewFolder(true)}
className="hover:bg-ctp-mauve group transition-colors rounded-md p-1.5" className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2"
title="New folder" title="New folder"
> >
<i className="fadr fa-folder-plus text-base text-ctp-mauve group-hover:text-ctp-base transition-colors"></i> <FolderPlusIcon className="w-4 h-4 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button> </button>
<button <button
onClick={clearSelection} onClick={handleCreate}
className="hover:bg-ctp-mauve group transition-colors rounded-md p-1.5" className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2 fill-ctp-mauve hover:fill-ctp-base"
title="New note" title="New note"
> >
<i className="fadr fa-file-circle-plus text-base text-ctp-mauve group-hover:text-ctp-base transition-colors"></i> <FileCirclePlusIcon className="w-4 h-4 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
</button> </button>
</div> </div>
</div>
); );
}; };

View file

@ -0,0 +1,114 @@
import React, { createContext, useContext, useState, useEffect } from "react";
interface ContextMenuState {
x: number;
y: number;
type: "note" | "folder" | "editor" | null;
data: any;
}
interface ContextMenuContextType {
contextMenu: ContextMenuState | null;
openContextMenu: (
x: number,
y: number,
type: "note" | "folder" | "editor",
data: any,
) => void;
closeContextMenu: () => void;
}
const ContextMenuContext = createContext<ContextMenuContextType | null>(null);
export const useContextMenu = () => {
const context = useContext(ContextMenuContext);
if (!context) {
throw new Error("useContextMenu must be used within a ContextMenuProvider");
}
return context;
};
export const ContextMenuProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const openContextMenu = (
x: number,
y: number,
type: "note" | "folder" | "editor",
data: any,
) => {
// Estimate menu height (you can adjust this based on your menu)
const menuHeight = 200;
const menuWidth = 160;
// Adjust y position if too close to bottom
const adjustedY =
y + menuHeight > window.innerHeight
? window.innerHeight - menuHeight - 10
: y;
// Adjust x position if too close to right edge
const adjustedX =
x + menuWidth > window.innerWidth
? window.innerWidth - menuWidth - 10
: x;
setContextMenu({ x: adjustedX, y: adjustedY, type, data });
};
const closeContextMenu = () => {
setContextMenu(null);
};
// Close on click outside
useEffect(() => {
const handleClick = () => {
if (contextMenu) {
closeContextMenu();
}
};
if (contextMenu) {
document.addEventListener("click", handleClick);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("click", handleClick);
document.body.style.overflow = "unset";
};
}, [contextMenu]);
// Close on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && contextMenu) {
closeContextMenu();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [contextMenu]);
return (
<ContextMenuContext.Provider
value={{ contextMenu, openContextMenu, closeContextMenu }}
>
{contextMenu && (
<div
onContextMenu={(e) => {
e.preventDefault();
closeContextMenu();
}}
className=" h-screen w-screen bg-ctp-crust/25 z-40 fixed top-0 left-0"
></div>
)}
{children}
</ContextMenuContext.Provider>
);
};

View file

@ -2,6 +2,46 @@
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@import "@catppuccin/tailwindcss/macchiato.css"; @import "@catppuccin/tailwindcss/macchiato.css";
@theme {
--color-base:
;
}
@theme {
/* Map Tailwind classes to CSS variables */
--color-ctp-base: var(--color-ctp-base);
--color-ctp-mantle: var(--color-ctp-mantle);
--color-ctp-crust: var(--color-ctp-crust);
--color-ctp-text: var(--color-ctp-text);
--color-ctp-subtext0: #a5adcb;
--color-ctp-overlay0: #6e738d;
--color-ctp-mauve: var(--color-ctp-mauve);
--color-ctp-blue: var(--color-ctp-blue);
--color-ctp-green: #a6da95;
--color-ctp-red: #ed8796;
--color-ctp-yellow: #eed49f;
--color-ctp-teal: #8bd5ca;
--color-ctp-sapphire: #7dc4e4;
--color-ctp-peach: #f5a97f;
/* Surface colors */
--color-ctp-surface0: #363a4f;
--color-ctp-surface1: #494d64;
--color-ctp-surface2: #5b6078;
}
/* Default values (Macchiato) - injected by JS, but good as fallback */
:root {
--color-ctp-base: #24273a;
--color-ctp-mantle: #1e2030;
--color-ctp-crust: #181926;
--color-ctp-text: #cad3f5;
--color-ctp-mauve: #c6a0f6;
--color-ctp-blue: #8aadf4;
}
/* Override MDXEditor and all its children */ /* Override MDXEditor and all its children */
[class*="mdxeditor"], [class*="mdxeditor"],
._mdxeditor-root-content-editable, ._mdxeditor-root-content-editable,
@ -10,42 +50,6 @@ div[contenteditable="true"] {
color: var(--color-ctp-text) !important; color: var(--color-ctp-text) !important;
} }
/* Override prose specifically */
.prose,
.prose * {
color: var(--color-ctp-text) !important;
}
/* Override list markers */
.prose ul li::marker,
.prose ol li::marker,
ul li::marker,
ol li::marker {
color: var(--color-ctp-text) !important;
}
.my-class {
background-color: var(--color-ctp-mantle) !important;
border-bottom: 1px solid var(--color-ctp-surface2) !important;
}
.my-class button {
color: var(--color-ctp-text) !important;
background-color: var(--color-ctp-surface0) !important;
}
_listItemChecked_1tncs_73::before {
background-color: var(--color-ctp-mauve);
}
.mdxeditor-popup-container > * {
background-color: var(--color-ctp-base) !important;
}
.toolbar {
background-color: var(--color-ctp-crust) !important;
}
._listItemChecked_1tncs_73::before { ._listItemChecked_1tncs_73::before {
--accentSolid: var(--color-ctp-mauve) !important; --accentSolid: var(--color-ctp-mauve) !important;
border-color: var(--color-ctp-mauve-900) !important; border-color: var(--color-ctp-mauve-900) !important;
@ -55,3 +59,7 @@ _listItemChecked_1tncs_73::before {
._listItemChecked_1tncs_73::after { ._listItemChecked_1tncs_73::after {
border-color: var(--color-ctp-mauve-900) !important; border-color: var(--color-ctp-mauve-900) !important;
} }
.standard-input {
@apply border border-ctp-mauve rounded-sm px-2 py-1 w-full focus:outline-none focus:ring-2 focus:ring-ctp-mauve bg-ctp-base text-ctp-text placeholder:text-ctp-overlay0;
}

View file

@ -2,8 +2,8 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import "./main.css"; import "./main.css";
import "./assets/fontawesome/js/fontawesome.min.js"; // import "./assets/fontawesome/js/fontawesome.min.js";
import "./assets/fontawesome/js/duotone-regular.js"; // import "./assets/fontawesome/js/duotone-regular.js";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>

View file

@ -1,82 +1,47 @@
import { import { useEffect, useRef, useState } from "react";
BoldItalicUnderlineToggles, import { notesApi } from "../api/notes";
codeBlockPlugin,
codeMirrorPlugin,
diffSourcePlugin,
headingsPlugin,
imagePlugin,
linkPlugin,
listsPlugin,
markdownShortcutPlugin,
MDXEditor,
quotePlugin,
SandpackConfig,
sandpackPlugin,
tablePlugin,
thematicBreakPlugin,
toolbarPlugin,
UndoRedo,
DiffSourceToggleWrapper,
} from "@mdxeditor/editor";
import { SetStateAction, useEffect, useRef, useState } from "react";
import {
folderApi,
FolderCreate,
FolderTreeNode,
FolderTreeResponse,
NoteRead,
} from "../api/folders";
import { NoteCreate, notesApi } from "../api/notes";
import "../main.css"; import "../main.css";
import { import { motion } from "framer-motion";
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import "@mdxeditor/editor/style.css"; import "@mdxeditor/editor/style.css";
import { DroppableFolder } from "../components/sidebar/DroppableFolder";
import { DraggableNote } from "../components/sidebar/DraggableNote";
// @ts-ignore // @ts-ignore
import CheckIcon from "../assets/fontawesome/svg/circle-check.svg?react"; import CheckIcon from "../assets/fontawesome/svg/circle-check.svg?react";
// @ts-ignore // @ts-ignore
import SpinnerIcon from "../assets/fontawesome/svg/rotate.svg?react"; import SpinnerIcon from "../assets/fontawesome/svg/rotate.svg?react";
// @ts-ignore
import WarningIcon from "../assets/fontawesome/svg/circle-exclamation.svg?react";
import { useNoteStore } from "../stores/notesStore"; import { useNoteStore } from "../stores/notesStore";
import { create } from "zustand";
import { Sidebar } from "../components/sidebar/SideBar"; import { Sidebar } from "../components/sidebar/SideBar";
import { Editor } from "../components/editor/Editor";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
import { TiptapEditor } from "./TipTap";
import { useAuthStore } from "../stores/authStore";
import { Login } from "./Login";
function Home() { function Home() {
// const [folderTree, setFolderTree] = useState<FolderTreeResponse | null>(null);
// const [selectedNote, setSelectedNote] = useState<NoteRead | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [newFolder, setNewFolder] = useState(false); const [newFolder, setNewFolder] = useState(false);
const [newFolderText, setNewFolderText] = useState(""); const [lastSavedNote, setLastSavedNote] = useState<{
// const [selectedFolder, setSelectedFolder] = useState<number | null>(null); id: number;
const [encrypted, setEncrypted] = useState(false); title: string;
// const [updating, setUpdating] = useState(false); content: string;
} | null>(null);
const { const {
setSelectedFolder,
selectedFolder,
folderTree,
loadFolderTree, loadFolderTree,
createNote,
createFolder,
updateNote, updateNote,
setSelectedNote, setSelectedNote,
setContent,
selectedNote, selectedNote,
setTitle,
} = useNoteStore(); } = useNoteStore();
const { updating } = useUIStore(); const { isAuthenticated, encryptionKey } = useAuthStore();
const { showModal, setShowModal } = useUIStore();
const newFolderRef = useRef<HTMLInputElement>(null); const newFolderRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
// if (!isAuthenticated) return;
console.log(encryptionKey);
loadFolderTree(); loadFolderTree();
}, []); }, []);
@ -86,73 +51,103 @@ function Home() {
} }
}, [newFolder]); }, [newFolder]);
const handleCreate = async () => {
if (!title.trim()) return;
await createNote({ title, content, folder_id: null });
};
const handleDelete = async (id: number) => {
await notesApi.delete(id);
loadFolderTree();
clearSelection();
};
const clearSelection = () => { const clearSelection = () => {
setSelectedNote(null); setSelectedNote(null);
setTitle(""); };
setContent("");
const { updating, setUpdating } = useUIStore();
useEffect(() => {
if (!selectedNote) return;
if (!encryptionKey) return; // Don't try to save without encryption key
// Check if content or title actually changed (not just selecting a different note)
const hasChanges =
lastSavedNote &&
lastSavedNote.id === selectedNote.id &&
(lastSavedNote.title !== selectedNote.title ||
lastSavedNote.content !== selectedNote.content);
// If it's a new note selection, just update lastSavedNote without saving
if (!lastSavedNote || lastSavedNote.id !== selectedNote.id) {
setLastSavedNote({
id: selectedNote.id,
title: selectedNote.title,
content: selectedNote.content,
});
return;
}
if (!hasChanges) return;
const timer = setTimeout(async () => {
setUpdating(true);
await handleUpdate();
setLastSavedNote({
id: selectedNote.id,
title: selectedNote.title,
content: selectedNote.content,
});
}, 2000);
return () => clearTimeout(timer);
}, [selectedNote, encryptionKey]);
const handleUpdate = async () => {
if (!selectedNote) return;
if (!encryptionKey) {
setUpdating(false);
return;
}
try {
await updateNote(selectedNote.id);
console.log(selectedNote.id);
} catch (error) {
console.error("Failed to update note:", error);
} finally {
setTimeout(() => {
setUpdating(false);
}, 1000);
}
}; };
return ( return (
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden"> <div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
{showModal && <Modal />}
<Sidebar clearSelection={clearSelection} /> <Sidebar clearSelection={clearSelection} />
{/* Main editor area */} {/* Main editor area */}
<div className="flex flex-col w-full h-screen overflow-hidden"> <div className="flex flex-col w-full h-screen overflow-hidden">
{/* Top accent bar */} {/*<Editor />*/}
<div className="w-full bg-ctp-crust h-1 shrink-0"></div> <input
type="text"
<Editor /> placeholder="Untitled note..."
value={selectedNote?.title || ""}
{/* Action bar */} onChange={(e) => setTitle(e.target.value)}
<div className="flex items-center gap-3 px-8 py-4 border-t border-ctp-surface2 bg-ctp-mantle shrink-0"> className="w-full px-4 py-3 text-3xl font-semibold bg-transparentfocus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text"
{selectedNote ? ( />
<> <TiptapEditor
{/*<button key={selectedNote?.id}
onClick={handleUpdate} content={selectedNote?.content || ""}
className="px-2 py-0.5 bg-ctp-blue text-ctp-base rounded-lg hover:bg-ctp-sapphire transition-colors font-medium shadow-sm" onChange={setContent}
> />
Save
</button>*/}
<button
onClick={() => handleDelete(selectedNote.id)}
className="px-2 py-0.5 bg-ctp-red text-ctp-base rounded-lg hover:bg-ctp-maroon transition-colors font-medium shadow-sm"
>
Delete
</button>
<button
onClick={clearSelection}
className="px-2 py-0.5 bg-ctp-surface0 text-ctp-text rounded-lg hover:bg-ctp-surface1 transition-colors font-medium"
>
Cancel
</button>
</>
) : (
<button
onClick={handleCreate}
className="px-2 py-0.5 bg-ctp-green text-ctp-base rounded-lg hover:bg-ctp-teal transition-colors font-medium shadow-sm"
>
Create Note
</button>
)}
</div>
</div> </div>
{/* Status indicator */} {/* Status indicator */}
<div className="fixed bottom-4 right-4 bg-ctp-surface0 border border-ctp-surface2 rounded-lg px-2 py-0.5 flex items-center gap-2.5 shadow-lg backdrop-blur-sm"> <div
{updating ? ( className="fixed bottom-2 right-3 bg-ctp-surface0 border border-ctp-surface2 rounded-sm px-2 py-0.5 flex items-center gap-2.5 shadow-lg backdrop-blur-sm"
onClick={() => {
if (!encryptionKey) {
setShowModal(true);
}
}}
>
{!encryptionKey ? (
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-ctp-yellow [&_.fa-secondary]:fill-ctp-orange" />
) : updating ? (
<> <>
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-ctp-blue [&_.fa-secondary]:fill-ctp-sapphire" /> <SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-ctp-blue [&_.fa-secondary]:fill-ctp-sapphire" />
<span className="text-sm text-ctp-subtext0 font-medium"> <span className="text-sm text-ctp-subtext0 font-medium">
@ -171,3 +166,22 @@ function Home() {
} }
export default Home; export default Home;
const Modal = () => {
const { setShowModal } = useUIStore();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onClick={() => setShowModal(false)}
className="absolute h-screen w-screen flex items-center justify-center bg-ctp-crust/60 z-50"
>
<div
onClick={(e) => e.stopPropagation()}
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5"
>
<Login />
</div>
</motion.div>
);
};

View file

@ -0,0 +1,56 @@
import { useState } from "react";
import { useAuthStore } from "../stores/authStore";
import { useNavigate } from "react-router-dom";
import { useUIStore } from "../stores/uiStore";
export const Login = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [remember, setRemember] = useState(false);
const { login, setRememberMe } = useAuthStore();
const navigate = useNavigate();
const { setShowModal } = useUIStore();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setRememberMe(remember);
try {
await login(username, password);
setShowModal(false);
navigate("/");
} catch (err) {
setError(err.message);
}
};
return (
<form onSubmit={handleSubmit} className="gap-2 flex flex-col">
<input
type="text"
placeholder="Username"
className="standard-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
className="standard-input"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div>{error}</div>}
<button type="submit">Login</button>
<div className="flex gap-2">
<input
type="check box"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
<div>Remember me?</div>
</div>
</form>
);
};

View file

@ -1,68 +0,0 @@
// src/pages/TestPage.tsx
import { FC } from "react";
import Markdown from "react-markdown";
import { MDXEditor, SandpackConfig } from "@mdxeditor/editor";
import {
headingsPlugin,
listsPlugin,
quotePlugin,
thematicBreakPlugin,
linkPlugin,
codeBlockPlugin,
codeMirrorPlugin,
sandpackPlugin,
markdownShortcutPlugin,
toolbarPlugin,
BoldItalicUnderlineToggles,
} from "@mdxeditor/editor";
import "@mdxeditor/editor/style.css";
const simpleSandpackConfig: SandpackConfig = {
defaultPreset: "react",
presets: [
{
label: "React",
name: "react",
meta: "live react",
sandpackTemplate: "react",
sandpackTheme: "dark",
snippetFileName: "/App.js",
snippetLanguage: "jsx",
},
],
};
export const MarkdownPage: FC = () => {
const markdown = `
# This is *perfect*!
- TestPage
- te
`;
return (
<MDXEditor
markdown={markdown}
plugins={[
toolbarPlugin({
toolbarClassName: "my-class",
toolbarContents: () => (
<>
<BoldItalicUnderlineToggles />
</>
),
}),
headingsPlugin(),
listsPlugin(),
quotePlugin(),
thematicBreakPlugin(),
linkPlugin(),
codeBlockPlugin({ defaultCodeBlockLanguage: "js" }),
sandpackPlugin({ sandpackConfig: simpleSandpackConfig }),
codeMirrorPlugin({
codeBlockLanguages: { js: "JavaScript", css: "CSS" },
}),
markdownShortcutPlugin(),
]}
/>
);
};

View file

@ -0,0 +1,51 @@
import { useState } from "react";
import { useAuthStore } from "../stores/authStore";
import { useNavigate } from "react-router-dom";
export const Register = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const { register } = useAuthStore();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await register(username, email, password);
navigate("/");
} catch (err) {
setError(err.message);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div>{error}</div>}
<button type="submit">Login</button>
</form>
);
};
// Similar pattern for Register.tsx

View file

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

View file

@ -0,0 +1,198 @@
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown";
import { ListKit } from "@tiptap/extension-list";
import "./tiptap.css";
// @ts-ignore
import BoldIcon from "../assets/fontawesome/svg/bold.svg?react";
// @ts-ignore
import ItalicIcon from "../assets/fontawesome/svg/italic.svg?react";
// @ts-ignore
import StrikethroughIcon from "../assets/fontawesome/svg/strikethrough.svg?react";
// @ts-ignore
import CodeIcon from "../assets/fontawesome/svg/code.svg?react";
// @ts-ignore
import ListUlIcon from "../assets/fontawesome/svg/list-ul.svg?react";
// @ts-ignore
import ListOlIcon from "../assets/fontawesome/svg/list-ol.svg?react";
// @ts-ignore
import SquareCheckIcon from "../assets/fontawesome/svg/square-check.svg?react";
// @ts-ignore
import CodeBracketIcon from "../assets/fontawesome/svg/code-simple.svg?react";
// @ts-ignore
import QuoteLeftIcon from "../assets/fontawesome/svg/quote-left.svg?react";
interface TiptapEditorProps {
content: string;
onChange: (markdown: string) => void;
placeholder?: string;
}
export const TiptapEditor = ({
placeholder,
content,
onChange,
}: TiptapEditorProps) => {
const editor = useEditor({
extensions: [
ListKit,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6],
},
codeBlock: {
HTMLAttributes: {
class: "code-block",
},
},
bulletList: false,
orderedList: false,
listItem: false,
listKeymap: false,
}),
Placeholder.configure({
placeholder: placeholder || "Start writing...",
}),
Markdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true,
}),
],
content,
editorProps: {
attributes: {
class: "prose prose-invert max-w-none focus:outline-none",
},
},
onUpdate: ({ editor }) => {
const markdown = (
editor.storage as Record<string, any>
).markdown.getMarkdown();
onChange(markdown);
},
});
if (!editor) {
return null;
}
return (
<div className="tiptap-editor h-full">
{/* Toolbar */}
{/*<div className="editor-toolbar">
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "active" : ""}
title="Bold (Ctrl+B)"
>
<BoldIcon className="w-4 h-4 fill-ctp-text" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "active" : ""}
title="Italic (Ctrl+I)"
>
<ItalicIcon className="w-4 h-4 fill-ctp-text" />
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive("strike") ? "active" : ""}
title="Strikethrough"
>
<StrikethroughIcon className="w-4 h-4 fill-ctp-text" />
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={editor.isActive("code") ? "active" : ""}
title="Inline code"
>
<CodeIcon className="w-4 h-4 fill-ctp-text" />
</button>
</div>
<div className="toolbar-divider"></div>
<div className="toolbar-group">
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
className={editor.isActive("heading", { level: 1 }) ? "active" : ""}
title="Heading 1"
>
H1
</button>
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={editor.isActive("heading", { level: 2 }) ? "active" : ""}
title="Heading 2"
>
H2
</button>
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
className={editor.isActive("heading", { level: 3 }) ? "active" : ""}
title="Heading 3"
>
H3
</button>
</div>
<div className="toolbar-divider"></div>
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "active" : ""}
title="Bullet list"
>
<ListUlIcon className="w-4 h-4 fill-ctp-text" />
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "active" : ""}
title="Numbered list"
>
<ListOlIcon className="w-4 h-4 fill-ctp-text" />
</button>
<button
onClick={() => editor.chain().focus().toggleTaskList().run()}
className={editor.isActive("taskList") ? "active" : ""}
title="Task list"
>
<SquareCheckIcon className="w-4 h-4 fill-ctp-text" />
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editor.isActive("codeBlock") ? "active" : ""}
title="Code block"
>
<CodeBracketIcon className="w-4 h-4 fill-ctp-text" />
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "active" : ""}
title="Quote"
>
<QuoteLeftIcon className="w-4 h-4 fill-ctp-text" />
</button>
</div>
<div className="toolbar-divider"></div>
</div>*/}
{/* Editor content */}
<EditorContent
editor={editor}
className="editor-content h-min-screen p-4!"
/>
</div>
);
};

View file

@ -0,0 +1,172 @@
@reference "../main.css";
/* Custom Scrollbar */
*::-webkit-scrollbar {
@apply w-2;
}
*::-webkit-scrollbar-track {
@apply bg-ctp-mantle rounded-full;
}
*::-webkit-scrollbar-thumb {
@apply bg-ctp-surface2 rounded-full;
}
*::-webkit-scrollbar-thumb:hover {
@apply bg-ctp-overlay0;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-ctp-surface2) var(--color-ctp-mantle);
}
.tiptap-editor {
@apply flex flex-col h-full bg-ctp-base;
}
.ProseMirror {
@apply text-ctp-text;
}
.editor-toolbar {
@apply flex gap-2 px-4 bg-ctp-mantle border-b border-ctp-surface2 flex-wrap items-center;
}
.toolbar-group {
@apply flex gap-1;
}
.toolbar-divider {
@apply w-px h-6 bg-ctp-surface2;
}
.editor-toolbar button {
@apply p-2 bg-transparent border-none rounded-sm text-ctp-text cursor-pointer transition-all duration-150 text-sm font-semibold min-w-8 flex items-center justify-center;
}
.editor-toolbar button:hover:not(:disabled) {
@apply bg-ctp-surface0;
}
.editor-toolbar button.active {
@apply bg-ctp-mauve text-ctp-base;
}
.editor-toolbar button:disabled {
@apply opacity-40 cursor-not-allowed;
}
.ProseMirror:focus {
@apply outline-none;
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
@apply float-left text-ctp-overlay0 pointer-events-none h-0;
}
.ProseMirror ul {
@apply mb-0!;
}
.ProseMirror h1 {
@apply text-3xl font-bold text-ctp-mauve mt-8 mb-4;
}
.ProseMirror h2 {
@apply text-2xl font-semibold text-ctp-blue mt-6 mb-3;
}
.ProseMirror h3 {
@apply text-xl font-semibold text-ctp-teal mt-5 mb-2;
}
.ProseMirror code {
@apply bg-ctp-surface0 text-ctp-peach px-1.5 py-0.5 rounded text-sm;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.ProseMirror .code-block {
@apply bg-ctp-surface0 border border-ctp-surface2 rounded-sm p-4 my-4 overflow-x-auto;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.ProseMirror .code-block code {
@apply bg-transparent p-0 text-ctp-text;
}
.ProseMirror blockquote {
@apply border-l-4 border-ctp-mauve pl-4 ml-0 text-ctp-subtext0 italic;
}
.ProseMirror hr {
@apply border-none border-t-2 border-ctp-surface2 my-8;
}
.ProseMirror a {
@apply text-ctp-blue underline;
}
.ProseMirror a:hover {
@apply text-ctp-sapphire;
}
.ProseMirror strong {
@apply text-ctp-peach font-semibold;
}
.ProseMirror em {
@apply text-ctp-yellow;
}
/* Task List (Checkboxes) */
.ProseMirror ul[data-type="taskList"] {
@apply list-none p-0;
}
.ProseMirror ul[data-type="taskList"] > li {
@apply flex flex-row items-baseline m-0 p-0;
}
.ProseMirror ul[data-type="taskList"] > li > label {
@apply flex-none mr-2 select-none flex items-center h-6;
}
.ProseMirror ul[data-type="taskList"] > li > label input[type="checkbox"] {
@apply cursor-pointer m-0 accent-ctp-mauve;
}
.ProseMirror ul[data-type="taskList"] > li > div {
@apply flex-1;
}
.ProseMirror ul[data-type="taskList"] > li > div p {
@apply m-0 p-0;
}
.ProseMirror li[data-checked="true"] > div > p {
@apply line-through text-ctp-overlay0;
text-decoration-style: wavy;
}
.ProseMirror u {
@apply decoration-ctp-mauve;
text-decoration-style: wavy;
}
.ProseMirror li::marker {
@apply text-ctp-mauve;
}
/* tiptap.css */
.ProseMirror ul,
.ProseMirror ol {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.ProseMirror li > p {
margin-top: 0 !important;
margin-bottom: 0 !important;
}

View file

@ -0,0 +1,151 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import {
deriveKey,
generateMasterKey,
unwrapMasterKey,
wrapMasterKey,
} from "../api/encryption";
interface User {
id: number;
username: string;
email: string;
salt: string; // For key derivation
}
interface AuthState {
user: User | null;
encryptionKey: CryptoKey | null; // Memory only!
isAuthenticated: boolean;
rememberMe: boolean;
setRememberMe: (boolean) => void;
login: (username: string, password: string) => Promise<void>;
register: (
username: string,
email: string,
password: string,
) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
initEncryptionKey: (password: string, salt: string) => Promise<void>;
}
const API_URL = "http://localhost:8000/api";
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
encryptionKey: null,
isAuthenticated: false,
rememberMe: false,
setRememberMe: (bool) => {
set({ rememberMe: bool });
},
initEncryptionKey: async (password: string, salt: string) => {
// Use user-specific salt instead of hardcoded
const key = await deriveKey(password, salt);
set({ encryptionKey: key });
},
register: async (username: string, email: string, password: string) => {
const masterKey = await generateMasterKey();
const salt = crypto.randomUUID();
const kek = await deriveKey(password, salt);
const wrappedMasterKey = await wrapMasterKey(masterKey, kek);
const response = await fetch(`${API_URL}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
username,
email,
password,
salt,
wrappedMasterKey,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail);
}
const data = await response.json();
// Store the master key directly (not derived from password)
set({
user: data.user,
isAuthenticated: true,
encryptionKey: masterKey,
});
},
login: async (username: string, password: string) => {
const response = await fetch(`${API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail);
}
const { user } = await response.json();
// Derive KEK and unwrap master key
const kek = await deriveKey(password, user.salt);
const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek);
// Store master key in memory
set({ encryptionKey: masterKey, user, isAuthenticated: true });
},
logout: async () => {
await fetch(`${API_URL}/auth/logout`, {
method: "POST",
credentials: "include",
});
set({
user: null,
encryptionKey: null, // Wipe from memory
isAuthenticated: false,
});
},
checkAuth: async () => {
try {
const response = await fetch(`${API_URL}/auth/me`, {
credentials: "include",
});
if (!response.ok) {
get().logout();
return;
}
const data = await response.json();
set({ user: data.user, isAuthenticated: true });
} catch (e) {
get().logout();
}
},
}),
{
name: "auth-storage",
partialize: (state) => {
return {
user: state.user,
isAuthenticated: state.isAuthenticated,
};
},
},
),
);

View file

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

View file

@ -1,13 +1,40 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware";
interface UIState { interface UIState {
updating: boolean; updating: boolean;
setUpdating: (update: boolean) => void; setUpdating: (update: boolean) => void;
showModal: boolean;
setShowModal: (show: boolean) => void;
sideBarResize: number;
setSideBarResize: (size: number) => void;
} }
export const useUIStore = create<UIState>()((set, get) => ({ export const useUIStore = create<UIState>()(
persist(
(set, get) => ({
updating: false, updating: false,
setUpdating: (update) => { setUpdating: (update) => {
set({ updating: update }); set({ updating: update });
}, },
})); showModal: false,
setShowModal: (show) => {
set({ showModal: show });
},
sideBarResize: 300,
setSideBarResize: (size) => {
set({ sideBarResize: size });
},
}),
{
name: "ui-store",
partialize: (state) => {
return {
sideBarResize: state.sideBarResize,
};
},
},
),
);