Compare commits
No commits in common. "main" and "test/implement-vitest" have entirely different histories.
main
...
test/imple
26
.gitignore
vendored
|
|
@ -1,27 +1,3 @@
|
||||||
node_modules
|
node_modules
|
||||||
*.svg
|
frontend/src/assets/fontawesome/svg/*
|
||||||
frontend/src/assets/fontawesome/svg/0.svg
|
frontend/src/assets/fontawesome/svg/0.svg
|
||||||
*.db
|
|
||||||
.zed/settings.json
|
|
||||||
**.pyc
|
|
||||||
!xmark.svg
|
|
||||||
!plus.svg
|
|
||||||
!circle-check.svg
|
|
||||||
!rotate.svg
|
|
||||||
!circle-exclamation.svg
|
|
||||||
!folder.svg
|
|
||||||
!tags.svg
|
|
||||||
!caret-right.svg
|
|
||||||
!folder-plus.svg
|
|
||||||
!file-circle-plus.svg
|
|
||||||
!bold.svg
|
|
||||||
!italic.svg
|
|
||||||
!strikethrough.svg
|
|
||||||
!code.svg
|
|
||||||
!list-ul.svg
|
|
||||||
!list-ol.svg
|
|
||||||
!square-check.svg
|
|
||||||
!code-simple.svg
|
|
||||||
!quote-left.svg
|
|
||||||
!gear.svg
|
|
||||||
frontend/dist/*
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,39 +1,10 @@
|
||||||
# ---- Builder stage ----
|
FROM python:3.11-slim
|
||||||
FROM python:3.12-slim AS builder
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --upgrade pip && \
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# ---- Runtime stage ----
|
COPY ./app ./app
|
||||||
FROM python:3.12-slim
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy installed packages from builder
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
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"]
|
|
||||||
|
|
|
||||||
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/auth.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/models.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/models.cpython-314.pyc
Normal file
|
|
@ -2,7 +2,7 @@ import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt # Use bcrypt directly instead of passlib
|
||||||
from fastapi import Cookie, Depends, HTTPException, Request, status
|
from fastapi import Cookie, Depends, HTTPException, Request, status
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ from app.models import Session as SessionModel
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
# Password hashing with bcrypt directly
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
password_bytes = password.encode("utf-8")
|
password_bytes = password.encode("utf-8")
|
||||||
salt = bcrypt.gensalt()
|
salt = bcrypt.gensalt()
|
||||||
|
|
@ -24,11 +25,12 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
# Session management
|
||||||
def create_session(
|
def create_session(
|
||||||
user_id: int, request: Request, db: Session, expires_in_days: int = 30
|
user_id: int, request: Request, db: Session, expires_in_days: int = 30
|
||||||
) -> str:
|
) -> str:
|
||||||
session_id = secrets.token_urlsafe(32)
|
session_id = secrets.token_urlsafe(32)
|
||||||
expires_at = datetime.now() + timedelta(days=expires_in_days)
|
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
|
||||||
|
|
||||||
db_session = SessionModel(
|
db_session = SessionModel(
|
||||||
session_id=session_id,
|
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)
|
select(SessionModel).where(SessionModel.session_id == session_id)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not session or session.expires_at < datetime.now():
|
if not session or session.expires_at < datetime.utcnow():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return session.user
|
return session.user
|
||||||
|
|
||||||
|
|
||||||
|
# Dependency for protected routes
|
||||||
async def require_auth(
|
async def require_auth(
|
||||||
session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session)
|
session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session)
|
||||||
) -> User:
|
) -> User:
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from sqlmodel import Session, SQLModel, create_engine # type: ignore
|
from sqlmodel import Session, SQLModel, create_engine # type: ignore
|
||||||
|
|
||||||
load_dotenv()
|
DATABASE_URL = "sqlite:///./notes.db"
|
||||||
# Get database URL from environment, with proper fallback
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
|
||||||
|
|
||||||
# If DATABASE_URL is not set or empty, use default SQLite
|
engine = create_engine(
|
||||||
if not DATABASE_URL or DATABASE_URL.strip() == "":
|
DATABASE_URL, echo=True, connect_args={"check_same_thread": False}
|
||||||
DATABASE_URL = "sqlite:////app/data/notes.db"
|
)
|
||||||
print(f"WARNING: DATABASE_URL not set, using default: {DATABASE_URL}")
|
|
||||||
else:
|
|
||||||
print(f"Using DATABASE_URL: {DATABASE_URL}")
|
|
||||||
|
|
||||||
# Only use check_same_thread for SQLite
|
|
||||||
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, echo=True, connect_args=connect_args)
|
|
||||||
|
|
||||||
|
|
||||||
def create_db_and_tables():
|
def create_db_and_tables():
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from fastapi import FastAPI # type: ignore
|
from fastapi import FastAPI # type: ignore
|
||||||
from fastapi.middleware.cors import CORSMiddleware # type:ignore
|
from fastapi.middleware.cors import CORSMiddleware # type:ignore
|
||||||
|
|
||||||
from app.database import create_db_and_tables
|
from app.database import create_db_and_tables
|
||||||
from app.routes import auth, folders, notes, tags
|
from app.routes import auth, folders, notes
|
||||||
|
|
||||||
app = FastAPI(title="Notes API")
|
app = FastAPI(title="Notes API")
|
||||||
|
|
||||||
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
# CORS - adjust origins for production
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=cors_origins,
|
allow_origins=["http://localhost:5173"], # Vite dev server
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|
@ -26,15 +24,8 @@ def on_startup():
|
||||||
app.include_router(notes.router, prefix="/api")
|
app.include_router(notes.router, prefix="/api")
|
||||||
app.include_router(folders.router, prefix="/api")
|
app.include_router(folders.router, prefix="/api")
|
||||||
app.include_router(auth.router, prefix="/api")
|
app.include_router(auth.router, prefix="/api")
|
||||||
app.include_router(tags.router, prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return {"message": "Notes API"}
|
return {"message": "Notes API"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health():
|
|
||||||
"""Health check endpoint for Docker and Coolify"""
|
|
||||||
return {"status": "healthy"}
|
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,26 @@ 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): # type: ignore
|
class User(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
username: str = Field(unique=True, index=True)
|
username: str = Field(unique=True, index=True)
|
||||||
email: str = Field(unique=True, index=True)
|
email: str = Field(unique=True, index=True)
|
||||||
hashed_password: str
|
hashed_password: str
|
||||||
salt: str
|
salt: str
|
||||||
wrapped_master_key: str
|
wrapped_master_key: str
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
# Add relationships to existing models
|
# Add relationships to existing models
|
||||||
notes: List["Note"] = Relationship(back_populates="user")
|
notes: List["Note"] = Relationship(back_populates="user")
|
||||||
folders: List["Folder"] = Relationship(back_populates="user")
|
folders: List["Folder"] = Relationship(back_populates="user")
|
||||||
sessions: List["Session"] = Relationship(back_populates="user")
|
sessions: List["Session"] = Relationship(back_populates="user")
|
||||||
tags: List["Tag"] = Relationship(back_populates="user")
|
|
||||||
|
|
||||||
|
|
||||||
class Session(SQLModel, table=True): # type: ignore
|
class Session(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
session_id: str = Field(unique=True, index=True)
|
session_id: str = Field(unique=True, index=True)
|
||||||
user_id: int = Field(foreign_key="user.id")
|
user_id: int = Field(foreign_key="user.id")
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
ip_address: Optional[str] = None
|
ip_address: Optional[str] = None
|
||||||
user_agent: Optional[str] = None
|
user_agent: Optional[str] = None
|
||||||
|
|
@ -36,7 +35,7 @@ class Folder(SQLModel, table=True): # type: ignore
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
name: str = Field(max_length=255)
|
name: str = Field(max_length=255)
|
||||||
parent_id: Optional[int] = Field(default=None, foreign_key="folder.id")
|
parent_id: Optional[int] = Field(default=None, foreign_key="folder.id")
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
user_id: int = Field(foreign_key="user.id")
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|
@ -48,75 +47,20 @@ class Folder(SQLModel, table=True): # type: ignore
|
||||||
user: User = Relationship(back_populates="folders")
|
user: User = Relationship(back_populates="folders")
|
||||||
|
|
||||||
|
|
||||||
class NoteTag(SQLModel, table=True): #type: ignore
|
|
||||||
note_id: int = Field(foreign_key="note.id", primary_key=True)
|
|
||||||
tag_id: int = Field(foreign_key="tag.id", primary_key=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(SQLModel, table=True): # type: ignore
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
name: str = Field(max_length=255)
|
|
||||||
parent_id: Optional[int] = Field(default=None, foreign_key="tag.id")
|
|
||||||
user_id: int = Field(foreign_key="user.id")
|
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user: User = Relationship(back_populates="tags")
|
|
||||||
parent: Optional["Tag"] = Relationship(
|
|
||||||
back_populates="children",
|
|
||||||
sa_relationship_kwargs={"remote_side": "Tag.id"}
|
|
||||||
)
|
|
||||||
children: List["Tag"] = Relationship(back_populates="parent")
|
|
||||||
notes: List["Note"] = Relationship(back_populates="tags", link_model=NoteTag)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Note(SQLModel, table=True): # type: ignore
|
class Note(SQLModel, table=True): # type: ignore
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
title: str = Field(max_length=255)
|
title: str = Field(max_length=255)
|
||||||
content: str
|
content: str
|
||||||
folder_id: Optional[int] = Field(default=None, foreign_key="folder.id")
|
folder_id: Optional[int] = Field(default=None, foreign_key="folder.id")
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
updated_at: datetime = Field(default_factory=datetime.now)
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
user_id: int = Field(foreign_key="user.id")
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
|
||||||
#Relationships
|
|
||||||
folder: Optional[Folder] = Relationship(back_populates="notes")
|
folder: Optional[Folder] = Relationship(back_populates="notes")
|
||||||
user: User = Relationship(back_populates="notes")
|
user: User = Relationship(back_populates="notes")
|
||||||
tags: List[Tag] = Relationship(back_populates="notes", link_model=NoteTag)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# API Response models
|
|
||||||
class TagRead(SQLModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
parent_id: Optional[int] = None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class TagCreate(SQLModel):
|
|
||||||
name: str
|
|
||||||
parent_id: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class TagUpdate(SQLModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
parent_id: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class TagTreeNode(SQLModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
parent_id: Optional[int] = None
|
|
||||||
created_at: datetime
|
|
||||||
children: List["TagTreeNode"] = []
|
|
||||||
|
|
||||||
|
|
||||||
class TagTreeResponse(SQLModel):
|
|
||||||
tags: List[TagTreeNode]
|
|
||||||
|
|
||||||
|
|
||||||
|
# API Response models (what gets sent to frontend)
|
||||||
class NoteRead(SQLModel):
|
class NoteRead(SQLModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
|
|
@ -124,7 +68,6 @@ class NoteRead(SQLModel):
|
||||||
folder_id: Optional[int] = None
|
folder_id: Optional[int] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
tags: List[TagRead] = []
|
|
||||||
|
|
||||||
|
|
||||||
class FolderTreeNode(SQLModel):
|
class FolderTreeNode(SQLModel):
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
|
||||||
|
from sqlmodel import Session, SQLModel, select
|
||||||
|
|
||||||
from app.auth import create_session, hash_password, require_auth, verify_password
|
from app.auth import create_session, hash_password, require_auth, verify_password
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import Session as SessionModel
|
from app.models import Session as SessionModel
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
|
|
||||||
from sqlmodel import Session, SQLModel, select
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
@ -29,8 +30,8 @@ class UserResponse(SQLModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
email: str
|
email: str
|
||||||
salt: str
|
salt: str # Client needs this for key derivation
|
||||||
wrapped_master_key: str
|
wrapped_master_key: str # Client needs this to unwrap the master key
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
|
|
@ -71,7 +72,7 @@ def register(
|
||||||
key="session_id",
|
key="session_id",
|
||||||
value=session_id,
|
value=session_id,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=True, # HTTPS only in production
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
max_age=30 * 24 * 60 * 60, # 30 days
|
max_age=30 * 24 * 60 * 60, # 30 days
|
||||||
)
|
)
|
||||||
|
|
@ -146,15 +147,15 @@ def list_sessions(
|
||||||
return {"sessions": sessions}
|
return {"sessions": sessions}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/sessions/{session_token}")
|
@router.delete("/sessions/{session_token}") # Renamed from session_id
|
||||||
def revoke_session(
|
def revoke_session(
|
||||||
session_token: str,
|
session_token: str, # Renamed to avoid conflict with Cookie parameter
|
||||||
current_user: User = Depends(require_auth),
|
current_user: User = Depends(require_auth),
|
||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
session = db.exec(
|
session = db.exec(
|
||||||
select(SessionModel)
|
select(SessionModel)
|
||||||
.where(SessionModel.session_id == session_token)
|
.where(SessionModel.session_id == session_token) # Use renamed variable
|
||||||
.where(SessionModel.user_id == current_user.id)
|
.where(SessionModel.user_id == current_user.id)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException # type: ignore
|
||||||
|
from sqlmodel import Session, select # type: ignore
|
||||||
|
|
||||||
from app.auth import require_auth
|
from app.auth import require_auth
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
|
@ -12,9 +15,6 @@ from app.models import (
|
||||||
NoteRead,
|
NoteRead,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, HTTPException # type: ignore
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import Session, select # type: ignore
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/folders", tags=["folders"])
|
router = APIRouter(prefix="/folders", tags=["folders"])
|
||||||
|
|
||||||
|
|
@ -35,20 +35,21 @@ def get_folder_tree(
|
||||||
):
|
):
|
||||||
"""Get complete folder tree with notes"""
|
"""Get complete folder tree with notes"""
|
||||||
|
|
||||||
|
# Get all top-level folders (parent_id is None) for current user
|
||||||
top_level_folders = session.exec(
|
top_level_folders = session.exec(
|
||||||
select(Folder)
|
select(Folder)
|
||||||
.options(selectinload(Folder.notes).selectinload(Note.tags))
|
|
||||||
.where(Folder.parent_id == None)
|
.where(Folder.parent_id == None)
|
||||||
.where(Folder.user_id == current_user.id)
|
.where(Folder.user_id == current_user.id)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
# Get all orphaned notes (folder_id is None) for current user
|
||||||
orphaned_notes = session.exec(
|
orphaned_notes = session.exec(
|
||||||
select(Note)
|
select(Note)
|
||||||
.options(selectinload(Note.tags))
|
|
||||||
.where(Note.folder_id == None)
|
.where(Note.folder_id == None)
|
||||||
.where(Note.user_id == current_user.id)
|
.where(Note.user_id == current_user.id)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
# Build tree recursively
|
||||||
tree = [build_folder_tree_node(folder) for folder in top_level_folders]
|
tree = [build_folder_tree_node(folder) for folder in top_level_folders]
|
||||||
|
|
||||||
return FolderTreeResponse(
|
return FolderTreeResponse(
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.auth import require_auth
|
from app.auth import require_auth
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import Note, NoteCreate, NoteRead, NoteUpdate, User
|
from app.models import Note, NoteCreate, NoteUpdate, User
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
router = APIRouter(prefix="/notes", tags=["notes"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[NoteRead])
|
@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() # pyright: ignore[reportAttributeAccessIssue]
|
notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() # pyright: ignore[reportAttributeAccessIssue]
|
||||||
return notes
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=Note)
|
@router.post("/", response_model=Note)
|
||||||
def create_note(
|
def create_note(
|
||||||
note: NoteCreate,
|
note: NoteCreate,
|
||||||
|
|
|
||||||
|
|
@ -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
1
backend/notes.sqbpro
Normal 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>
|
||||||
|
|
@ -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 "*"
|
|
||||||
44
compose.yaml
|
|
@ -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:
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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;"]
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19602
frontend/package-lock.json
generated
|
|
@ -1,57 +1,49 @@
|
||||||
{
|
{
|
||||||
"name": "note-frontend",
|
"name": "note-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest",
|
"dev": "vite",
|
||||||
"test:ui": "vitest --ui",
|
"build": "vite build",
|
||||||
"test:coverage": "vitest --coverage",
|
"preview": "vite preview"
|
||||||
"dev": "vite",
|
},
|
||||||
"build": "vite build",
|
"dependencies": {
|
||||||
"preview": "vite preview",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"generate-types": "openapi-typescript http://localhost:8000/openapi.json -o src/types/api.d.ts"
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
},
|
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||||
"dependencies": {
|
"@mdxeditor/editor": "^3.49.3",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
"@tiptap/extension-placeholder": "^3.12.1",
|
||||||
"@mdxeditor/editor": "^3.49.3",
|
"@tiptap/react": "^3.12.1",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tiptap/starter-kit": "^3.12.1",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"axios": "^1.13.2",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"framer-motion": "^12.23.25",
|
||||||
"@tanstack/react-query-devtools": "^5.91.1",
|
"jszip": "^3.10.1",
|
||||||
"@tiptap/extension-placeholder": "^3.12.1",
|
"react": "^18.3.1",
|
||||||
"@tiptap/react": "^3.12.1",
|
"react-dom": "^18.3.1",
|
||||||
"@tiptap/starter-kit": "^3.12.1",
|
"react-router-dom": "^7.9.6",
|
||||||
"axios": "^1.13.2",
|
"tailwindcss": "^4.1.17",
|
||||||
"framer-motion": "^12.23.25",
|
"tiptap-markdown": "^0.9.0",
|
||||||
"humps": "^2.0.1",
|
"zustand": "^5.0.8"
|
||||||
"jszip": "^3.10.1",
|
},
|
||||||
"openapi-fetch": "^0.15.0",
|
"devDependencies": {
|
||||||
"react": "^18.3.1",
|
"@catppuccin/tailwindcss": "^1.0.0",
|
||||||
"react-dom": "^18.3.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"react-router-dom": "^7.9.6",
|
"@testing-library/react": "^16.3.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"tiptap-markdown": "^0.9.0",
|
"@types/react": "^19.2.6",
|
||||||
"uuid": "^13.0.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"zustand": "^5.0.8"
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
},
|
"@vitest/ui": "^4.0.15",
|
||||||
"devDependencies": {
|
"jsdom": "^27.3.0",
|
||||||
"@catppuccin/tailwindcss": "^1.0.0",
|
"vite": "^5.4.21",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"vitest": "^4.0.15"
|
||||||
"@testing-library/user-event": "^14.6.1",
|
},
|
||||||
"@types/humps": "^2.0.6",
|
"scripts": {
|
||||||
"@types/react": "^19.2.6",
|
"test": "vitest",
|
||||||
"@types/react-dom": "^19.2.3",
|
"test:ui": "vitest --ui",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"test:coverage": "vitest --coverage"
|
||||||
"@vitest/ui": "^4.0.15",
|
}
|
||||||
"jsdom": "^27.3.0",
|
|
||||||
"openapi-typescript": "^7.10.1",
|
|
||||||
"type-fest": "^5.3.1",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.3.1",
|
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
|
||||||
"vitest": "^4.0.15"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,23 +1,4 @@
|
||||||
import { components } from "@/types/api";
|
import { FolderTreeResponse, FolderTreeNode } from "./folders";
|
||||||
// encryption.tsx
|
|
||||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
|
||||||
import { FolderTreeResponse } from "./folders";
|
|
||||||
export type Tag = CamelCasedPropertiesDeep<components["schemas"]["Tag"]>;
|
|
||||||
export type FolderTreeNode = CamelCasedPropertiesDeep<
|
|
||||||
components["schemas"]["FolderTreeNode"]
|
|
||||||
>;
|
|
||||||
export type TagTreeNode = CamelCasedPropertiesDeep<
|
|
||||||
components["schemas"]["TagTreeNode"]
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface DecryptedTagNode {
|
|
||||||
id?: number | null | undefined;
|
|
||||||
name: string;
|
|
||||||
parentId?: number | null;
|
|
||||||
createdAt?: string;
|
|
||||||
parentPath: string;
|
|
||||||
children: DecryptedTagNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deriveKey(password: string, salt: string) {
|
export async function deriveKey(password: string, salt: string) {
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
|
|
@ -133,12 +114,6 @@ export async function decryptFolderTree(
|
||||||
...note,
|
...note,
|
||||||
title: await decryptString(note.title, encryptionKey),
|
title: await decryptString(note.title, encryptionKey),
|
||||||
content: await decryptString(note.content, encryptionKey),
|
content: await decryptString(note.content, encryptionKey),
|
||||||
tags: await Promise.all(
|
|
||||||
note.tags.map(async (tag) => ({
|
|
||||||
...tag,
|
|
||||||
name: await decryptString(tag.name, encryptionKey),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
children: await Promise.all(
|
children: await Promise.all(
|
||||||
|
|
@ -151,40 +126,12 @@ export async function decryptFolderTree(
|
||||||
folders: await Promise.all(
|
folders: await Promise.all(
|
||||||
tree.folders.map((folder) => decryptFolder(folder)),
|
tree.folders.map((folder) => decryptFolder(folder)),
|
||||||
),
|
),
|
||||||
orphanedNotes: await Promise.all(
|
orphaned_notes: await Promise.all(
|
||||||
tree.orphanedNotes.map(async (note) => ({
|
tree.orphaned_notes.map(async (note) => ({
|
||||||
...note,
|
...note,
|
||||||
title: await decryptString(note.title, encryptionKey),
|
title: await decryptString(note.title, encryptionKey),
|
||||||
content: await decryptString(note.content, encryptionKey),
|
content: await decryptString(note.content, encryptionKey),
|
||||||
tags: await Promise.all(
|
|
||||||
note.tags.map(async (tag) => ({
|
|
||||||
...tag,
|
|
||||||
name: await decryptString(tag.name, encryptionKey),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const decryptTagTree = async (
|
|
||||||
tags: TagTreeNode[],
|
|
||||||
key: CryptoKey,
|
|
||||||
parentPath = "",
|
|
||||||
): Promise<DecryptedTagNode[]> => {
|
|
||||||
return Promise.all(
|
|
||||||
tags.map(async (tag) => {
|
|
||||||
const decryptedName = await decryptString(tag.name, key);
|
|
||||||
const currentPath = parentPath
|
|
||||||
? `${parentPath} › ${decryptedName}`
|
|
||||||
: decryptedName;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...tag,
|
|
||||||
name: decryptedName,
|
|
||||||
parentPath: parentPath,
|
|
||||||
children: await decryptTagTree(tag.children, key, currentPath),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,60 @@
|
||||||
|
import axios from "axios";
|
||||||
import { decryptFolderTree } from "./encryption";
|
import { decryptFolderTree } from "./encryption";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
|
||||||
import { components } from "@/types/api";
|
|
||||||
import client from "./client";
|
|
||||||
|
|
||||||
export type Folder = CamelCasedPropertiesDeep<components["schemas"]["Folder"]>;
|
axios.defaults.withCredentials = true;
|
||||||
|
|
||||||
export type FolderTreeNode = CamelCasedPropertiesDeep<
|
const API_URL = (import.meta as any).env.PROD
|
||||||
components["schemas"]["FolderTreeNode"]
|
? "/api"
|
||||||
>;
|
: "http://localhost:8000/api";
|
||||||
|
|
||||||
export type FolderTreeResponse = CamelCasedPropertiesDeep<
|
export interface Folder {
|
||||||
components["schemas"]["FolderTreeResponse"]
|
id: number;
|
||||||
>;
|
name: string;
|
||||||
export type FolderCreate = CamelCasedPropertiesDeep<
|
parent_id: number | null;
|
||||||
components["schemas"]["FolderCreate"]
|
created_at: string;
|
||||||
>;
|
}
|
||||||
export type FolderUpdate = CamelCasedPropertiesDeep<
|
|
||||||
components["schemas"]["FolderUpdate"]
|
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 getFolderTree = async () => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
if (!encryptionKey) throw new Error("Not authenticated");
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const { data, 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(data, encryptionKey);
|
||||||
|
|
||||||
const decryptedFolderTree = await decryptFolderTree(newData, encryptionKey);
|
|
||||||
|
|
||||||
return decryptedFolderTree;
|
return decryptedFolderTree;
|
||||||
};
|
};
|
||||||
|
|
@ -36,10 +62,7 @@ const getFolderTree = async () => {
|
||||||
const updateFolder = async (id: number, folder: FolderUpdate) => {
|
const updateFolder = async (id: number, folder: FolderUpdate) => {
|
||||||
console.log(`Updating folder ${id} with:`, folder);
|
console.log(`Updating folder ${id} with:`, folder);
|
||||||
try {
|
try {
|
||||||
const response = await client.PATCH("/folders/{folder_id}", {
|
const response = await axios.patch(`${API_URL}/folders/${id}`, folder);
|
||||||
params: { path: { folder_id: id } },
|
|
||||||
body: folder,
|
|
||||||
});
|
|
||||||
console.log(`Folder ${id} update response:`, response.data);
|
console.log(`Folder ${id} update response:`, response.data);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -50,12 +73,10 @@ const updateFolder = async (id: number, folder: FolderUpdate) => {
|
||||||
|
|
||||||
export const folderApi = {
|
export const folderApi = {
|
||||||
tree: () => getFolderTree(),
|
tree: () => getFolderTree(),
|
||||||
list: () => client.GET("/folders/", {}),
|
list: () => axios.get<Folder[]>(`${API_URL}/folders`),
|
||||||
create: (folder: FolderCreate) => client.POST("/folders/", { body: folder }),
|
create: (folder: FolderCreate) =>
|
||||||
delete: (id: number) =>
|
axios.post<Folder>(`${API_URL}/folders/`, folder),
|
||||||
client.DELETE("/folders/{folder_id}", {
|
delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`),
|
||||||
params: { path: { folder_id: id } },
|
|
||||||
}),
|
|
||||||
update: (id: number, updateData: FolderUpdate) =>
|
update: (id: number, updateData: FolderUpdate) =>
|
||||||
updateFolder(id, updateData),
|
updateFolder(id, updateData),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
|
import axios from "axios";
|
||||||
|
import { NoteRead } from "./folders";
|
||||||
import { encryptString, decryptString } from "./encryption";
|
import { encryptString, decryptString } from "./encryption";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
axios.defaults.withCredentials = true;
|
||||||
import { components } from "@/types/api";
|
const API_URL = (import.meta as any).env.PROD
|
||||||
import client from "./client";
|
? "/api"
|
||||||
|
: "http://localhost:8000/api";
|
||||||
|
|
||||||
export type NoteRead = CamelCasedPropertiesDeep<
|
export interface Note {
|
||||||
components["schemas"]["NoteRead"]
|
id: number;
|
||||||
>;
|
title: string;
|
||||||
export type NoteCreate = CamelCasedPropertiesDeep<
|
folder_id?: number;
|
||||||
components["schemas"]["NoteCreate"]
|
content: string;
|
||||||
>;
|
created_at: string;
|
||||||
export type Note = CamelCasedPropertiesDeep<components["schemas"]["Note"]>;
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteCreate {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
folder_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
const createNote = async (note: NoteCreate) => {
|
const createNote = async (note: NoteCreate) => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
|
|
@ -22,93 +32,52 @@ const createNote = async (note: NoteCreate) => {
|
||||||
var encryptedNote = {
|
var encryptedNote = {
|
||||||
title: noteTitle,
|
title: noteTitle,
|
||||||
content: noteContent,
|
content: noteContent,
|
||||||
folderId: note.folderId,
|
folder_id: note.folder_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(encryptedNote);
|
console.log(encryptedNote);
|
||||||
return client.POST(`/notes/`, { body: encryptedNote });
|
return axios.post(`${API_URL}/notes/`, encryptedNote);
|
||||||
};
|
};
|
||||||
const fetchNotes = async () => {
|
const fetchNotes = async () => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
if (!encryptionKey) throw new Error("Not authenticated");
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const { data, error } = await client.GET(`/notes/`);
|
const { data } = await axios.get(`${API_URL}/notes/`);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
console.log(data);
|
console.log(data);
|
||||||
|
const decryptedNotes = await Promise.all(
|
||||||
|
data.map(async (note: Note) => ({
|
||||||
|
...note,
|
||||||
|
title: await decryptString(note.title, encryptionKey),
|
||||||
|
content: await decryptString(note.content, encryptionKey),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
if (data) {
|
return decryptedNotes;
|
||||||
const decryptedNotes = await Promise.all(
|
|
||||||
data.map(async (note) => ({
|
|
||||||
...note,
|
|
||||||
title: await decryptString(note.title, encryptionKey),
|
|
||||||
content: await decryptString(note.content, encryptionKey),
|
|
||||||
tags: note.tags
|
|
||||||
? await Promise.all(
|
|
||||||
note.tags.map(async (tag) => ({
|
|
||||||
...tag,
|
|
||||||
name: await decryptString(tag.name, encryptionKey),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
return decryptedNotes;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateNote = async (id: number, note: Partial<NoteRead>) => {
|
const updateNote = async (id: number, note: Partial<Note>) => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
if (!encryptionKey) throw new Error("Not authenticated");
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
var encryptedNote: Partial<NoteRead> = {};
|
var encryptedNote: Partial<Note> = {};
|
||||||
if (note.content) {
|
if (note.content) {
|
||||||
encryptedNote.content = await encryptString(note.content, encryptionKey);
|
encryptedNote.content = await encryptString(note.content, encryptionKey);
|
||||||
}
|
}
|
||||||
if (note.title) {
|
if (note.title) {
|
||||||
encryptedNote.title = await encryptString(note.title, encryptionKey);
|
encryptedNote.title = await encryptString(note.title, encryptionKey);
|
||||||
}
|
}
|
||||||
if (note.folderId) {
|
if (note.folder_id) {
|
||||||
encryptedNote.folderId = note.folderId;
|
encryptedNote.folder_id = note.folder_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await client.PATCH(`/notes/{note_id}`, {
|
return axios.patch(`${API_URL}/notes/${id}`, encryptedNote);
|
||||||
body: encryptedNote,
|
|
||||||
params: {
|
|
||||||
path: {
|
|
||||||
note_id: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
console.log(data);
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const notesApi = {
|
export const notesApi = {
|
||||||
list: () => fetchNotes(),
|
list: () => fetchNotes(),
|
||||||
get: (id: number) =>
|
get: (id: number) => axios.get(`${API_URL}/notes/${id}`),
|
||||||
client.GET(`/notes/{note_id}`, {
|
|
||||||
params: {
|
|
||||||
path: {
|
|
||||||
note_id: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
create: (note: NoteCreate) => createNote(note),
|
create: (note: NoteCreate) => createNote(note),
|
||||||
update: (id: number, note: Partial<NoteRead>) => updateNote(id, note),
|
update: (id: number, note: Partial<Note>) => updateNote(id, note),
|
||||||
delete: (id: number) =>
|
delete: (id: number) => axios.delete(`${API_URL}/notes/${id}`),
|
||||||
client.DELETE(`/notes/{note_id}`, {
|
|
||||||
params: {
|
|
||||||
path: {
|
|
||||||
note_id: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -1,10 +1,7 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { FolderTreeNode } from "../../api/folders";
|
import { FolderTreeNode } from "../../api/folders";
|
||||||
import {
|
import { useNoteStore } from "../../stores/notesStore";
|
||||||
useCreateFolder,
|
import { folderApi } from "../../api/folders";
|
||||||
useUpdateFolder,
|
|
||||||
useDeleteFolder,
|
|
||||||
} from "../../hooks/useFolders";
|
|
||||||
|
|
||||||
interface FolderContextMenuProps {
|
interface FolderContextMenuProps {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -19,10 +16,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
folder,
|
folder,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const createFolderMutation = useCreateFolder();
|
const { loadFolderTree, updateFolder } = useNoteStore();
|
||||||
const updateFolderMutation = useUpdateFolder();
|
|
||||||
const deleteFolderMutation = useDeleteFolder();
|
|
||||||
|
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const [newName, setNewName] = useState(folder.name);
|
const [newName, setNewName] = useState(folder.name);
|
||||||
|
|
||||||
|
|
@ -31,7 +25,8 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deleteFolderMutation.mutateAsync(folder.id);
|
await folderApi.delete(folder.id);
|
||||||
|
await loadFolderTree();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete folder:", error);
|
console.error("Failed to delete folder:", error);
|
||||||
|
|
@ -40,14 +35,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
|
|
||||||
const handleRename = async () => {
|
const handleRename = async () => {
|
||||||
if (newName.trim() && newName !== folder.name) {
|
if (newName.trim() && newName !== folder.name) {
|
||||||
try {
|
await updateFolder(folder.id, { name: newName });
|
||||||
await updateFolderMutation.mutateAsync({
|
|
||||||
folderId: folder.id,
|
|
||||||
folder: { name: newName },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to rename folder:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setIsRenaming(false);
|
setIsRenaming(false);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -55,10 +43,11 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
|
|
||||||
const handleCreateSubfolder = async () => {
|
const handleCreateSubfolder = async () => {
|
||||||
try {
|
try {
|
||||||
await createFolderMutation.mutateAsync({
|
await folderApi.create({
|
||||||
name: "New Folder",
|
name: "New Folder",
|
||||||
parentId: folder.id,
|
parent_id: folder.id,
|
||||||
});
|
});
|
||||||
|
await loadFolderTree();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create subfolder:", error);
|
console.error("Failed to create subfolder:", error);
|
||||||
|
|
@ -73,7 +62,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
top: y,
|
top: y,
|
||||||
left: x,
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
|
@ -89,7 +78,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
}}
|
}}
|
||||||
onBlur={handleRename}
|
onBlur={handleRename}
|
||||||
autoFocus
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -102,25 +91,25 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
top: y,
|
top: y,
|
||||||
left: x,
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsRenaming(true)}
|
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
|
Rename
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateSubfolder}
|
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
|
New Subfolder
|
||||||
</button>
|
</button>
|
||||||
<div className="border-t border-surface1 my-1" />
|
<div className="border-t border-ctp-surface2 my-1" />
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Note } from "../../api/notes";
|
import { NoteRead } from "../../api/folders";
|
||||||
import { useCreateNote, useDeleteNote } from "../../hooks/useFolders";
|
import { useNoteStore } from "../../stores/notesStore";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { notesApi } from "../../api/notes";
|
||||||
|
|
||||||
interface NoteContextMenuProps {
|
interface NoteContextMenuProps {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
note: Note;
|
note: NoteRead;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,15 +16,12 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
|
||||||
note,
|
note,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const { setSelectedNote } = useUIStore();
|
const { loadFolderTree, setSelectedNote } = useNoteStore();
|
||||||
const deleteNoteMutation = useDeleteNote();
|
|
||||||
const createNoteMutation = useCreateNote();
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteNoteMutation.mutateAsync(note.id);
|
await notesApi.delete(note.id);
|
||||||
// Clear selection if this note was selected
|
await loadFolderTree();
|
||||||
setSelectedNote(null);
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete note:", error);
|
console.error("Failed to delete note:", error);
|
||||||
|
|
@ -33,11 +30,12 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
|
||||||
|
|
||||||
const handleDuplicate = async () => {
|
const handleDuplicate = async () => {
|
||||||
try {
|
try {
|
||||||
await createNoteMutation.mutateAsync({
|
await notesApi.create({
|
||||||
title: `${note.title} (Copy)`,
|
title: `${note.title} (Copy)`,
|
||||||
content: note.content,
|
content: note.content,
|
||||||
folderId: note.folderId || null,
|
folder_id: note.folder_id,
|
||||||
});
|
});
|
||||||
|
await loadFolderTree();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to duplicate note:", error);
|
console.error("Failed to duplicate note:", error);
|
||||||
|
|
@ -57,25 +55,25 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
|
||||||
top: y,
|
top: y,
|
||||||
left: x,
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={handleRename}
|
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
|
Rename
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDuplicate}
|
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
|
Duplicate
|
||||||
</button>
|
</button>
|
||||||
<div className="border-t border-surface1 my-1" />
|
<div className="border-t border-ctp-surface2 my-1" />
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export const ContextMenuProvider = ({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
closeContextMenu();
|
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>
|
></div>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -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"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -2,28 +2,44 @@
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
@import "@catppuccin/tailwindcss/macchiato.css";
|
@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 {
|
@theme {
|
||||||
--color-base: #24273a;
|
--color-base:
|
||||||
--color-surface0: #1e2030;
|
;
|
||||||
--color-surface1: #181926;
|
}
|
||||||
--color-overlay0: #363a4f;
|
|
||||||
--color-overlay1: #494d64;
|
|
||||||
--color-text: #cad3f5;
|
|
||||||
--color-subtext: #b8c0e0;
|
|
||||||
--color-accent: #e2a16f;
|
|
||||||
|
|
||||||
--color-danger: #e26f6f;
|
@theme {
|
||||||
--color-success: #6fe29b;
|
/* Map Tailwind classes to CSS variables */
|
||||||
--color-warn: #e2c56f;
|
--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 */
|
||||||
|
|
@ -31,19 +47,19 @@
|
||||||
._mdxeditor-root-content-editable,
|
._mdxeditor-root-content-editable,
|
||||||
.mdxeditor-root-contenteditable,
|
.mdxeditor-root-contenteditable,
|
||||||
div[contenteditable="true"] {
|
div[contenteditable="true"] {
|
||||||
color: var(--color-text) !important;
|
color: var(--color-ctp-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
._listItemChecked_1tncs_73::before {
|
._listItemChecked_1tncs_73::before {
|
||||||
--accentSolid: var(--color-accent) !important;
|
--accentSolid: var(--color-ctp-mauve) !important;
|
||||||
border-color: var(--color-accent) !important;
|
border-color: var(--color-ctp-mauve-900) !important;
|
||||||
border: 2px;
|
border: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
._listItemChecked_1tncs_73::after {
|
._listItemChecked_1tncs_73::after {
|
||||||
border-color: var(--color-accent) !important;
|
border-color: var(--color-ctp-mauve-900) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standard-input {
|
.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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,11 @@ 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
// import "./assets/fontawesome/js/fontawesome.min.js";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
// import "./assets/fontawesome/js/duotone-regular.js";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<App />
|
||||||
<App />
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,62 @@
|
||||||
import {
|
import { useEffect, useRef, useState } from "react";
|
||||||
ChangeEvent,
|
|
||||||
ChangeEventHandler,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import "../../main.css";
|
import "../../main.css";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { useNoteStore } from "@/stores/notesStore";
|
||||||
import { useUIStore } from "@/stores/uiStore";
|
import { useUIStore } from "@/stores/uiStore";
|
||||||
import { Login } from "../Login";
|
import { Login } from "../Login";
|
||||||
import { TiptapEditor } from "../TipTap";
|
import { TiptapEditor } from "../TipTap";
|
||||||
import { Sidebar } from "./components/sidebar/SideBar";
|
import { Sidebar } from "./components/sidebar/SideBar";
|
||||||
import { StatusIndicator } from "./components/StatusIndicator";
|
import { StatusIndicator } from "./components/StatusIndicator";
|
||||||
import { useUpdateNote } from "@/hooks/useFolders";
|
|
||||||
import { NoteRead } from "@/api/notes";
|
|
||||||
// @ts-ignore
|
|
||||||
import XmarkIcon from "@/assets/fontawesome/svg/xmark.svg?react";
|
|
||||||
// @ts-ignore
|
|
||||||
import PlusIcon from "@/assets/fontawesome/svg/plus.svg?react";
|
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const [newFolder] = useState(false);
|
const [newFolder] = useState(false);
|
||||||
|
|
||||||
// Local state for editing the current note
|
|
||||||
const [editingNote, setEditingNote] = useState<NoteRead | null>(null);
|
|
||||||
const [lastSavedNote, setLastSavedNote] = useState<{
|
const [lastSavedNote, setLastSavedNote] = useState<{
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const { loadFolderTree, updateNote, setContent, selectedNote, setTitle } =
|
||||||
|
useNoteStore();
|
||||||
|
|
||||||
const { encryptionKey } = useAuthStore();
|
const { encryptionKey } = useAuthStore();
|
||||||
const { showModal, setUpdating, selectedNote, editorView } = useUIStore();
|
|
||||||
|
const { showModal, setUpdating } = useUIStore();
|
||||||
|
|
||||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const updateNoteMutation = useUpdateNote();
|
|
||||||
|
|
||||||
// Sync editingNote with selectedNote when selection changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedNote) {
|
if (!encryptionKey) return;
|
||||||
setEditingNote(selectedNote);
|
loadFolderTree();
|
||||||
setLastSavedNote({
|
}, []);
|
||||||
id: selectedNote.id,
|
|
||||||
title: selectedNote.title,
|
|
||||||
content: selectedNote.content,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setEditingNote(null);
|
|
||||||
setLastSavedNote(null);
|
|
||||||
}
|
|
||||||
}, [selectedNote?.id]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (newFolder && newFolderRef.current) {
|
if (newFolder && newFolderRef.current) {
|
||||||
newFolderRef.current.focus();
|
newFolderRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [newFolder]);
|
}, [newFolder]);
|
||||||
|
|
||||||
// Auto-save effect - watches editingNote for changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editingNote) return;
|
if (!selectedNote) return;
|
||||||
if (!encryptionKey) 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 =
|
const hasChanges =
|
||||||
lastSavedNote &&
|
lastSavedNote &&
|
||||||
lastSavedNote.id === editingNote.id &&
|
lastSavedNote.id === selectedNote.id &&
|
||||||
(lastSavedNote.title !== editingNote.title ||
|
(lastSavedNote.title !== selectedNote.title ||
|
||||||
lastSavedNote.content !== editingNote.content);
|
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;
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
|
@ -75,31 +64,25 @@ function Home() {
|
||||||
setUpdating(true);
|
setUpdating(true);
|
||||||
await handleUpdate();
|
await handleUpdate();
|
||||||
setLastSavedNote({
|
setLastSavedNote({
|
||||||
id: editingNote.id,
|
id: selectedNote.id,
|
||||||
title: editingNote.title,
|
title: selectedNote.title,
|
||||||
content: editingNote.content,
|
content: selectedNote.content,
|
||||||
});
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [editingNote?.title, editingNote?.content, encryptionKey]);
|
}, [selectedNote, encryptionKey]);
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
if (!editingNote) return;
|
if (!selectedNote) return;
|
||||||
if (!encryptionKey) {
|
if (!encryptionKey) {
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!editingNote.id) throw new Error("Editing note has no id.");
|
await updateNote(selectedNote.id);
|
||||||
await updateNoteMutation.mutateAsync({
|
console.log(selectedNote.id);
|
||||||
noteId: editingNote.id,
|
|
||||||
note: {
|
|
||||||
title: editingNote.title,
|
|
||||||
content: editingNote.content,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update note:", error);
|
console.error("Failed to update note:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -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 (
|
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 */}
|
{/* Sidebar */}
|
||||||
<AnimatePresence>{showModal && <Modal />}</AnimatePresence>
|
{showModal && <Modal />}
|
||||||
|
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
||||||
{/* Main editor area */}
|
{/* Main editor area */}
|
||||||
<div className="flex flex-col w-full h-screen overflow-hidden">
|
<div className="flex flex-col w-full h-screen overflow-hidden">
|
||||||
{" "}
|
{/*<Editor />*/}
|
||||||
{editingNote ? (
|
<input
|
||||||
<>
|
type="text"
|
||||||
<input
|
placeholder="Untitled note..."
|
||||||
type="text"
|
value={selectedNote?.title || ""}
|
||||||
id="noteTitle"
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Untitled note..."
|
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"
|
||||||
value={editingNote.title || ""}
|
/>
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
<TiptapEditor
|
||||||
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"
|
key={selectedNote?.id}
|
||||||
/>
|
content={selectedNote?.content || ""}
|
||||||
<div className="h-full lg:w-3xl w-full mx-auto overflow-y-hidden">
|
onChange={setContent}
|
||||||
{" "}
|
/>
|
||||||
{editorView == "parsed" ? (
|
|
||||||
<TiptapEditor
|
|
||||||
key={editingNote.id}
|
|
||||||
content={editingNote.content || ""}
|
|
||||||
onChange={setContent}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<textarea
|
|
||||||
value={editingNote.content || ""}
|
|
||||||
className="w-full font-mono p-4 bg-transparent focus:outline-none resize-none text-text"
|
|
||||||
style={{
|
|
||||||
minHeight: "calc(100vh - 55px)",
|
|
||||||
}}
|
|
||||||
onChange={setUnparsedContent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full text-overlay0">
|
|
||||||
<div className="text-center">
|
|
||||||
<PlusIcon className="w-16 h-16 mx-auto mb-4 fill-current opacity-50" />
|
|
||||||
<p className="text-lg">Select a note or create a new one</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusIndicator />
|
<StatusIndicator />
|
||||||
|
|
@ -185,35 +124,20 @@ function Home() {
|
||||||
export default Home;
|
export default Home;
|
||||||
|
|
||||||
const Modal = () => {
|
const Modal = () => {
|
||||||
const { setShowModal, modalContent, showModal } = useUIStore();
|
const { setShowModal } = useUIStore();
|
||||||
const ModalContent = modalContent;
|
|
||||||
if (!showModal || !ModalContent) return null;
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={() => setShowModal(false)}
|
onClick={() => setShowModal(false)}
|
||||||
className="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
|
<div
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
transition={{ type: "spring", duration: 0.3 }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="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
|
<Login />
|
||||||
onClick={() => setShowModal(false)}
|
</div>
|
||||||
className="absolute top-4 right-4 p-2 hover:bg-surface0 rounded-sm transition-colors group"
|
|
||||||
aria-label="Close modal"
|
|
||||||
>
|
|
||||||
<XmarkIcon className="w-5 h-5 fill-overlay0 group-hover:fill-text transition-colors" />
|
|
||||||
</button>
|
|
||||||
<ModalContent />
|
|
||||||
{/*<Login />*/}
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,43 +6,32 @@ import CheckIcon from "../../../assets/fontawesome/svg/circle-check.svg?react";
|
||||||
import SpinnerIcon from "../../../assets/fontawesome/svg/rotate.svg?react";
|
import SpinnerIcon from "../../../assets/fontawesome/svg/rotate.svg?react";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import WarningIcon from "../../../assets/fontawesome/svg/circle-exclamation.svg?react";
|
import WarningIcon from "../../../assets/fontawesome/svg/circle-exclamation.svg?react";
|
||||||
import { Login } from "@/pages/Login";
|
|
||||||
|
|
||||||
export const StatusIndicator = () => {
|
export const StatusIndicator = () => {
|
||||||
const { encryptionKey } = useAuthStore();
|
const { encryptionKey } = useAuthStore();
|
||||||
const { updating, setShowModal, editorView, setEditorView, setModalContent } =
|
const { updating, setShowModal } = useUIStore();
|
||||||
useUIStore();
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={() => {
|
onClick={() => {
|
||||||
if (!encryptionKey) {
|
if (!encryptionKey) {
|
||||||
setModalContent(Login);
|
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
className="select-none"
|
|
||||||
onClick={() =>
|
|
||||||
setEditorView(editorView == "parsed" ? "unparsed" : "parsed")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{editorView}
|
|
||||||
</div>
|
|
||||||
{!encryptionKey ? (
|
{!encryptionKey ? (
|
||||||
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-warn [&_.fa-secondary]:fill-orange" />
|
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-ctp-yellow [&_.fa-secondary]:fill-ctp-orange" />
|
||||||
) : updating ? (
|
) : updating ? (
|
||||||
<>
|
<>
|
||||||
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-warn [&_.fa-secondary]:fill-sapphire" />
|
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-ctp-blue [&_.fa-secondary]:fill-ctp-sapphire" />
|
||||||
{/*<span className="text-sm text-subtext font-medium">
|
<span className="text-sm text-ctp-subtext0 font-medium">
|
||||||
Saving...
|
Saving...
|
||||||
</span>*/}
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="h-4 w-4 [&_.fa-primary]:fill-success [&_.fa-secondary]:fill-teal" />
|
<CheckIcon className="h-4 w-4 [&_.fa-primary]:fill-ctp-green [&_.fa-secondary]:fill-ctp-teal" />
|
||||||
{/*<span className="text-sm text-subtext font-medium">Saved</span>*/}
|
<span className="text-sm text-ctp-subtext0 font-medium">Saved</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect, SetStateAction } from "react";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
|
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
|
||||||
// @ts-ignore
|
|
||||||
import TagsIcon from "@/assets/fontawesome/svg/tags.svg?react";
|
|
||||||
import { DraggableNote } from "./subcomponents/DraggableNote";
|
import { DraggableNote } from "./subcomponents/DraggableNote";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,13 +17,8 @@ import {
|
||||||
import { FolderTree } from "./subcomponents/FolderTree.tsx";
|
import { FolderTree } from "./subcomponents/FolderTree.tsx";
|
||||||
import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
|
import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
|
||||||
import { useAuthStore } from "@/stores/authStore.ts";
|
import { useAuthStore } from "@/stores/authStore.ts";
|
||||||
|
import { useNoteStore } from "@/stores/notesStore.ts";
|
||||||
import { useUIStore } from "@/stores/uiStore.ts";
|
import { useUIStore } from "@/stores/uiStore.ts";
|
||||||
import {
|
|
||||||
useCreateFolder,
|
|
||||||
useFolderTree,
|
|
||||||
useUpdateFolder,
|
|
||||||
useUpdateNote,
|
|
||||||
} from "@/hooks/useFolders.ts";
|
|
||||||
|
|
||||||
export const Sidebar = () => {
|
export const Sidebar = () => {
|
||||||
const [newFolder, setNewFolder] = useState(false);
|
const [newFolder, setNewFolder] = useState(false);
|
||||||
|
|
@ -36,12 +29,17 @@ export const Sidebar = () => {
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { data: folderTree, isLoading, error } = useFolderTree();
|
const {
|
||||||
const createFolder = useCreateFolder();
|
folderTree,
|
||||||
|
loadFolderTree,
|
||||||
|
moveNoteToFolder,
|
||||||
|
moveFolderToFolder,
|
||||||
|
createFolder,
|
||||||
|
} = useNoteStore();
|
||||||
|
|
||||||
const { encryptionKey } = useAuthStore();
|
const { encryptionKey } = useAuthStore();
|
||||||
|
|
||||||
const { setSideBarResize, sideBarResize, setColourScheme } = useUIStore();
|
const { setSideBarResize, sideBarResize } = useUIStore();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (newFolder && newFolderRef.current) {
|
if (newFolder && newFolderRef.current) {
|
||||||
newFolderRef.current.focus();
|
newFolderRef.current.focus();
|
||||||
|
|
@ -50,11 +48,17 @@ export const Sidebar = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!encryptionKey) return;
|
if (!encryptionKey) return;
|
||||||
|
loadFolderTree();
|
||||||
}, [encryptionKey]);
|
}, [encryptionKey]);
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (!newFolderText.trim()) return;
|
if (!newFolderText.trim()) return;
|
||||||
createFolder.mutate({ name: newFolderText, parentId: null });
|
await createFolder({
|
||||||
|
name: newFolderText,
|
||||||
|
parent_id: null,
|
||||||
|
});
|
||||||
|
setNewFolderText("");
|
||||||
|
setNewFolder(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pointer = useSensor(PointerSensor, {
|
const pointer = useSensor(PointerSensor, {
|
||||||
|
|
@ -73,9 +77,6 @@ export const Sidebar = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateNote = useUpdateNote();
|
|
||||||
const updateFolder = useUpdateFolder();
|
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
setActiveItem(null);
|
setActiveItem(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
@ -90,11 +91,8 @@ export const Sidebar = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (active.data.current?.type === "note") {
|
if (active.data.current?.type === "note") {
|
||||||
console.log("Updating note ", active.id, "to folder", over.id);
|
console.log("Updating note", active.id, "to folder", over.id);
|
||||||
updateNote.mutate({
|
await moveNoteToFolder(active.id as number, over.id as number);
|
||||||
noteId: active.id as number,
|
|
||||||
note: { folderId: over.id as number },
|
|
||||||
});
|
|
||||||
} else if (active.data.current?.type === "folder") {
|
} else if (active.data.current?.type === "folder") {
|
||||||
// Prevent dropping folder into itself
|
// Prevent dropping folder into itself
|
||||||
if (active.data.current.folder.id === over.id) {
|
if (active.data.current.folder.id === over.id) {
|
||||||
|
|
@ -109,10 +107,10 @@ export const Sidebar = () => {
|
||||||
over.id,
|
over.id,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
updateFolder.mutate({
|
await moveFolderToFolder(
|
||||||
folderId: active.data.current.folder.id,
|
active.data.current.folder.id,
|
||||||
folder: { parentId: over.id as number },
|
over.id as number,
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update folder:", error);
|
console.error("Failed to update folder:", error);
|
||||||
return;
|
return;
|
||||||
|
|
@ -161,100 +159,75 @@ export const Sidebar = () => {
|
||||||
autoScroll={false}
|
autoScroll={false}
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
>
|
>
|
||||||
<div className="flex-row-reverse flex h-screen">
|
<div className="flex-row-reverse flex">
|
||||||
<div
|
<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}
|
onMouseDown={handleMouseDown}
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
className="flex flex-col h-full"
|
className="flex flex-col min-h-full"
|
||||||
style={{ width: `${sideBarResize}px` }}
|
style={{ width: `${sideBarResize}px` }}
|
||||||
>
|
>
|
||||||
<SidebarHeader setNewFolder={setNewFolder} />
|
<SidebarHeader setNewFolder={setNewFolder} />
|
||||||
<div className="flex-1 overflow-y-auto bg-surface1 border-r border-surface1">
|
<div
|
||||||
<>
|
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"
|
||||||
<div
|
onDragOver={(e) => e.preventDefault()}
|
||||||
className="w-full p-4 sm:block hidden"
|
onTouchMove={(e) => e.preventDefault()}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
>
|
||||||
onTouchMove={(e) => e.preventDefault()}
|
{/* New folder input */}
|
||||||
>
|
{newFolder && (
|
||||||
{/* New folder input */}
|
<div className="mb-2">
|
||||||
{newFolder && (
|
<input
|
||||||
<div className="mb-2">
|
onBlur={() => setNewFolder(false)}
|
||||||
<input
|
onChange={(e) => setNewFolderText(e.target.value)}
|
||||||
onBlur={() => setNewFolder(false)}
|
value={newFolderText}
|
||||||
onChange={(e) => setNewFolderText(e.target.value)}
|
type="text"
|
||||||
value={newFolderText}
|
placeholder="Folder name..."
|
||||||
type="text"
|
className="standard-input"
|
||||||
placeholder="Folder name..."
|
ref={newFolderRef}
|
||||||
className="standard-input"
|
onKeyDown={(e) => {
|
||||||
ref={newFolderRef}
|
if (e.key === "Enter") {
|
||||||
onKeyDown={(e) => {
|
handleCreateFolder();
|
||||||
if (e.key === "Enter") {
|
}
|
||||||
handleCreateFolder();
|
if (e.key === "Escape") {
|
||||||
}
|
setNewFolder(false);
|
||||||
if (e.key === "Escape") {
|
}
|
||||||
setNewFolder(false);
|
}}
|
||||||
}
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center py-8 text-subtext0">
|
|
||||||
<div className="text-sm">Loading folders...</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error state */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center justify-center py-8 text-danger">
|
|
||||||
<div className="text-sm">Failed to load folders</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Folder tree */}
|
|
||||||
{!isLoading && !error && (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{folderTree?.folders.map((folder) => (
|
|
||||||
<FolderTree key={folder.id} folder={folder} depth={0} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Orphaned notes */}
|
|
||||||
{folderTree?.orphanedNotes &&
|
|
||||||
folderTree.orphanedNotes.length > 0 && (
|
|
||||||
<div className="mt-4 flex flex-col gap-1">
|
|
||||||
{folderTree.orphanedNotes.map((note) => (
|
|
||||||
<DraggableNote key={note.id} note={note} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/*<div className="fixed bottom-1 left-2">
|
)}
|
||||||
<button onClick={setColour}>purple</button>
|
|
||||||
</div>*/}
|
|
||||||
|
|
||||||
<DragOverlay>
|
{/* Folder tree */}
|
||||||
{activeItem?.type === "note" && (
|
<div className="flex flex-col gap-1">
|
||||||
<div className="bg-surface0 rounded-md px-2 py-1 shadow-lg border border-accent">
|
{folderTree?.folders.map((folder) => (
|
||||||
{activeItem.data.title}
|
<FolderTree key={folder.id} folder={folder} depth={0} />
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
{activeItem?.type === "folder" && (
|
|
||||||
<div className="bg-surface0 rounded-md px-1 py-0.5 shadow-lg flex items-center gap-1 text-sm">
|
{/* Orphaned notes */}
|
||||||
<FolderIcon className="w-3 h-3 fill-accent mr-1" />
|
{folderTree?.orphaned_notes &&
|
||||||
{activeItem.data.name}
|
folderTree.orphaned_notes.length > 0 && (
|
||||||
</div>
|
<div className="mt-4 flex flex-col gap-1">
|
||||||
)}
|
{folderTree.orphaned_notes.map((note) => (
|
||||||
</DragOverlay>
|
<DraggableNote key={note.id} note={note} />
|
||||||
</>
|
))}
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
||||||
import { useUIStore } from "@/stores/uiStore";
|
import { useNoteStore } from "@/stores/notesStore";
|
||||||
import { NoteRead } from "@/api/notes";
|
import { NoteRead } from "@/api/folders";
|
||||||
|
|
||||||
export const DraggableNote = ({ note }: { note: NoteRead }) => {
|
export const DraggableNote = ({ note }: { note: NoteRead }) => {
|
||||||
const { selectedNote, setSelectedNote } = useUIStore();
|
const { selectedNote, setSelectedNote } = useNoteStore();
|
||||||
const { openContextMenu } = useContextMenu();
|
const { openContextMenu } = useContextMenu();
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||||
|
|
@ -39,8 +39,8 @@ export const DraggableNote = ({ note }: { note: NoteRead }) => {
|
||||||
}}
|
}}
|
||||||
className={` rounded-sm px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${
|
className={` rounded-sm px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${
|
||||||
selectedNote?.id === note.id
|
selectedNote?.id === note.id
|
||||||
? "bg-accent text-base"
|
? "bg-ctp-mauve text-ctp-base"
|
||||||
: "hover:bg-surface1"
|
: "hover:bg-ctp-surface1"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useDroppable, useDraggable } from "@dnd-kit/core";
|
||||||
import CaretRightIcon from "@/assets/fontawesome/svg/caret-right.svg?react";
|
import CaretRightIcon from "@/assets/fontawesome/svg/caret-right.svg?react";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
|
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
|
||||||
import { FolderTreeNode } from "@/api/folders";
|
import { Folder } from "@/api/folders";
|
||||||
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
||||||
|
|
||||||
export const DroppableFolder = ({
|
export const DroppableFolder = ({
|
||||||
|
|
@ -12,7 +12,7 @@ export const DroppableFolder = ({
|
||||||
setCollapse,
|
setCollapse,
|
||||||
collapse,
|
collapse,
|
||||||
}: {
|
}: {
|
||||||
folder: Partial<FolderTreeNode>;
|
folder: Partial<Folder>;
|
||||||
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
|
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
collapse: boolean;
|
collapse: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -63,12 +63,10 @@ export const DroppableFolder = ({
|
||||||
{...listeners}
|
{...listeners}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
{(folder.notes?.length ?? 0) > 0 && (
|
<CaretRightIcon
|
||||||
<CaretRightIcon
|
className={`w-4 h-4 min-h-4 min-w-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-ctp-mauve`}
|
||||||
className={`w-4 h-4 min-h-4 min-w-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-accent`}
|
/>
|
||||||
/>
|
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-ctp-mauve mr-1" />
|
||||||
)}
|
|
||||||
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-accent mr-1" />
|
|
||||||
<span className="truncate">{folder.name}</span>
|
<span className="truncate">{folder.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export const FolderTree = ({ folder, depth = 0 }: FolderTreeProps) => {
|
||||||
className="overflow-hidden flex flex-col"
|
className="overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
{/* The line container */}
|
{/* The line container */}
|
||||||
<div className="ml-2 pl-3 border-l border-surface1">
|
<div className="ml-2 pl-3 border-l border-ctp-surface2">
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{folder.notes.map((note) => (
|
{folder.notes.map((note) => (
|
||||||
|
|
|
||||||
|
|
@ -2,87 +2,38 @@ import { SetStateAction } from "react";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
|
import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import TagsIcon from "@assets/fontawesome/svg/tags.svg?react";
|
|
||||||
// @ts-ignore
|
|
||||||
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
|
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
|
||||||
// @ts-ignore
|
import { useNoteStore } from "@/stores/notesStore";
|
||||||
import GearIcon from "@assets/fontawesome/svg/gear.svg?react";
|
|
||||||
import { useUIStore } from "@/stores/uiStore";
|
|
||||||
import { useCreateNote } from "@/hooks/useFolders";
|
|
||||||
import { NoteCreate } from "@/api/notes";
|
|
||||||
import { Login } from "@/pages/Login";
|
|
||||||
import { ColourState } from "@/stores/uiStore";
|
|
||||||
|
|
||||||
const Test = () => {
|
|
||||||
const { colourScheme, setColourScheme } = useUIStore();
|
|
||||||
|
|
||||||
const handleColor = (key: string, value: string) => {
|
|
||||||
setColourScheme({
|
|
||||||
...colourScheme,
|
|
||||||
[key]: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Object.entries(colourScheme).map(([key, value]) => (
|
|
||||||
<div key={key}>
|
|
||||||
<label>{key}</label>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => handleColor(key, e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SidebarHeader = ({
|
export const SidebarHeader = ({
|
||||||
setNewFolder,
|
setNewFolder,
|
||||||
}: {
|
}: {
|
||||||
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
|
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
|
||||||
}) => {
|
}) => {
|
||||||
const { selectedFolder, setShowModal, setModalContent } = useUIStore();
|
const { createNote, selectedFolder } = useNoteStore();
|
||||||
const createNote = useCreateNote();
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
createNote.mutate({
|
await createNote({
|
||||||
title: "Untitled",
|
title: "Untitled",
|
||||||
content: "",
|
content: "",
|
||||||
folder_id: selectedFolder,
|
folder_id: selectedFolder,
|
||||||
} as NoteCreate);
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettings = () => {
|
|
||||||
setModalContent(Test);
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="w-full p-2 border-b border-surface1 bg-surface1">
|
<div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1">
|
||||||
<div className="flex items-center justify-around bg-surface0 rounded-lg p-1 gap-1">
|
<button
|
||||||
<button
|
onClick={() => setNewFolder(true)}
|
||||||
onClick={() => setNewFolder(true)}
|
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2"
|
||||||
className="hover:bg-accent active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
|
title="New folder"
|
||||||
title="New folder"
|
>
|
||||||
>
|
<FolderPlusIcon className="w-4 h-4 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
|
||||||
<FolderPlusIcon className="w-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
onClick={handleCreate}
|
||||||
onClick={handleCreate}
|
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2 fill-ctp-mauve hover:fill-ctp-base"
|
||||||
className="hover:bg-accent active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
|
title="New note"
|
||||||
title="New note"
|
>
|
||||||
>
|
<FileCirclePlusIcon className="w-4 h-4 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
|
||||||
<FileCirclePlusIcon className="w-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent" />
|
</button>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSettings}
|
|
||||||
className="hover:bg-accent active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
|
|
||||||
title="New note"
|
|
||||||
>
|
|
||||||
<GearIcon className="w-5 h-5 group-hover:fill-base transition-all duration-200 fill-accent" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -26,62 +26,31 @@ export const Login = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form onSubmit={handleSubmit} className="gap-2 flex flex-col">
|
||||||
onSubmit={handleSubmit}
|
<input
|
||||||
className="gap-4 flex flex-col max-w-md mx-auto"
|
type="text"
|
||||||
>
|
placeholder="Username"
|
||||||
<h2 className="text-2xl font-semibold text-text mb-2">Welcome Back</h2>
|
className="standard-input"
|
||||||
|
value={username}
|
||||||
<div className="flex flex-col gap-2">
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
<label className="text-sm font-medium text-subtext">Username</label>
|
/>
|
||||||
|
<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
|
<input
|
||||||
type="text"
|
type="check box"
|
||||||
placeholder="Enter your username"
|
|
||||||
className="standard-input"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label className="text-sm font-medium text-subtext">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="standard-input"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-danger/10 border border-danger text-danger px-3 py-2 rounded-sm text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="remember"
|
|
||||||
checked={remember}
|
checked={remember}
|
||||||
onChange={(e) => setRemember(e.target.checked)}
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
className="accent-accent cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
<label
|
<div>Remember me?</div>
|
||||||
htmlFor="remember"
|
|
||||||
className="text-sm text-subtext cursor-pointer"
|
|
||||||
>
|
|
||||||
Remember me
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-accent hover:bg-accent/90 text-base font-semibold px-4 py-2.5 rounded-sm transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-base"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -23,57 +23,29 @@ export const Register = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form onSubmit={handleSubmit}>
|
||||||
onSubmit={handleSubmit}
|
<input
|
||||||
className="gap-4 flex flex-col max-w-md mx-auto"
|
type="text"
|
||||||
>
|
placeholder="Username"
|
||||||
<h2 className="text-2xl font-semibold text-text mb-2">Create Account</h2>
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
<div className="flex flex-col gap-2">
|
/>
|
||||||
<label className="text-sm font-medium text-subtext">Username</label>
|
<input
|
||||||
<input
|
type="email"
|
||||||
type="text"
|
placeholder="Email"
|
||||||
placeholder="Choose a username"
|
value={email}
|
||||||
className="standard-input"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
value={username}
|
/>
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
<input
|
||||||
/>
|
type="password"
|
||||||
</div>
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
<div className="flex flex-col gap-2">
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
<label className="text-sm font-medium text-subtext">Email</label>
|
/>
|
||||||
<input
|
{error && <div>{error}</div>}
|
||||||
type="email"
|
<button type="submit">Login</button>
|
||||||
placeholder="Enter your email"
|
|
||||||
className="standard-input"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label className="text-sm font-medium text-subtext">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="standard-input"
|
|
||||||
placeholder="Create a password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-danger/10 border border-danger text-danger px-3 py-2 rounded-sm text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-accent hover:bg-accent/90 text-base font-semibold px-4 py-2.5 rounded-sm transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-base"
|
|
||||||
>
|
|
||||||
Register
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Similar pattern for Register.tsx
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export const Test = () => {
|
export const Test = () => {
|
||||||
return (
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Folder name..."
|
placeholder="Folder name..."
|
||||||
|
|
|
||||||
|
|
@ -79,12 +79,7 @@ export const TiptapEditor = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="tiptap-editor h-full">
|
||||||
className="tiptap-editor pt-0! overflow-y-scroll"
|
|
||||||
style={{
|
|
||||||
minHeight: "calc(100vh - 55px)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
{/*<div className="editor-toolbar">
|
{/*<div className="editor-toolbar">
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
|
|
@ -93,28 +88,28 @@ export const TiptapEditor = ({
|
||||||
className={editor.isActive("bold") ? "active" : ""}
|
className={editor.isActive("bold") ? "active" : ""}
|
||||||
title="Bold (Ctrl+B)"
|
title="Bold (Ctrl+B)"
|
||||||
>
|
>
|
||||||
<BoldIcon className="w-4 h-4 fill-text" />
|
<BoldIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
className={editor.isActive("italic") ? "active" : ""}
|
className={editor.isActive("italic") ? "active" : ""}
|
||||||
title="Italic (Ctrl+I)"
|
title="Italic (Ctrl+I)"
|
||||||
>
|
>
|
||||||
<ItalicIcon className="w-4 h-4 fill-text" />
|
<ItalicIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
className={editor.isActive("strike") ? "active" : ""}
|
className={editor.isActive("strike") ? "active" : ""}
|
||||||
title="Strikethrough"
|
title="Strikethrough"
|
||||||
>
|
>
|
||||||
<StrikethroughIcon className="w-4 h-4 fill-text" />
|
<StrikethroughIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
className={editor.isActive("code") ? "active" : ""}
|
className={editor.isActive("code") ? "active" : ""}
|
||||||
title="Inline code"
|
title="Inline code"
|
||||||
>
|
>
|
||||||
<CodeIcon className="w-4 h-4 fill-text" />
|
<CodeIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -158,35 +153,35 @@ export const TiptapEditor = ({
|
||||||
className={editor.isActive("bulletList") ? "active" : ""}
|
className={editor.isActive("bulletList") ? "active" : ""}
|
||||||
title="Bullet list"
|
title="Bullet list"
|
||||||
>
|
>
|
||||||
<ListUlIcon className="w-4 h-4 fill-text" />
|
<ListUlIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
className={editor.isActive("orderedList") ? "active" : ""}
|
className={editor.isActive("orderedList") ? "active" : ""}
|
||||||
title="Numbered list"
|
title="Numbered list"
|
||||||
>
|
>
|
||||||
<ListOlIcon className="w-4 h-4 fill-text" />
|
<ListOlIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||||
className={editor.isActive("taskList") ? "active" : ""}
|
className={editor.isActive("taskList") ? "active" : ""}
|
||||||
title="Task list"
|
title="Task list"
|
||||||
>
|
>
|
||||||
<SquareCheckIcon className="w-4 h-4 fill-text" />
|
<SquareCheckIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
className={editor.isActive("codeBlock") ? "active" : ""}
|
className={editor.isActive("codeBlock") ? "active" : ""}
|
||||||
title="Code block"
|
title="Code block"
|
||||||
>
|
>
|
||||||
<CodeBracketIcon className="w-4 h-4 fill-text" />
|
<CodeBracketIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
className={editor.isActive("blockquote") ? "active" : ""}
|
className={editor.isActive("blockquote") ? "active" : ""}
|
||||||
title="Quote"
|
title="Quote"
|
||||||
>
|
>
|
||||||
<QuoteLeftIcon className="w-4 h-4 fill-text" />
|
<QuoteLeftIcon className="w-4 h-4 fill-ctp-text" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -196,7 +191,7 @@ export const TiptapEditor = ({
|
||||||
{/* Editor content */}
|
{/* Editor content */}
|
||||||
<EditorContent
|
<EditorContent
|
||||||
editor={editor}
|
editor={editor}
|
||||||
className="editor-content h-min-screen p-4! pt-0!"
|
className="editor-content h-min-screen p-4!"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,37 +7,33 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
*::-webkit-scrollbar-track {
|
||||||
@apply bg-surface0 rounded-full;
|
@apply bg-ctp-mantle rounded-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
*::-webkit-scrollbar-thumb {
|
||||||
@apply bg-surface1 rounded-full;
|
@apply bg-ctp-surface2 rounded-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb:hover {
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
@apply bg-overlay0;
|
@apply bg-ctp-overlay0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Firefox scrollbar */
|
/* Firefox scrollbar */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--color-surface1) var(--color-surface0);
|
scrollbar-color: var(--color-ctp-surface2) var(--color-ctp-mantle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor {
|
.tiptap-editor {
|
||||||
@apply flex flex-col h-full bg-base;
|
@apply flex flex-col h-full bg-ctp-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
@apply text-text;
|
@apply text-ctp-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
@apply flex gap-2 px-4 bg-surface0 border-b border-surface1 flex-wrap items-center;
|
@apply flex gap-2 px-4 bg-ctp-mantle border-b border-ctp-surface2 flex-wrap items-center;
|
||||||
}
|
|
||||||
|
|
||||||
.editor-content {
|
|
||||||
@apply h-full;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-group {
|
.toolbar-group {
|
||||||
|
|
@ -45,19 +41,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-divider {
|
.toolbar-divider {
|
||||||
@apply w-px h-6 bg-surface1;
|
@apply w-px h-6 bg-ctp-surface2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar button {
|
.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) {
|
.editor-toolbar button:hover:not(:disabled) {
|
||||||
@apply bg-surface0;
|
@apply bg-ctp-surface0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar button.active {
|
.editor-toolbar button.active {
|
||||||
@apply bg-accent text-base;
|
@apply bg-ctp-mauve text-ctp-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar button:disabled {
|
.editor-toolbar button:disabled {
|
||||||
|
|
@ -70,59 +66,59 @@
|
||||||
|
|
||||||
.ProseMirror p.is-editor-empty:first-child::before {
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
@apply float-left text-overlay0 pointer-events-none h-0;
|
@apply float-left text-ctp-overlay0 pointer-events-none h-0;
|
||||||
}
|
}
|
||||||
.ProseMirror ul {
|
.ProseMirror ul {
|
||||||
@apply mb-0!;
|
@apply mb-0!;
|
||||||
}
|
}
|
||||||
.ProseMirror h1 {
|
.ProseMirror h1 {
|
||||||
@apply text-3xl font-bold mt-6 mb-4 text-accent;
|
@apply text-3xl font-bold text-ctp-mauve mt-8 mb-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror h2 {
|
.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 {
|
.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 {
|
.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;
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror .code-block {
|
.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;
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror .code-block code {
|
.ProseMirror .code-block code {
|
||||||
@apply bg-transparent p-0 text-text;
|
@apply bg-transparent p-0 text-ctp-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror blockquote {
|
.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 {
|
.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 {
|
.ProseMirror a {
|
||||||
@apply text-accent underline;
|
@apply text-ctp-blue underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror a:hover {
|
.ProseMirror a:hover {
|
||||||
@apply text-accent;
|
@apply text-ctp-sapphire;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror strong {
|
.ProseMirror strong {
|
||||||
@apply text-accent font-semibold;
|
@apply text-ctp-peach font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror em {
|
.ProseMirror em {
|
||||||
@apply text-accent;
|
@apply text-ctp-yellow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Task List (Checkboxes) */
|
/* Task List (Checkboxes) */
|
||||||
|
|
@ -139,7 +135,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror ul[data-type="taskList"] > li > label input[type="checkbox"] {
|
.ProseMirror ul[data-type="taskList"] > li > label input[type="checkbox"] {
|
||||||
@apply cursor-pointer m-0 accent-accent;
|
@apply cursor-pointer m-0 accent-ctp-mauve;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror ul[data-type="taskList"] > li > div {
|
.ProseMirror ul[data-type="taskList"] > li > div {
|
||||||
|
|
@ -151,18 +147,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror li[data-checked="true"] > div > p {
|
.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-style: wavy;
|
||||||
text-decoration-thickness: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror u {
|
.ProseMirror u {
|
||||||
@apply decoration-accent;
|
@apply decoration-ctp-mauve;
|
||||||
/*text-decoration-style: wavy;*/
|
text-decoration-style: wavy;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror li::marker {
|
.ProseMirror li::marker {
|
||||||
@apply text-accent;
|
@apply text-ctp-mauve;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tiptap.css */
|
/* tiptap.css */
|
||||||
|
|
@ -176,19 +171,3 @@
|
||||||
margin-top: 0 !important;
|
margin-top: 0 !important;
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
height: 3px;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--color-accent) 0px,
|
|
||||||
var(--color-accent) 8px,
|
|
||||||
var(--color-accent) 16px
|
|
||||||
);
|
|
||||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 6'%3E%3Cpath d='M0 3 Q 3 0, 6 3 T 12 3 T 18 3 T 24 3' stroke='black' stroke-width='2' fill='none'/%3E%3C/svg%3E")
|
|
||||||
repeat-x;
|
|
||||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 6'%3E%3Cpath d='M0 3 Q 3 0, 6 3 T 12 3 T 18 3 T 24 3' stroke='black' stroke-width='2' fill='none'/%3E%3C/svg%3E")
|
|
||||||
repeat-x;
|
|
||||||
margin: 2em 0;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,15 @@ interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
salt: string;
|
salt: string; // For key derivation
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
encryptionKey: CryptoKey | null;
|
encryptionKey: CryptoKey | null; // Memory only!
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
rememberMe: boolean;
|
rememberMe: boolean;
|
||||||
setRememberMe: (remember: boolean) => void;
|
setRememberMe: (boolean) => void;
|
||||||
|
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
register: (
|
register: (
|
||||||
|
|
@ -30,12 +30,9 @@ interface AuthState {
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
initEncryptionKey: (password: string, salt: string) => Promise<void>;
|
initEncryptionKey: (password: string, salt: string) => Promise<void>;
|
||||||
clearAll: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = import.meta.env.PROD
|
const API_URL = "http://localhost:8000/api";
|
||||||
? "/api" // ← Same domain, different path
|
|
||||||
: "http://localhost:8000/api";
|
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
|
|
@ -48,6 +45,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
set({ rememberMe: bool });
|
set({ rememberMe: bool });
|
||||||
},
|
},
|
||||||
initEncryptionKey: async (password: string, salt: string) => {
|
initEncryptionKey: async (password: string, salt: string) => {
|
||||||
|
// Use user-specific salt instead of hardcoded
|
||||||
const key = await deriveKey(password, salt);
|
const key = await deriveKey(password, salt);
|
||||||
set({ encryptionKey: key });
|
set({ encryptionKey: key });
|
||||||
},
|
},
|
||||||
|
|
@ -78,6 +76,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Store the master key directly (not derived from password)
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
|
@ -100,9 +99,11 @@ export const useAuthStore = create<AuthState>()(
|
||||||
|
|
||||||
const { user } = await response.json();
|
const { user } = await response.json();
|
||||||
|
|
||||||
|
// Derive KEK and unwrap master key
|
||||||
const kek = await deriveKey(password, user.salt);
|
const kek = await deriveKey(password, user.salt);
|
||||||
const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek);
|
const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek);
|
||||||
|
|
||||||
|
// Store master key in memory
|
||||||
set({ encryptionKey: masterKey, user, isAuthenticated: true });
|
set({ encryptionKey: masterKey, user, isAuthenticated: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -114,10 +115,9 @@ export const useAuthStore = create<AuthState>()(
|
||||||
|
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
encryptionKey: null,
|
encryptionKey: null, // Wipe from memory
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
get().clearAll();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
|
|
@ -137,17 +137,6 @@ export const useAuthStore = create<AuthState>()(
|
||||||
get().logout();
|
get().logout();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAll: () => {
|
|
||||||
set({
|
|
||||||
user: null,
|
|
||||||
encryptionKey: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
rememberMe: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
localStorage.clear();
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "auth-storage",
|
name: "auth-storage",
|
||||||
|
|
@ -160,6 +149,3 @@ export const useAuthStore = create<AuthState>()(
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
(window as any).useAuthStore = useAuthStore;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
352
frontend/src/stores/notesStore.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -1,27 +1,5 @@
|
||||||
import { Note, NoteRead } from "@/api/notes";
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import { Login } from "@/pages/Login";
|
|
||||||
|
|
||||||
interface HSL {
|
|
||||||
H: Number;
|
|
||||||
S: Number;
|
|
||||||
L: Number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ColourState {
|
|
||||||
base: string;
|
|
||||||
surface0: string;
|
|
||||||
surface1: string;
|
|
||||||
overlay0: string;
|
|
||||||
overlay1: string;
|
|
||||||
text: string;
|
|
||||||
subtext: string;
|
|
||||||
accent: string;
|
|
||||||
warn: string;
|
|
||||||
success: string;
|
|
||||||
danger: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
updating: boolean;
|
updating: boolean;
|
||||||
|
|
@ -30,26 +8,8 @@ interface UIState {
|
||||||
showModal: boolean;
|
showModal: boolean;
|
||||||
setShowModal: (show: boolean) => void;
|
setShowModal: (show: boolean) => void;
|
||||||
|
|
||||||
modalContent: React.ComponentType | null;
|
|
||||||
setModalContent: (content: React.ComponentType) => void;
|
|
||||||
|
|
||||||
sideBarResize: number;
|
sideBarResize: number;
|
||||||
setSideBarResize: (size: number) => void;
|
setSideBarResize: (size: number) => void;
|
||||||
|
|
||||||
sideBarView: string;
|
|
||||||
setSideBarView: (view: string) => void;
|
|
||||||
|
|
||||||
editorView: string;
|
|
||||||
setEditorView: (view: string) => void;
|
|
||||||
|
|
||||||
selectedNote: NoteRead | null;
|
|
||||||
setSelectedNote: (note: NoteRead | null) => void;
|
|
||||||
|
|
||||||
selectedFolder: number | null;
|
|
||||||
setSelectedFolder: (id: number | null) => void;
|
|
||||||
|
|
||||||
colourScheme: ColourState;
|
|
||||||
setColourScheme: (colors: ColourState) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>()(
|
export const useUIStore = create<UIState>()(
|
||||||
|
|
@ -59,60 +19,15 @@ export const useUIStore = create<UIState>()(
|
||||||
setUpdating: (update) => {
|
setUpdating: (update) => {
|
||||||
set({ updating: update });
|
set({ updating: update });
|
||||||
},
|
},
|
||||||
showModal: true,
|
showModal: false,
|
||||||
setShowModal: (show) => {
|
setShowModal: (show) => {
|
||||||
set({ showModal: show });
|
set({ showModal: show });
|
||||||
},
|
},
|
||||||
modalContent: null,
|
|
||||||
setModalContent: (content) => {
|
|
||||||
set({ modalContent: content });
|
|
||||||
},
|
|
||||||
sideBarResize: 300,
|
sideBarResize: 300,
|
||||||
setSideBarResize: (size) => {
|
setSideBarResize: (size) => {
|
||||||
set({ sideBarResize: size });
|
set({ sideBarResize: size });
|
||||||
},
|
},
|
||||||
sideBarView: "folders",
|
|
||||||
setSideBarView: (view) => {
|
|
||||||
set({ sideBarView: view });
|
|
||||||
},
|
|
||||||
editorView: "parsed",
|
|
||||||
setEditorView: (view) => {
|
|
||||||
set({ editorView: view });
|
|
||||||
},
|
|
||||||
selectedNote: null,
|
|
||||||
|
|
||||||
setSelectedNote: (id: NoteRead | null) => {
|
|
||||||
set({ selectedNote: id });
|
|
||||||
},
|
|
||||||
selectedFolder: null,
|
|
||||||
|
|
||||||
setSelectedFolder: (id: number | null) => {
|
|
||||||
set({ selectedFolder: id });
|
|
||||||
},
|
|
||||||
|
|
||||||
colourScheme: {
|
|
||||||
base: "#24273a",
|
|
||||||
surface0: "#1e2030",
|
|
||||||
surface1: "#181926",
|
|
||||||
overlay0: "#363a4f",
|
|
||||||
overlay1: "#494d64",
|
|
||||||
text: "#cad3f5",
|
|
||||||
subtext: "#b8c0e0",
|
|
||||||
accent: "#e2a16f",
|
|
||||||
danger: "#e26f6f",
|
|
||||||
success: "#6fe29b",
|
|
||||||
warn: "#e2c56f",
|
|
||||||
},
|
|
||||||
|
|
||||||
setColourScheme: (colors: ColourState) => {
|
|
||||||
set({ colourScheme: colors });
|
|
||||||
|
|
||||||
Object.entries(colors).forEach(([key, value]) => {
|
|
||||||
document.documentElement.style.setProperty(`--color-${key}`, value);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "ui-store",
|
name: "ui-store",
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
|
|
|
||||||
1192
frontend/src/types/api.d.ts
vendored
14
frontend/src/vite-env.d.ts
vendored
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import svgr from "vite-plugin-svgr";
|
import svgr from "vite-plugin-svgr";
|
||||||
import * as path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), react(), svgr()],
|
plugins: [tailwindcss(), react(), svgr()],
|
||||||
|
|
|
||||||