diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1e5f210 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +POSTGRES_USER=admin +POSTGRES_PASSWORD=admin +POSTGRES_DB=main_db +JWT_SECRET_KEY=change-me-in-production +COOKIE_SECURE=false +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost +VITE_API_BASE_URL=/api diff --git a/.gitignore b/.gitignore index d05d320..b143e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -184,3 +184,6 @@ cython_debug/ backend/uploads/* !backend/uploads/README.md +backend/cloud_notes.db +!frontend/src/lib/ +!frontend/src/lib/** diff --git a/README.md b/README.md index 7762d2e..9c2e282 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# βš™οΈ Cloud_Notes_api +# βš™οΈ Cloud_Notes -Async cloud notes backend powered by FastAPI + PostgreSQL. Clean architecture, authentication, and containerization out of the box. +Full-stack premium writing workspace powered by FastAPI, PostgreSQL, React, Tiptap, and Docker. Includes registration, authorization, rich text editing, autosave, note organization, and image attachments in a polished browser UI. --- @@ -12,13 +12,16 @@ Async cloud notes backend powered by FastAPI + PostgreSQL. Clean architecture, a [![Docker](https://img.shields.io/badge/docker-grey?style=for-the-badge&logo=docker)](https://docker.com) [![JWT](https://img.shields.io/badge/JWT-grey?style=for-the-badge&logo=jsonwebtokens)](https://www.jwt.io) [![Pydantic](https://img.shields.io/badge/Pydantic-grey?style=for-the-badge&logo=pydantic)](https://pydantic.dev) +[![React](https://img.shields.io/badge/react-grey?style=for-the-badge&logo=react)](https://react.dev) +[![Vite](https://img.shields.io/badge/vite-grey?style=for-the-badge&logo=vite)](https://vite.dev) - **Backend Framework:** [FastAPI](https://tiangolo.com) (Asynchronous, High performance) - **Database Engine:** [PostgreSQL](https://postgresql.org) - **ORM:** [SQLAlchemy 2.0](https://sqlalchemy.org) (Async extension with `asyncpg`) - **Data Validation:** [Pydantic v2](https://pydantic.dev) - **Security:** JWT (JSON Web Tokens) inside secure **HttpOnly Cookies** + `bcrypt` password hashing -- **Environment:** [Docker](https://docker.com) / [OrbStack](https://orbstack.dev) for full containerization +- **Frontend:** [React 19](https://react.dev) + [Vite](https://vite.dev) + [Tiptap](https://tiptap.dev) +- **Environment:** [Docker](https://docker.com) / [OrbStack](https://orbstack.dev) with production-ready containers ## πŸš€ Quick Start (Docker) @@ -30,36 +33,79 @@ Make sure you have **Docker** or **OrbStack** running on your system. You don't cd Cloud_Notes_api ``` -2. **Launch the environment:** - This command automatically creates the database, runs migrations (tables initialization), and starts the backend server: +2. **Prepare environment variables:** + ```bash + cp .env.example .env + ``` + +3. **Launch the production-like environment:** + This command builds the backend image, builds the frontend static bundle, starts PostgreSQL, and serves the app through Nginx: ```bash docker compose up --build ``` -3. **Explore the API:** - Once the terminal prints `Uvicorn running on http://0.0.0.0`, open your browser at: - - **Interactive API Docs (Swagger UI):** `http://localhost:8000/docs` +4. **Open the apps:** + - **Web UI:** `http://localhost:5173` + - **API Docs (Swagger UI):** `http://localhost:8000/docs` --- +## 🐳 Container Notes + +- `frontend` is built as static assets and served by **Nginx** +- browser API calls go through `/api`, which Nginx proxies to the FastAPI container +- `server` stores uploaded attachments in a persistent Docker volume +- `db` stores PostgreSQL data in a persistent Docker volume +- for production, replace `JWT_SECRET_KEY` and set `COOKIE_SECURE=true` behind HTTPS + +## πŸ§ͺ Development Mode + +For hot reload during active development: + +```bash +docker compose -f docker-compose.dev.yml up --build +``` + +This mode differs from the default stack: + +- `frontend` runs Vite dev server on `http://localhost:5173` +- `server` runs `uvicorn --reload` +- source folders are mounted into the containers +- PostgreSQL still runs in Docker, so no local DB install is required + ## πŸ“‚ Project Structure ```text -Cloud_Notes_api/ +Cloud_Notes/ β”œβ”€β”€ backend/ β”‚ β”œβ”€β”€ app/ β”‚ β”‚ β”œβ”€β”€ api/ # Modular API Routers (Separation of Concerns) -β”‚ β”‚ β”‚ β”œβ”€β”€ notes.py # Notes CRUD logic (Markdown supported) +β”‚ β”‚ β”‚ β”œβ”€β”€ attachments.py # Authenticated image upload & download +β”‚ β”‚ β”‚ β”œβ”€β”€ notes.py # Notes CRUD logic β”‚ β”‚ β”‚ β”œβ”€β”€ system.py # Health checks and monitoring -β”‚ β”‚ β”‚ └── users.py # Registration & Authentication +β”‚ β”‚ β”‚ └── users.py # Registration, login, current user β”‚ β”‚ β”œβ”€β”€ database.py # Async SQLAlchemy engine & session setup -β”‚ β”‚ β”œβ”€β”€ dependencies.py # Secure request filters (Token decoding & User fetch) -β”‚ β”‚ β”œβ”€β”€ main.py # Application entrypoint & Lifespan setup -β”‚ β”‚ β”œβ”€β”€ models.py # SQLAlchemy DB models (User, Note) +β”‚ β”‚ β”œβ”€β”€ dependencies.py # Cookie auth and current user lookup +β”‚ β”‚ β”œβ”€β”€ main.py # FastAPI entrypoint, startup, CORS +β”‚ β”‚ β”œβ”€β”€ models.py # SQLAlchemy DB models (User, Note, Attachment) β”‚ β”‚ β”œβ”€β”€ schemas.py # Pydantic validation DTOs -β”‚ β”‚ └── utils.py # Hashing & JWT utility functions +β”‚ β”‚ └── utils.py # Password hashing & JWT utility functions +β”‚ β”œβ”€β”€ Dockerfile +β”‚ β”œβ”€β”€ requirements.txt +β”‚ └── .dockerignore +β”œβ”€β”€ frontend/ +β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”œβ”€β”€ App.tsx # Auth flow and premium notes workspace UI +β”‚ β”‚ β”œβ”€β”€ App.css # Main application layout and components +β”‚ β”‚ β”œβ”€β”€ index.css # Global tokens, typography, background +β”‚ β”‚ β”œβ”€β”€ lib/api.ts # Browser API client with cookie credentials +β”‚ β”‚ └── types.ts # Shared frontend models β”‚ β”œβ”€β”€ Dockerfile -β”‚ └── requirements.txt +β”‚ β”œβ”€β”€ nginx.conf +β”‚ β”œβ”€β”€ package.json +β”‚ └── vite.config.ts +β”œβ”€β”€ .env.example +β”œβ”€β”€ docker-compose.dev.yml β”œβ”€β”€ docker-compose.yml └── README.md ``` @@ -69,5 +115,17 @@ Cloud_Notes_api/ ## πŸ”’ Security Features - **XSS Protection:** Access tokens are strictly transmitted via **HttpOnly** cookies, preventing malicious JavaScript from stealing sessions. -- **CSRF Mitigation:** Cookie management utilizes `samesite="lax"` policy configuration. +- **Cookie Policy:** Session cookies use `samesite="lax"` and support `secure=true` through environment configuration. - **Credential Safety:** Passwords are encrypted with a slow `bcrypt` hashing context. No raw credentials ever enter the database. +- **Frontend Auth Flow:** Browser requests are sent with credentials and validated through `/users/me`. + +## ✨ Product Features + +- Registration and login with secure cookie-based sessions +- Rich text editor with visual formatting toolbar +- Autosave without cursor reset during editing +- Pinned, favorite, archived, and searchable notes +- Drag-and-drop image uploads inside the editor +- Slash command palette for fast block insertion +- Focus mode for distraction-free writing +- Responsive premium workspace for desktop and mobile diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..3fc15ac --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.venv/ +venv/ +uploads/ +cloud_notes.db diff --git a/backend/Dockerfile b/backend/Dockerfile index f10f57e..85d63fd 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,4 +13,4 @@ COPY . /code/ EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api/attachments.py b/backend/app/api/attachments.py index 4cd9949..ee47b7f 100644 --- a/backend/app/api/attachments.py +++ b/backend/app/api/attachments.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, status +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -12,16 +14,24 @@ ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} MAX_FILE_SIZE = 25 * 1024 * 1024 +UPLOADS_DIR = Path(__file__).resolve().parents[2] / "uploads" attachments_router = APIRouter(prefix="/attachments", tags=["Attachments"]) @attachments_router.post("") async def upload_attachments( + request: Request, file: UploadFile, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): + if not file.filename: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Filename is required." + ) + extension = os.path.splitext(file.filename)[1].lower() if extension not in ALLOWED_EXTENSIONS: @@ -30,7 +40,8 @@ async def upload_attachments( detail=f"Unsupported file type. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}" ) - if file.size > MAX_FILE_SIZE: + file_bytes = await file.read() + if len(file_bytes) > MAX_FILE_SIZE: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File is too large. Maximum allowed size is 25 MB." @@ -38,9 +49,9 @@ async def upload_attachments( file_uuid = uuid.uuid4() saved_name = f"{file_uuid}{extension}" + UPLOADS_DIR.mkdir(parents=True, exist_ok=True) - file_path = f"uploads/{saved_name}" - file_bytes = await file.read() + file_path = UPLOADS_DIR / saved_name with open(file_path, "wb") as buffer: buffer.write(file_bytes) @@ -54,7 +65,7 @@ async def upload_attachments( db.add(new_attachment) await db.commit() - return {"url": f"http://localhost:8000/attachments/download/{file_uuid}"} + return {"url": str(request.url_for("download_attachment", file_uuid=str(file_uuid)))} @attachments_router.get("/download/{file_uuid}") async def download_attachment( @@ -69,12 +80,11 @@ async def download_attachment( if attachment is None or attachment.creator_id != current_user.id: raise HTTPException(status_code=404, detail="File not found") - files = [f for f in os.listdir("uploads") if f.startswith(file_uuid)] + files = [f for f in os.listdir(UPLOADS_DIR) if f.startswith(file_uuid)] if not files: raise HTTPException(status_code=404, detail="File on disk not found") file_name = files[0] - file_path = f"uploads/{file_name}" + file_path = UPLOADS_DIR / file_name return FileResponse(file_path) - diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index d15da08..a9d4b10 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -1,37 +1,90 @@ +import json + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, case, desc from ..models import User, Note from ..database import get_db from ..dependencies import get_current_user -from ..schemas import NoteCreate, NoteUpdate +from ..schemas import NoteCreate, NoteUpdate, NotePublic notes_router = APIRouter(prefix="/notes", tags=["Notes"]) -@notes_router.post("") +def normalize_tags(tags: list[str]) -> list[str]: + seen: set[str] = set() + normalized: list[str] = [] + + for tag in tags: + clean = tag.strip().lower() + if not clean or clean in seen: + continue + seen.add(clean) + normalized.append(clean[:24]) + + return normalized[:12] + + +def serialize_note(note: Note) -> NotePublic: + try: + tags = json.loads(note.tags or "[]") + except json.JSONDecodeError: + tags = [] + + return NotePublic( + id=note.id, + title=note.title, + text=note.text, + summary=note.summary, + tags=tags if isinstance(tags, list) else [], + is_pinned=note.is_pinned, + is_favorite=note.is_favorite, + is_archived=note.is_archived, + created_time=note.created_time, + edit_time=note.edit_time, + creator_id=note.creator_id, + ) + + +@notes_router.post("", response_model=NotePublic) async def create_note(userdata: NoteCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): - note = Note(title=userdata.title, text=userdata.text, creator_id=current_user.id) + note = Note( + title=userdata.title, + text=userdata.text, + summary=userdata.summary, + tags=json.dumps(normalize_tags(userdata.tags)), + is_pinned=userdata.is_pinned, + is_favorite=userdata.is_favorite, + is_archived=userdata.is_archived, + creator_id=current_user.id, + ) db.add(note) await db.commit() await db.refresh(note) - return note + return serialize_note(note) -@notes_router.get("") +@notes_router.get("", response_model=list[NotePublic]) async def get_notes(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): - query = select(Note).where(Note.creator_id == current_user.id) + query = ( + select(Note) + .where(Note.creator_id == current_user.id) + .order_by( + desc(case((Note.is_pinned.is_(True), 1), else_=0)), + desc(Note.edit_time), + ) + ) result = await db.execute(query) - return result.scalars().all() + return [serialize_note(note) for note in result.scalars().all()] -@notes_router.get("/{note_id}") +@notes_router.get("/{note_id}", response_model=NotePublic) async def get_note(note_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): query = select(Note).where(Note.id == note_id, Note.creator_id == current_user.id) result = await db.execute(query) note = result.scalar_one_or_none() if note is None: raise HTTPException(status_code=404, detail="Note not found") - return note + return serialize_note(note) @notes_router.delete("/{note_id}") async def delete_note(note_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): @@ -44,7 +97,7 @@ async def delete_note(note_id: int, db: AsyncSession = Depends(get_db), current_ await db.commit() return {"message": "Note deleted successfully"} -@notes_router.put("/{note_id}") +@notes_router.put("/{note_id}", response_model=NotePublic) async def update_note( note_id: int, userdata: NoteUpdate, @@ -60,7 +113,12 @@ async def update_note( note.title = userdata.title note.text = userdata.text + note.summary = userdata.summary + note.tags = json.dumps(normalize_tags(userdata.tags)) + note.is_pinned = userdata.is_pinned + note.is_favorite = userdata.is_favorite + note.is_archived = userdata.is_archived await db.commit() await db.refresh(note) - return note + return serialize_note(note) diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 0e0e646..acaa823 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -1,16 +1,21 @@ -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from ..schemas import Register, Login +import os + +from ..schemas import Register, Login, UserPublic from ..database import get_db from ..models import User +from ..dependencies import get_current_user from ..utils import hash_password, verify_password, create_access_token user_router = APIRouter(prefix="/users", tags=["Users"]) -@user_router.post("/register") +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true" + +@user_router.post("/register", response_model=UserPublic) async def register(userdata:Register, db: AsyncSession = Depends(get_db)): query = select(User).where(User.login == userdata.login) result = await db.execute(query) @@ -42,6 +47,7 @@ async def login(userdata:Login, response:Response, db: AsyncSession = Depends(ge key="access_token", value=token, httponly=True, + secure=COOKIE_SECURE, samesite="lax", max_age=1800 ) @@ -51,7 +57,11 @@ async def login(userdata:Login, response:Response, db: AsyncSession = Depends(ge @user_router.post("/logout") async def logout(response: Response): - response.delete_cookie(key="access_token") + response.delete_cookie(key="access_token", secure=COOKIE_SECURE, samesite="lax") return {"message": "Logout successful"} + +@user_router.get("/me", response_model=UserPublic) +async def current_user(current_user: User = Depends(get_current_user)): + return current_user diff --git a/backend/app/database.py b/backend/app/database.py index 86d9970..0313417 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -2,7 +2,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy.orm import DeclarativeBase -DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://dev_user:dev_password@db:5432/dev_db") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./cloud_notes.db") engine = create_async_engine(DATABASE_URL, echo=True) diff --git a/backend/app/main.py b/backend/app/main.py index 8fa59eb..0de07fd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,18 +1,38 @@ -import os import asyncio from contextlib import asynccontextmanager +import os + from fastapi import FastAPI -from sqlalchemy.ext.asyncio import create_async_engine +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import inspect, text -from .database import Base -from .models import User, Note +from .database import Base, engine +from .models import User, Note, Attachment from .api.users import user_router from .api.notes import notes_router from .api.system import system_router from .api.attachments import attachments_router -DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://admin:admin@db:5432/main_db") -engine = create_async_engine(DATABASE_URL, echo=True) + +def migrate_existing_schema(connection): + inspector = inspect(connection) + note_columns = {column["name"] for column in inspector.get_columns("notes")} if inspector.has_table("notes") else set() + + alterations = { + "summary": "ALTER TABLE notes ADD COLUMN summary VARCHAR(280)", + "tags": "ALTER TABLE notes ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'", + "is_pinned": "ALTER TABLE notes ADD COLUMN is_pinned BOOLEAN NOT NULL DEFAULT 0", + "is_favorite": "ALTER TABLE notes ADD COLUMN is_favorite BOOLEAN NOT NULL DEFAULT 0", + "is_archived": "ALTER TABLE notes ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT 0", + "created_time": "ALTER TABLE notes ADD COLUMN created_time DATETIME", + } + + for column_name, statement in alterations.items(): + if column_name not in note_columns: + connection.execute(text(statement)) + + if inspector.has_table("notes") and "created_time" not in note_columns: + connection.execute(text("UPDATE notes SET created_time = edit_time WHERE created_time IS NULL")) @asynccontextmanager async def lifespan(app: FastAPI): @@ -21,6 +41,7 @@ async def lifespan(app: FastAPI): try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(migrate_existing_schema) print("Successfully connected to the database and created tables!") break except Exception as e: @@ -31,6 +52,20 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan, swagger_ui_parameters={"withCredentials": True}) +cors_origins = [ + origin.strip() + for origin in os.getenv("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(",") + if origin.strip() +] + +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + app.include_router(user_router) app.include_router(notes_router) app.include_router(system_router) @@ -39,5 +74,3 @@ async def lifespan(app: FastAPI): @app.get("/") async def root(): return {"message": "Hello from FastAPI backend!"} - - diff --git a/backend/app/models.py b/backend/app/models.py index 0c8ac0a..f82ec66 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import String, Integer, ForeignKey, Text, DateTime +from sqlalchemy import String, Integer, ForeignKey, Text, DateTime, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship from .database import Base @@ -21,6 +21,12 @@ class Note(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) title: Mapped[str] = mapped_column(String(100), nullable=False) text: Mapped[str] = mapped_column(Text, nullable=True) + summary: Mapped[str | None] = mapped_column(String(280), nullable=True) + tags: Mapped[str] = mapped_column(Text, default="[]", nullable=False) + is_pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_favorite: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) edit_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5873e23..b757502 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,17 +1,51 @@ +from datetime import datetime + from pydantic import BaseModel, Field class Register(BaseModel): - login: str - password: str + login: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=8, max_length=128) class Login(BaseModel): - login: str - password: str + login: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=8, max_length=128) class NoteCreate(BaseModel): - title: str - text: str + title: str = Field(..., min_length=1, max_length=100) + text: str | None = None + summary: str | None = Field(default=None, max_length=280) + tags: list[str] = Field(default_factory=list, max_length=12) + is_pinned: bool = False + is_favorite: bool = False + is_archived: bool = False class NoteUpdate(BaseModel): title: str = Field(..., min_length=1, max_length=100) text: str | None = None + summary: str | None = Field(default=None, max_length=280) + tags: list[str] = Field(default_factory=list, max_length=12) + is_pinned: bool = False + is_favorite: bool = False + is_archived: bool = False + +class NotePublic(BaseModel): + id: int + title: str + text: str | None + summary: str | None + tags: list[str] + is_pinned: bool + is_favorite: bool + is_archived: bool + created_time: datetime + edit_time: datetime + creator_id: int + + model_config = {"from_attributes": True} + +class UserPublic(BaseModel): + id: int + login: str + theme: str + + model_config = {"from_attributes": True} diff --git a/backend/app/utils.py b/backend/app/utils.py index 06c6946..b708539 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -1,9 +1,10 @@ +import os from passlib.context import CryptContext import jwt from datetime import datetime, timedelta -SECRET_KEY = "SUPER_SECRET_KEY_CHANGEME_IN_PRODUCTION" +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-change-me") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 diff --git a/backend/requirements.txt b/backend/requirements.txt index e5bbf07..1101950 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,7 @@ fastapi>=0.110.0 uvicorn[standard]>=0.28.0 sqlalchemy>=2.0.0 asyncpg>=0.29.0 +aiosqlite>=0.20.0 passlib[bcrypt] bcrypt==4.0.1 pydantic diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..166616f --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,77 @@ +version: "3.9" + +services: + db: + image: postgres:16-alpine + container_name: cloud_notes_db_dev + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_USER: ${POSTGRES_USER:-admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin} + POSTGRES_DB: ${POSTGRES_DB:-main_db} + ports: + - "5432:5432" + volumes: + - postgres_data_dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-admin} -d ${POSTGRES_DB:-main_db}"] + interval: 10s + timeout: 5s + retries: 8 + start_period: 10s + networks: + - cloud_notes_dev + + server: + build: + context: ./backend + dockerfile: Dockerfile + container_name: cloud_notes_api_dev + restart: unless-stopped + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + env_file: + - .env + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-admin}:${POSTGRES_PASSWORD:-admin}@db:5432/${POSTGRES_DB:-main_db} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + CORS_ORIGINS: http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000 + ports: + - "8000:8000" + volumes: + - ./backend:/code + - backend_uploads_dev:/code/uploads + depends_on: + db: + condition: service_healthy + networks: + - cloud_notes_dev + + frontend: + image: node:24-alpine + container_name: cloud_notes_frontend_dev + restart: unless-stopped + working_dir: /app + command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 5173" + environment: + VITE_API_BASE_URL: http://localhost:8000 + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - frontend_node_modules_dev:/app/node_modules + depends_on: + - server + networks: + - cloud_notes_dev + +volumes: + postgres_data_dev: + backend_uploads_dev: + frontend_node_modules_dev: + +networks: + cloud_notes_dev: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 978de4e..6ee9aa6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,42 +1,88 @@ -version: '3.8' +version: "3.9" services: db: image: postgres:16-alpine - container_name: backend_postgres - restart: always + container_name: cloud_notes_db + restart: unless-stopped + env_file: + - .env environment: - POSTGRES_USER: admin - POSTGRES_PASSWORD: admin - POSTGRES_DB: main_db - ports: - - "5432:5432" + POSTGRES_USER: ${POSTGRES_USER:-admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin} + POSTGRES_DB: ${POSTGRES_DB:-main_db} volumes: - - pgdata:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin -d main_db"] + interval: 10s + timeout: 5s + retries: 8 + start_period: 10s networks: - - app-network + - cloud_notes server: build: context: ./backend dockerfile: Dockerfile - container_name: fastapi_server - restart: always + container_name: cloud_notes_api + restart: unless-stopped + env_file: + - .env + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-admin}:${POSTGRES_PASSWORD:-admin}@db:5432/${POSTGRES_DB:-main_db} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173,http://localhost} ports: - "8000:8000" - environment: - DATABASE_URL: "postgresql+asyncpg://admin:admin@db:5432/main_db" volumes: - - ./backend:/code + - backend_uploads:/code/uploads + depends_on: + db: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/system/health-check')", + ] + interval: 15s + timeout: 5s + retries: 6 + start_period: 20s + networks: + - cloud_notes + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} + container_name: cloud_notes_frontend + restart: unless-stopped + ports: + - "5173:80" depends_on: - - db + server: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"] + interval: 15s + timeout: 5s + retries: 6 + start_period: 10s networks: - - app-network + - cloud_notes volumes: - pgdata: + postgres_data: + backend_uploads: networks: - app-network: + cloud_notes: driver: bridge - diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e3013b3 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +npm-debug.log* diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..14ea4ad --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=/api diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e67eaa2 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:24-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +ARG VITE_API_BASE_URL=/api +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +RUN npm run build + +FROM nginx:1.29-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..cefd63d --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://server:8000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + 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; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..add280a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2244 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tiptap/extension-color": "^3.27.4", + "@tiptap/extension-highlight": "^3.27.4", + "@tiptap/extension-image": "^3.27.4", + "@tiptap/extension-link": "^3.27.4", + "@tiptap/extension-placeholder": "^3.27.4", + "@tiptap/extension-table": "^3.27.4", + "@tiptap/extension-table-cell": "^3.27.4", + "@tiptap/extension-table-header": "^3.27.4", + "@tiptap/extension-table-row": "^3.27.4", + "@tiptap/extension-task-item": "^3.27.4", + "@tiptap/extension-task-list": "^3.27.4", + "@tiptap/extension-text-align": "^3.27.4", + "@tiptap/extension-text-style": "^3.27.4", + "@tiptap/extension-underline": "^3.27.4", + "@tiptap/react": "^3.27.4", + "@tiptap/starter-kit": "^3.27.4", + "lucide-react": "^1.24.0", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT", + "optional": true + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.73.0.tgz", + "integrity": "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.73.0.tgz", + "integrity": "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.73.0.tgz", + "integrity": "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.73.0.tgz", + "integrity": "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.73.0.tgz", + "integrity": "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.73.0.tgz", + "integrity": "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.73.0.tgz", + "integrity": "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.73.0.tgz", + "integrity": "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.73.0.tgz", + "integrity": "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.73.0.tgz", + "integrity": "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.73.0.tgz", + "integrity": "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.73.0.tgz", + "integrity": "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.73.0.tgz", + "integrity": "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.73.0.tgz", + "integrity": "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.73.0.tgz", + "integrity": "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.73.0.tgz", + "integrity": "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.73.0.tgz", + "integrity": "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.73.0.tgz", + "integrity": "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.73.0.tgz", + "integrity": "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tiptap/core": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.4.tgz", + "integrity": "sha512-8W/GwlEn0JwNdpyVfTWcXwHYUpj9BWwO++YxtizmgjJzlwigSh7/xLVJMwVykuQHQ2fCq5rkUvmBRtpHOMLUQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.27.4.tgz", + "integrity": "sha512-d1tOHgP3R5cOE+Ot8qL/dkLXRByajgn+j6cCXHqDtmJO2wsK9knmbKQ0SEjbKrU6OgHrTnY/EotNxBEBW9HGoA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.4.tgz", + "integrity": "sha512-wTtJUUAxCAZ01ICH2DNlOBzzHKRQ1ZST8aRYtIhBPzqEUhnJaKGcjnDB4X49fqPi48iXaPxzhsInDl+rVUujWg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.4.tgz", + "integrity": "sha512-Poy7xwcD3POG5ew/TW7mYXv7m++vCchvHxPUqIfnTxBxvvvqDZkPYFWZS1lvPrSBtm1DcfUTQAgVutM5NDZ99Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.4.tgz", + "integrity": "sha512-rvja0N1RnwGJAVwDdbUfDIJ4NoT+KjPFaZudKiPuEMfMHfbqe4xcbbC2hsfs61JNcl2xmx+ohV6lzD9YxxJl1w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.4" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.4.tgz", + "integrity": "sha512-aPc7opCR1ylK4m4c2lsjLsGpEBD1fLQQKWd5PbZiJvrTF8gkdGZlYLt9A6VukpxeJyHhb22Jaj4fxgKmGMeTtw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.27.4.tgz", + "integrity": "sha512-a5caWfWN6Z6usy48vzJDDOhWoA6+rFFCHGpQM7jXn/7rRzYPcvBzTZUGptjEbltj4YqtrQ2tVwTJcCtbb+mknA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/extension-color": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.27.4.tgz", + "integrity": "sha512-uGbgErKGKO4OTBGqnXOA1CGXa3IqoBaiPBGcHu/px01fS8EVFu0A7q5HS6vxFiMK6qW5x71sEpCA+FUINMAb3g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-text-style": "3.27.4" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.27.4.tgz", + "integrity": "sha512-7nAqgfkgb9HADBeCTnOHuTiyZuxfxvMPT3nH4OZeY+cmtkI1On3QffqlmtcUPvNbkhT3o9ehA1hVfCnQ1Ye4LQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.4.tgz", + "integrity": "sha512-RiZasQJuUTUO3aME16Bn8eJH7cYnvhT5JCFDFq0ya/1iFI9wUQA2NJC5tb5TrZ74+sQwkYU9VzexnchM481Y9w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.4" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.4.tgz", + "integrity": "sha512-tnZywwoNDuEcUZmYYIztXl3PpIKUq+gKeaYPuZhpYEVTThU44tzK3ZuFOmd+qf2aAa1MQwxKWqUuLpNK77bwNw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.4.tgz", + "integrity": "sha512-svLwSKcFhzpcJeXvxxKkRFuQpykmXrQefVhEsaXq0L95yJIIAGKMRmQC3mxKdzL2j0P9cY7V41bNVSyOAyvclw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.4" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.27.4.tgz", + "integrity": "sha512-W+Z9pmDgqjbdu3NeZOQrzA15iM4w60Yd8l2CYzxcdApPVIfYzb2S3a7+u1RqW9wnTYb6xyZjASmFNfxXS4P4cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.4.tgz", + "integrity": "sha512-RgvpxzuYk6QEK+az+eiXpWvGlUso42zNcGnjyUrvskoZjS47MbhSg8ylRYQSRtXE0ETlXhAx4J7iGlGr72kyIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-highlight": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.27.4.tgz", + "integrity": "sha512-STRX1qJLhTZslBF8fEE5qpTGrFd/g7Ufidjxt84p0uT8FrtcbfPUwyweeYwhZp7Iw8n+qODGYnheJTOpfaW2JA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.4.tgz", + "integrity": "sha512-2eQU/55nE5mhMJHALtLMuBL3dcVJUDVVT7n+uZYMaYE63BtCvC4VS08YLFSR7JZSVJIlgVAmdt5nAw0B+rEPNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.27.4.tgz", + "integrity": "sha512-yQ8CazyOL4z1/NbV1NLGv6DvchVhOXHH3uQ7md5VX/IGZruFpnm8IpF9MDpdAUxUFmTsXJDYyO2lWGO1PYWG8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.4.tgz", + "integrity": "sha512-PeZT4XbyxAp7Lqo/hfA1k5LI27g1RlgS+YgXp2CeHXIrUfSpO5HlZXh02Bvb0pOdl3RFw2tEKtlHzjt8Y1+Nwg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.27.4.tgz", + "integrity": "sha512-6K/FkNwMLWWQbNWKlycrUPTN7YcyVFdFwZncoBXe5WyarRjLTGw7ywafnCI9PDIWSq7ttzVL4NgjN2IN8kBXww==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.4.tgz", + "integrity": "sha512-A0BgmRO1RE0yLCx9w7GQITtKfS9wLE5cdngSYDiSpwulcXJhJjKm5mZ4OUZmks2VN4HO5jMl2BWCGt2NSDhA+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.27.4.tgz", + "integrity": "sha512-z5TVuPw2mkK0B/x+gFg3uUV7tBdaElDFg0zVgnXZCqlSVTLfIyInOOnG5LTWoAd9BdzBjGrzE3PohDcLVDDGBQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.4" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.4.tgz", + "integrity": "sha512-on7JNDi7Eqz7UdZeZdiO83bQHo0flVDHzjmtR+v/nrCGW9H15D3CHs5+4ozLDiCvTK8tbkBuut/l9AWNxcCE/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.4" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.4.tgz", + "integrity": "sha512-bHwLiof0FqJfWzB0act7oEKMTZatEKQ4IYCvmyF5EktjMs4kxEatkPp4Yx/1LSYSjLy1MMT7oLELyaz2FFYyXA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.4" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.4.tgz", + "integrity": "sha512-8Dnr1J5s/s4XYYuEF3b784NnCxLjXOlQpmGyXRxTAzW7JaOP08tIUJWVNvSMekfXc2vXa33HUbqjxyyWZEQ6LQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.27.4.tgz", + "integrity": "sha512-7hBoFLeddCv1WzkqB0x3coZ1Hp9WZ9wLoRXIUtUhRKMpzFq2IlTtW1iw88g9pTJnL98bCCElN4DZ4mYtaQvmgA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.4" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.4.tgz", + "integrity": "sha512-8OXwcPKuV3ToBBgyvDxH1jQdObK5FIKCGiyIim6qNWiOpi9BhM3XYD+aO1khjv8qIjtoI/DYbizF4ewj09fX2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-table": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.27.4.tgz", + "integrity": "sha512-ejQjqt8GjUn4YswG/SsiLr/W3LZApZGUEDW0N7NoOduE0dBZ/pVJHPuqWu33kK+phJjSNCIN+bSAkoWE01rZSw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.27.4.tgz", + "integrity": "sha512-1B7J4ZiaXaGxT2IB3hrwtz0433bFVSWsDB+B125vt2DZQr9bgdX40GLesSPlymqOdMxKJwksCPMan2ON2LkVAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.27.4" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.27.4.tgz", + "integrity": "sha512-V/O690Z6VcHMAWDfgVFiOCwx3eE2/HM2gH+Fp0eQe3WnajQj2DPViW4VWR/rNVl3DspHoL/EU+eEsnhcvO34Vw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.27.4" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.27.4.tgz", + "integrity": "sha512-2RTJtcy90Tc2+HpW1JyKMTwg/zz4u6hHof4CTcJS/eXLk3g1L60DDzYvz4GzBxQvsOT3QVcbaU9QOk9A3Sx7Fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.27.4" + } + }, + "node_modules/@tiptap/extension-task-item": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.27.4.tgz", + "integrity": "sha512-0/sGxmuUgintoCIh7fF7bblM1Hlr5W498njBLUPSJAd7cO78XXCjoB/UUEiCoBEPEyhHcDYesnYdZxsNfjChGg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.4" + } + }, + "node_modules/@tiptap/extension-task-list": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-3.27.4.tgz", + "integrity": "sha512-nlFw+pOhnj9pF1wN0CyjqL0JppM6KNw05XtZWD5kt68tBUpBb7+aEy02tDXpZoRefiFhcIouKVJSeSzILZlCrA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.4" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.4.tgz", + "integrity": "sha512-lKQH/hP4FBXsziHypd6Ywj8JFvMLM5GVkK1xsH6yApNuXbHq95rd42ZOYWpYILIBib7tlaz93z61d74UrJiuiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.27.4.tgz", + "integrity": "sha512-ArfL7GLOXCSmrmiBiaWwAf7RPHXDdxLGPru29qKDLQDthjXcNOdlwlPBbolRu7mXwlSmaYpqWGrG75/AEat3mw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.27.4.tgz", + "integrity": "sha512-Wmj64TQXY85gc7lUNbubW32sDCnVOJlpGraMeARRC9Z0EKBX1JpAxddo/64F17bn3kzLxXVszBFyLRcNgTdU2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.4.tgz", + "integrity": "sha512-nRJGvRyEXDtINlHTW+C2oWcL3vmX1URVxAPpkD3Zwn5Rb/vEeOU/pk/w97I0iid816MR4iVbvl1XhbUVegK9gQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.4.tgz", + "integrity": "sha512-d8opkg2iGtVwJmNGIqv0blfRxnvWOJp1brz+Z8CsP4ojSS2ZtaE46d6JSQ5OeJ7nMpjhT+9wh4UQcA7OSEO59w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.4.tgz", + "integrity": "sha512-UB8lcyomfWk7YGI2PZKNqcYXfyRA+PFj+QntlsUXyrsiA5JJIaE8SHKYjxKlGG/xtW3EtPm1b0p38T9Mk4xiFw==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.9", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.27.4.tgz", + "integrity": "sha512-rTY1V9Y1jzwmo5ItRi3v2Og/mbcYsr9AjUvGoqpXzR9Z31WhXYphw0y05aYzryh0MHXYzkiE+gbGvrbg+cjwEg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.27.4", + "@tiptap/extension-floating-menu": "^3.27.4" + }, + "peerDependencies": { + "@tiptap/core": "3.27.4", + "@tiptap/pm": "3.27.4", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.27.4.tgz", + "integrity": "sha512-/sb6rFxNt5BO4hWpUwvHh+Yh1kNyCQuuz3oDpGef5HUUjSdu9p9rfNiHWIUKBadK8VXuw5es7N+UlZ4hma+gvA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.27.4", + "@tiptap/extension-blockquote": "^3.27.4", + "@tiptap/extension-bold": "^3.27.4", + "@tiptap/extension-bullet-list": "^3.27.4", + "@tiptap/extension-code": "^3.27.4", + "@tiptap/extension-code-block": "^3.27.4", + "@tiptap/extension-document": "^3.27.4", + "@tiptap/extension-dropcursor": "^3.27.4", + "@tiptap/extension-gapcursor": "^3.27.4", + "@tiptap/extension-hard-break": "^3.27.4", + "@tiptap/extension-heading": "^3.27.4", + "@tiptap/extension-horizontal-rule": "^3.27.4", + "@tiptap/extension-italic": "^3.27.4", + "@tiptap/extension-link": "^3.27.4", + "@tiptap/extension-list": "^3.27.4", + "@tiptap/extension-list-item": "^3.27.4", + "@tiptap/extension-list-keymap": "^3.27.4", + "@tiptap/extension-ordered-list": "^3.27.4", + "@tiptap/extension-paragraph": "^3.27.4", + "@tiptap/extension-strike": "^3.27.4", + "@tiptap/extension-text": "^3.27.4", + "@tiptap/extension-underline": "^3.27.4", + "@tiptap/extensions": "^3.27.4", + "@tiptap/pm": "^3.27.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/oxlint": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz", + "integrity": "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.73.0", + "@oxlint/binding-android-arm64": "1.73.0", + "@oxlint/binding-darwin-arm64": "1.73.0", + "@oxlint/binding-darwin-x64": "1.73.0", + "@oxlint/binding-freebsd-x64": "1.73.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", + "@oxlint/binding-linux-arm-musleabihf": "1.73.0", + "@oxlint/binding-linux-arm64-gnu": "1.73.0", + "@oxlint/binding-linux-arm64-musl": "1.73.0", + "@oxlint/binding-linux-ppc64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-musl": "1.73.0", + "@oxlint/binding-linux-s390x-gnu": "1.73.0", + "@oxlint/binding-linux-x64-gnu": "1.73.0", + "@oxlint/binding-linux-x64-musl": "1.73.0", + "@oxlint/binding-openharmony-arm64": "1.73.0", + "@oxlint/binding-win32-arm64-msvc": "1.73.0", + "@oxlint/binding-win32-ia32-msvc": "1.73.0", + "@oxlint/binding-win32-x64-msvc": "1.73.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.24.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.1.tgz", + "integrity": "sha512-rRqzZnRgkyh69XoOMrfFJHwauHscLBmHbq772kwbic1ymQAM8gXjzEbJse5j1ep2UO2HRIAQL0bY3kZ/RoqjVw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..be1ffa7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@tiptap/extension-color": "^3.27.4", + "@tiptap/extension-highlight": "^3.27.4", + "@tiptap/extension-image": "^3.27.4", + "@tiptap/extension-link": "^3.27.4", + "@tiptap/extension-placeholder": "^3.27.4", + "@tiptap/extension-table": "^3.27.4", + "@tiptap/extension-table-cell": "^3.27.4", + "@tiptap/extension-table-header": "^3.27.4", + "@tiptap/extension-table-row": "^3.27.4", + "@tiptap/extension-task-item": "^3.27.4", + "@tiptap/extension-task-list": "^3.27.4", + "@tiptap/extension-text-align": "^3.27.4", + "@tiptap/extension-text-style": "^3.27.4", + "@tiptap/extension-underline": "^3.27.4", + "@tiptap/react": "^3.27.4", + "@tiptap/starter-kit": "^3.27.4", + "lucide-react": "^1.24.0", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..7e8d830 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,899 @@ +.app-shell { + min-height: 100vh; + background: + radial-gradient(circle at top, rgba(74, 74, 74, 0.18), transparent 18%), + linear-gradient(180deg, #171717 0%, #1d1d1d 100%); + color: #f4f4f2; +} + +.app-shell--loading { + display: grid; + place-items: center; +} + +.loading-mark { + display: grid; + gap: 16px; + justify-items: center; + color: #b8b8b2; +} + +.loading-mark__orbit { + width: 72px; + height: 72px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-top-color: #f4f4f2; + animation: spin 1.1s linear infinite; +} + +.auth-screen { + display: grid; + place-items: center; + padding: 24px; +} + +.auth-screen__panel { + width: min(980px, 100%); + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(320px, 400px); + gap: 24px; + padding: 24px; + border-radius: 32px; + background: rgba(24, 24, 24, 0.94); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.35); +} + +.auth-screen__brand { + padding: 28px; +} + +.auth-screen__badge { + display: inline-flex; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: #b7b7b0; + font-size: 0.85rem; +} + +.auth-screen__brand h1 { + margin: 24px 0 12px; + font-size: clamp(2.8rem, 5vw, 4.4rem); + line-height: 0.94; + letter-spacing: -0.06em; + font-family: Georgia, 'Times New Roman', serif; +} + +.auth-screen__brand p { + margin: 0; + max-width: 42ch; + color: #9b9b94; + font-size: 1.02rem; +} + +.auth-form--dark { + padding: 24px; + border-radius: 28px; + background: rgba(34, 34, 34, 0.92); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.auth-form, +.auth-form label { + display: grid; + gap: 12px; +} + +.auth-form span { + color: #a4a49d; + font-size: 0.88rem; +} + +.auth-form input { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.04); + color: #f3f3ef; + font: inherit; + box-sizing: border-box; +} + +.auth-form input:focus, +.toolbar-search:focus, +.notes-title:focus, +.notes-editor__content:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.18); +} + +.button, +.icon-button, +.ghost-button, +.toolbar-pill__button, +.format-popover button, +.note-row { + font: inherit; +} + +.button { + border: 0; + border-radius: 16px; + padding: 14px 18px; + cursor: pointer; +} + +.button--bright { + background: #f4f4f2; + color: #171717; +} + +.auth-switch { + margin-top: 8px; + border: 0; + background: transparent; + color: #9c9c96; + padding: 0; + cursor: pointer; + text-align: left; +} + +.form-error { + margin: 0; + color: #ff8f8f; +} + +.notes-app { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 0; +} + +.notes-app--focus { + grid-template-columns: minmax(0, 1fr); +} + +.notes-sidebar { + min-height: 100vh; + padding: 18px 16px; + border-right: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(20, 20, 20, 0.96); + box-sizing: border-box; +} + +.notes-sidebar--hidden { + display: none; +} + +.notes-sidebar--collapsed { + width: 72px; + overflow: hidden; +} + +.notes-sidebar__top, +.notes-sidebar__section, +.notes-toolbar, +.notes-toolbar__left, +.notes-toolbar__center, +.notes-toolbar__right, +.notes-stage__meta, +.notes-footer, +.notes-footer__stats, +.notes-footer__actions { + display: flex; + align-items: center; +} + +.notes-sidebar__top { + justify-content: space-between; + margin-bottom: 22px; +} + +.notes-sidebar__section { + justify-content: space-between; + color: #9f9f98; + margin-bottom: 14px; +} + +.notes-sidebar__section h2 { + margin: 0; + font-size: 1rem; + color: #ecece8; +} + +.icon-button { + width: 36px; + height: 36px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: #efefec; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.icon-button--active { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} + +.notes-list--dark { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.sidebar-shelves { + display: grid; + gap: 6px; +} + +.sidebar-shelf { + border: 0; + border-radius: 14px; + background: transparent; + color: #b0b0aa; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + text-align: left; +} + +.sidebar-shelf:hover, +.sidebar-shelf--active { + background: rgba(255, 255, 255, 0.06); + color: #f2f2ee; +} + +.note-row { + display: grid; + gap: 6px; + text-align: left; + padding: 14px 12px; + border: 0; + border-radius: 16px; + background: transparent; + color: #f1f1ee; + cursor: pointer; +} + +.note-row strong { + font-size: 1rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.note-row span, +.muted, +.notes-stage__meta, +.notes-footer__stats { + color: #8f8f89; +} + +.note-row span { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + -webkit-line-clamp: 2; +} + +.note-row--active { + background: rgba(255, 255, 255, 0.06); +} + +.notes-workspace { + min-width: 0; + padding: 10px 20px 24px; +} + +.notes-toolbar { + justify-content: space-between; + gap: 20px; + position: sticky; + top: 0; + z-index: 10; + padding: 2px 0 20px; + background: linear-gradient(180deg, rgba(23, 23, 23, 0.98), rgba(23, 23, 23, 0.72) 70%, transparent); +} + +.notes-toolbar__left, +.notes-toolbar__right { + gap: 12px; + flex: 0 0 auto; +} + +.notes-toolbar__center { + position: relative; + justify-content: center; + flex: 1 1 auto; +} + +.toolbar-pill { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 4px; + border-radius: 18px; + background: rgba(40, 40, 40, 0.96); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 10px 32px rgba(0, 0, 0, 0.28); +} + +.toolbar-pill__button { + min-width: 34px; + height: 34px; + border: 0; + border-radius: 12px; + background: transparent; + color: #d8d8d2; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 10px; +} + +.toolbar-pill__button.is-active, +.toolbar-pill__button:hover, +.format-popover button:hover, +.ghost-button:hover, +.icon-button:hover { + background: rgba(255, 255, 255, 0.08); +} + +.format-popover { + position: absolute; + top: calc(100% + 12px); + left: 50%; + transform: translateX(-50%); + width: 240px; + padding: 8px; + border-radius: 18px; + background: rgba(32, 32, 32, 0.98); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); + display: grid; + gap: 4px; +} + +.format-popover button { + border: 0; + background: transparent; + color: #f1f1ee; + border-radius: 12px; + padding: 10px 12px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.format-popover__text { + width: 16px; + text-align: center; +} + +.format-popover__swatch { + width: 14px; + height: 14px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.16); + flex: 0 0 auto; +} + +.format-popover__swatch--purple { + background: #caa8ff; +} + +.format-popover__swatch--clear { + background: + linear-gradient(135deg, transparent 42%, #ff7a7a 42%, #ff7a7a 58%, transparent 58%), + rgba(255, 255, 255, 0.06); +} + +.toolbar-search { + width: 220px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + padding: 11px 16px; + background: rgba(255, 255, 255, 0.04); + color: #f2f2ee; + font: inherit; +} + +.notes-stage { + width: min(860px, 100%); + margin: 0 auto; +} + +.notes-stage__meta { + justify-content: center; + gap: 28px; + font-size: 0.9rem; + margin-bottom: 10px; + flex-wrap: wrap; + text-align: center; +} + +.notes-canvas { + position: relative; + min-height: calc(100vh - 138px); + padding: 18px 10px 36px; + border-radius: 30px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.015)), + rgba(17, 17, 17, 0.62); + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.22); +} + +.notes-canvas--drag { + border-color: rgba(188, 216, 255, 0.45); + box-shadow: 0 0 0 1px rgba(188, 216, 255, 0.18), 0 24px 80px rgba(0, 0, 0, 0.22); +} + +.drop-overlay { + position: absolute; + inset: 14px; + border-radius: 24px; + border: 1px dashed rgba(188, 216, 255, 0.38); + background: rgba(20, 24, 30, 0.86); + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + z-index: 3; + pointer-events: none; + text-align: center; +} + +.drop-overlay strong { + font-size: 1.05rem; + color: #f5f7fb; +} + +.drop-overlay span { + color: #aab1bc; +} + +.notes-canvas__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.notes-canvas__meta-pills { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.meta-pill { + display: inline-flex; + align-items: center; + padding: 7px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.07); + color: #c8c8c1; + font-size: 0.88rem; +} + +.tag-field { + display: grid; + gap: 8px; + min-width: min(320px, 100%); +} + +.tag-field span { + color: #9f9f98; + font-size: 0.82rem; +} + +.tag-field input { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + padding: 11px 14px; + background: rgba(255, 255, 255, 0.04); + color: #f2f2ee; + font: inherit; + box-sizing: border-box; +} + +.notes-title { + width: 100%; + border: 0; + background: transparent; + color: #f5f5f1; + font-size: 2rem; + font-weight: 700; + margin-bottom: 18px; + padding: 0; + font-family: inherit; + overflow: hidden; + text-overflow: ellipsis; +} + +.notes-title::placeholder { + color: #6e6e69; +} + +.tag-cloud, +.quick-insert { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.tag-cloud { + margin: 0 0 16px; +} + +.tag-chip, +.quick-insert button { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: #ecece7; + padding: 8px 12px; + font: inherit; + cursor: pointer; +} + +.tag-chip:hover, +.quick-insert button:hover { + background: rgba(255, 255, 255, 0.08); +} + +.quick-insert { + margin: 0 0 18px; +} + +.command-overlay { + position: fixed; + inset: 0; + background: rgba(7, 7, 8, 0.58); + backdrop-filter: blur(18px); + display: grid; + place-items: start center; + padding: 84px 20px 20px; + z-index: 40; +} + +.command-panel { + width: min(640px, 100%); + border-radius: 24px; + background: rgba(24, 24, 25, 0.96); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 36px 120px rgba(0, 0, 0, 0.38); + overflow: hidden; +} + +.command-panel__head { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + color: #e5e5df; +} + +.command-panel__input { + width: 100%; + border: 0; + background: transparent; + color: #f2f2ee; + font: inherit; + font-size: 1rem; +} + +.command-panel__input:focus { + outline: none; +} + +.command-panel__list { + display: grid; + gap: 2px; + padding: 10px; + max-height: min(65vh, 520px); + overflow: auto; +} + +.command-item { + border: 0; + border-radius: 16px; + background: transparent; + color: #f2f2ee; + padding: 12px; + display: flex; + align-items: center; + gap: 12px; + text-align: left; + cursor: pointer; +} + +.command-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.command-item__icon { + width: 34px; + height: 34px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.06); + color: #e7e7e1; + flex: 0 0 auto; +} + +.command-item__copy { + display: grid; + gap: 2px; +} + +.command-item__copy strong { + font-weight: 600; +} + +.command-item__copy small, +.command-empty { + color: #9f9f98; +} + +.command-empty { + margin: 0; + padding: 18px 12px 14px; +} + +.notes-editor__content { + min-height: 58vh; + color: #ededea; + line-height: 1.7; + font-size: 1.02rem; + overflow-wrap: anywhere; + word-break: break-word; +} + +.notes-editor__content p { + margin: 0 0 12px; +} + +.notes-editor__content hr { + border: 0; + height: 1px; + background: rgba(255, 255, 255, 0.1); + margin: 20px 0; +} + +.notes-editor__content h1, +.notes-editor__content h2, +.notes-editor__content h3 { + margin: 0 0 12px; + line-height: 1.15; +} + +.notes-editor__content h1 { + font-size: 2.1rem; + overflow-wrap: anywhere; +} + +.notes-editor__content h2 { + font-size: 1.5rem; +} + +.notes-editor__content ul, +.notes-editor__content ol { + padding-left: 22px; +} + +.notes-editor__content ul[data-type='taskList'] { + list-style: none; + padding-left: 0; +} + +.notes-editor__content ul[data-type='taskList'] li { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 8px; +} + +.notes-editor__content ul[data-type='taskList'] li > label { + margin-top: 3px; +} + +.notes-editor__content ul[data-type='taskList'] li > div { + flex: 1 1 auto; +} + +.notes-editor__content blockquote { + margin: 0 0 12px; + padding-left: 16px; + border-left: 3px solid rgba(255, 255, 255, 0.18); + color: #b9b9b3; +} + +.notes-editor__content a { + color: #9fc8ff; + overflow-wrap: anywhere; +} + +.notes-editor__content img { + max-width: min(520px, 100%); + height: auto; + border-radius: 18px; + display: block; + margin: 14px 0; +} + +.notes-editor__content code { + padding: 2px 6px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); +} + +.notes-editor__content pre { + padding: 16px; + border-radius: 14px; + background: #0f0f10; + overflow: auto; +} + +.notes-editor__content table { + width: 100%; + border-collapse: collapse; + margin: 16px 0; + overflow: hidden; + border-radius: 14px; +} + +.notes-editor__content th, +.notes-editor__content td { + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 10px 12px; + text-align: left; +} + +.notes-editor__content th { + background: rgba(255, 255, 255, 0.06); +} + +.notes-footer { + justify-content: space-between; + gap: 18px; + margin-top: 24px; + padding-top: 18px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.notes-footer__stats, +.notes-footer__actions { + gap: 14px; + flex-wrap: wrap; +} + +.ghost-button { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + color: #f3f3ef; + padding: 10px 14px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ghost-button--danger { + color: #ffb1b1; +} + +.ghost-button--bright { + color: #171717; + background: #f4f4f2; +} + +.notes-empty { + min-height: calc(100vh - 160px); + display: grid; + place-content: center; + justify-items: start; + gap: 10px; +} + +.notes-empty h2, +.notes-empty p { + margin: 0; +} + +.toast-stack { + position: fixed; + right: 24px; + bottom: 24px; + display: grid; + gap: 10px; +} + +.toast { + padding: 12px 14px; + border-radius: 14px; + background: rgba(245, 245, 241, 0.94); + color: #151515; + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.25); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 980px) { + .auth-screen__panel, + .notes-app { + grid-template-columns: 1fr; + } + + .notes-sidebar { + min-height: 0; + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + } + + .notes-stage { + width: 100%; + } + + .notes-canvas { + min-height: 0; + } +} + +@media (max-width: 720px) { + .notes-workspace { + padding-inline: 12px; + } + + .notes-toolbar, + .notes-footer, + .notes-toolbar__right, + .notes-stage__meta { + flex-wrap: wrap; + } + + .toolbar-search { + width: 100%; + } + + .notes-title { + font-size: 1.7rem; + } + + .notes-canvas { + padding: 16px 14px 28px; + border-radius: 24px; + } + + .notes-canvas__head { + flex-direction: column; + align-items: stretch; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..022237b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,1320 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { FormEvent } from 'react' +import { EditorContent, useEditor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import Underline from '@tiptap/extension-underline' +import Link from '@tiptap/extension-link' +import Placeholder from '@tiptap/extension-placeholder' +import TextAlign from '@tiptap/extension-text-align' +import TaskList from '@tiptap/extension-task-list' +import TaskItem from '@tiptap/extension-task-item' +import Highlight from '@tiptap/extension-highlight' +import { TextStyle } from '@tiptap/extension-text-style' +import Color from '@tiptap/extension-color' +import Image from '@tiptap/extension-image' +import { Table } from '@tiptap/extension-table' +import TableRow from '@tiptap/extension-table-row' +import TableCell from '@tiptap/extension-table-cell' +import TableHeader from '@tiptap/extension-table-header' +import { + Archive, + Bold, + CheckSquare, + Code2, + Copy, + Download, + Highlighter, + Heading1, + Heading2, + Heading3, + ImagePlus, + Italic, + Link2, + List, + ListOrdered, + Minus, + PanelLeft, + PencilLine, + Pilcrow, + Pin, + Rows3, + Star, + Quote, + Search, + Share, + Slash, + SquarePen, + Strikethrough, + Table2, + Trash2, + Underline as UnderlineIcon, + Focus, +} from 'lucide-react' + +import './App.css' +import { + createNote, + deleteNote, + getCurrentUser, + getNotes, + login, + logout, + register, + updateNote, + uploadAttachment, +} from './lib/api' +import type { AuthPayload, Note, NotePayload } from './types' + +type SessionStatus = 'booting' | 'anonymous' | 'authenticated' +type AuthMode = 'login' | 'register' +type SaveState = 'idle' | 'dirty' | 'saving' | 'saved' +type Shelf = 'all' | 'pinned' | 'favorites' | 'archived' + +interface Toast { + id: number + text: string +} + +interface CommandItem { + id: string + label: string + hint: string + keywords: string + icon: typeof Pilcrow + action: () => void +} + +const defaultDraft: NotePayload = { + title: '', + text: '', + summary: '', + tags: [], + is_pinned: false, + is_favorite: false, + is_archived: false, +} + +const shelfLabels: Record = { + all: 'Notes', + pinned: 'Pinned', + favorites: 'Favorites', + archived: 'Archive', +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat('ru-RU', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)) +} + +function sortNotes(items: Note[]) { + return [...items].sort( + (left, right) => + new Date(right.edit_time).getTime() - new Date(left.edit_time).getTime(), + ) +} + +function htmlToPlainText(value: string) { + if (!value) { + return '' + } + + const doc = new DOMParser().parseFromString(value, 'text/html') + return doc.body.textContent?.replace(/\s+/g, ' ').trim() ?? '' +} + +function extractSummary(value: string) { + const text = htmlToPlainText(value) + return text.slice(0, 280) +} + +function estimateReadingTime(words: number) { + return Math.max(1, Math.ceil(words / 180)) +} + +function noteToPayload(note: Note): NotePayload { + return { + title: note.title, + text: note.text ?? '', + summary: note.summary ?? '', + tags: note.tags, + is_pinned: note.is_pinned, + is_favorite: note.is_favorite, + is_archived: note.is_archived, + } +} + +function payloadEqualsNote(payload: NotePayload, note: Note | null) { + if (!note) { + return false + } + + return JSON.stringify({ + ...payload, + text: payload.text ?? '', + summary: payload.summary ?? '', + }) === JSON.stringify(noteToPayload(note)) +} + +function App() { + const [sessionStatus, setSessionStatus] = useState('booting') + const [authMode, setAuthMode] = useState('login') + const [authForm, setAuthForm] = useState({ login: '', password: '' }) + const [authError, setAuthError] = useState('') + const [authBusy, setAuthBusy] = useState(false) + + const [notes, setNotes] = useState([]) + const [selectedNoteId, setSelectedNoteId] = useState(null) + const [draft, setDraft] = useState(defaultDraft) + const [saveState, setSaveState] = useState('idle') + const [notesBusy, setNotesBusy] = useState(false) + const [deleteBusy, setDeleteBusy] = useState(false) + const [notice, setNotice] = useState('Private notes synced with secure cookie auth.') + const [searchQuery, setSearchQuery] = useState('') + const [activeShelf, setActiveShelf] = useState('all') + const [toolbarOpen, setToolbarOpen] = useState(false) + const [sidebarOpen, setSidebarOpen] = useState(true) + const [focusMode, setFocusMode] = useState(false) + const [tagInput, setTagInput] = useState('') + const [uploadBusy, setUploadBusy] = useState(false) + const [dragActive, setDragActive] = useState(false) + const [commandOpen, setCommandOpen] = useState(false) + const [commandQuery, setCommandQuery] = useState('') + const [toasts, setToasts] = useState([]) + + const toastIdRef = useRef(0) + const autosaveTimerRef = useRef(null) + const titleRef = useRef(null) + const fileInputRef = useRef(null) + const hydratedNoteIdRef = useRef(null) + const dragDepthRef = useRef(0) + + const selectedNote = notes.find((note) => note.id === selectedNoteId) ?? null + const shelfCounts = useMemo( + () => ({ + all: notes.filter((note) => !note.is_archived).length, + pinned: notes.filter((note) => note.is_pinned && !note.is_archived).length, + favorites: notes.filter((note) => note.is_favorite && !note.is_archived).length, + archived: notes.filter((note) => note.is_archived).length, + }), + [notes], + ) + + const filteredNotes = useMemo(() => { + const normalized = searchQuery.trim().toLowerCase() + + const shelfItems = notes.filter((note) => { + if (activeShelf === 'pinned') { + return note.is_pinned && !note.is_archived + } + + if (activeShelf === 'favorites') { + return note.is_favorite && !note.is_archived + } + + if (activeShelf === 'archived') { + return note.is_archived + } + + return !note.is_archived + }) + + if (!normalized) { + return shelfItems + } + + return shelfItems.filter((note) => { + const haystack = [ + note.title, + note.summary ?? '', + htmlToPlainText(note.text ?? ''), + ] + .join('\n') + .toLowerCase() + + return haystack.includes(normalized) + }) + }, [activeShelf, notes, searchQuery]) + + const pushToast = useCallback((text: string) => { + const nextToast = { id: ++toastIdRef.current, text } + setToasts((current) => [...current, nextToast]) + window.setTimeout(() => { + setToasts((current) => current.filter((toast) => toast.id !== nextToast.id)) + }, 2600) + }, []) + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + heading: { + levels: [1, 2, 3], + }, + }), + Underline, + Link.configure({ + openOnClick: false, + }), + Placeholder.configure({ + placeholder: 'НачнитС ΠΏΠΈΡΠ°Ρ‚ΡŒ замСтку…', + }), + TaskList, + TaskItem.configure({ + nested: true, + }), + Highlight, + TextStyle, + Color, + Image.configure({ + inline: false, + allowBase64: false, + }), + Table.configure({ + resizable: true, + }), + TableRow, + TableHeader, + TableCell, + TextAlign.configure({ + types: ['heading', 'paragraph'], + }), + ], + content: '

', + editorProps: { + attributes: { + class: 'notes-editor__content', + }, + }, + onUpdate: ({ editor: instance }) => { + const html = instance.getHTML() + setDraft((current) => ({ + ...current, + text: html, + summary: extractSummary(html), + })) + }, + }) + + const dirty = selectedNote ? !payloadEqualsNote(draft, selectedNote) : false + const plainText = htmlToPlainText(draft.text ?? '') + const wordCount = plainText ? plainText.split(/\s+/).length : 0 + const readingTime = estimateReadingTime(wordCount) + + const commandItems = useMemo(() => { + if (!editor) { + return [] + } + + return [ + { + id: 'paragraph', + label: 'Body text', + hint: 'Default paragraph block', + keywords: 'paragraph body text normal', + icon: Pilcrow, + action: () => editor.chain().focus().setParagraph().run(), + }, + { + id: 'title', + label: 'Large heading', + hint: 'Primary section title', + keywords: 'heading h1 title big', + icon: Heading1, + action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), + }, + { + id: 'section', + label: 'Section heading', + hint: 'Secondary heading block', + keywords: 'heading h2 section subtitle', + icon: Heading2, + action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + }, + { + id: 'subsection', + label: 'Subsection', + hint: 'Compact heading block', + keywords: 'heading h3 subsection', + icon: Heading3, + action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), + }, + { + id: 'quote', + label: 'Quote', + hint: 'Block quote with emphasis', + keywords: 'quote citation blockquote', + icon: Quote, + action: () => editor.chain().focus().toggleBlockquote().run(), + }, + { + id: 'bullets', + label: 'Bullet list', + hint: 'Unordered list', + keywords: 'list bullets unordered', + icon: List, + action: () => editor.chain().focus().toggleBulletList().run(), + }, + { + id: 'numbered', + label: 'Numbered list', + hint: 'Ordered sequence', + keywords: 'list ordered numbered', + icon: ListOrdered, + action: () => editor.chain().focus().toggleOrderedList().run(), + }, + { + id: 'checklist', + label: 'Checklist', + hint: 'Trackable task list', + keywords: 'task checklist todos', + icon: CheckSquare, + action: () => editor.chain().focus().toggleTaskList().run(), + }, + { + id: 'code', + label: 'Code block', + hint: 'Monospaced preformatted block', + keywords: 'code snippet block', + icon: Code2, + action: () => editor.chain().focus().toggleCodeBlock().run(), + }, + { + id: 'divider', + label: 'Divider', + hint: 'Visual section break', + keywords: 'divider separator line rule', + icon: Minus, + action: () => editor.chain().focus().insertContent('

').run(), + }, + { + id: 'table', + label: 'Table', + hint: '3 by 3 grid', + keywords: 'table grid columns rows', + icon: Table2, + action: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), + }, + { + id: 'image', + label: 'Image', + hint: 'Upload from device', + keywords: 'image media upload picture photo', + icon: ImagePlus, + action: () => fileInputRef.current?.click(), + }, + ] + }, [editor]) + + const filteredCommands = useMemo(() => { + const query = commandQuery.trim().toLowerCase() + if (!query) { + return commandItems + } + + return commandItems.filter((item) => + `${item.label} ${item.hint} ${item.keywords}`.toLowerCase().includes(query), + ) + }, [commandItems, commandQuery]) + + const syncDraft = useCallback((nextNote: Note | null) => { + const payload = nextNote ? noteToPayload(nextNote) : defaultDraft + setDraft(payload) + setTagInput(payload.tags.join(', ')) + setSaveState('idle') + setToolbarOpen(false) + + if (editor) { + editor.commands.setContent(payload.text || '

', { emitUpdate: false }) + } + }, [editor]) + + const loadNotes = useCallback(async (preferredId?: number | null) => { + setNotesBusy(true) + + try { + const nextNotes = sortNotes(await getNotes()) + setNotes(nextNotes) + setSelectedNoteId((currentId) => { + const candidate = preferredId ?? currentId + if (candidate && nextNotes.some((note) => note.id === candidate)) { + return candidate + } + return nextNotes[0]?.id ?? null + }) + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Failed to load notes.') + } finally { + setNotesBusy(false) + } + }, []) + + useEffect(() => { + void (async () => { + try { + const user = await getCurrentUser() + setSessionStatus('authenticated') + setNotice(`Welcome back, ${user.login}.`) + await loadNotes() + } catch { + setNotes([]) + setSelectedNoteId(null) + setSessionStatus('anonymous') + setNotice('Sign in to open your notes workspace.') + } + })() + }, [loadNotes]) + + useEffect(() => { + if (!editor) { + return + } + + if (selectedNoteId === null) { + if (hydratedNoteIdRef.current !== null) { + hydratedNoteIdRef.current = null + syncDraft(null) + } + return + } + + if (hydratedNoteIdRef.current === selectedNoteId) { + return + } + + const nextNote = notes.find((note) => note.id === selectedNoteId) ?? null + hydratedNoteIdRef.current = selectedNoteId + syncDraft(nextNote) + }, [editor, notes, selectedNoteId, syncDraft]) + + const persistCurrentNote = useCallback(async (successNotice?: string, silent = false) => { + if (!selectedNoteId || !selectedNote) { + return + } + + setSaveState('saving') + + try { + const payload = { + ...draft, + title: draft.title.trim() || 'Untitled note', + text: draft.text || '

', + summary: extractSummary(draft.text || ''), + } + + const updated = await updateNote(selectedNoteId, payload) + setNotes((current) => + sortNotes(current.map((note) => (note.id === updated.id ? updated : note))), + ) + setDraft(noteToPayload(updated)) + setTagInput(updated.tags.join(', ')) + setSaveState('saved') + setNotice(successNotice ?? 'Note saved.') + if (!silent) { + pushToast(successNotice ?? 'Saved') + } + } catch (error) { + setSaveState('dirty') + setNotice(error instanceof Error ? error.message : 'Failed to save note.') + if (!silent) { + pushToast('Save failed') + } + } + }, [draft, pushToast, selectedNote, selectedNoteId]) + + useEffect(() => { + if (!selectedNote || !dirty) { + if (saveState !== 'saving') { + setSaveState(selectedNote ? 'saved' : 'idle') + } + return + } + + setSaveState('dirty') + if (autosaveTimerRef.current) { + window.clearTimeout(autosaveTimerRef.current) + } + + autosaveTimerRef.current = window.setTimeout(() => { + void persistCurrentNote('Autosaved.', true) + }, 900) + + return () => { + if (autosaveTimerRef.current) { + window.clearTimeout(autosaveTimerRef.current) + } + } + }, [dirty, persistCurrentNote, saveState, selectedNote]) + + const handleCreateNote = useCallback(async () => { + try { + const note = await createNote({ + ...defaultDraft, + title: `Untitled note ${notes.length + 1}`, + }) + + setNotes((current) => sortNotes([note, ...current.filter((item) => item.id !== note.id)])) + setSelectedNoteId(note.id) + setNotice('New note created.') + pushToast('New note') + window.setTimeout(() => titleRef.current?.focus(), 60) + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Failed to create note.') + } + }, [notes.length, pushToast]) + + const handleDuplicateNote = useCallback(async () => { + if (!selectedNote) { + return + } + + try { + const duplicate = await createNote({ + ...noteToPayload(selectedNote), + title: `${selectedNote.title} copy`, + }) + setNotes((current) => sortNotes([duplicate, ...current])) + setSelectedNoteId(duplicate.id) + setNotice('Note duplicated.') + pushToast('Duplicated') + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Failed to duplicate note.') + } + }, [pushToast, selectedNote]) + + const handleDeleteNote = useCallback(async () => { + if (!selectedNoteId || !selectedNote) { + return + } + + if (!window.confirm(`Delete "${selectedNote.title}"?`)) { + return + } + + setDeleteBusy(true) + try { + await deleteNote(selectedNoteId) + const remaining = notes.filter((note) => note.id !== selectedNoteId) + setNotes(remaining) + setSelectedNoteId(remaining[0]?.id ?? null) + setNotice('Note deleted.') + pushToast('Deleted') + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Failed to delete note.') + } finally { + setDeleteBusy(false) + } + }, [notes, pushToast, selectedNote, selectedNoteId]) + + const handleExportNote = useCallback(() => { + if (!selectedNote) { + return + } + + const content = ` + + + + + ${draft.title || 'Untitled note'} + + + +

${draft.title || 'Untitled note'}

+ ${draft.text || '

'} + +` + + const blob = new Blob([content], { type: 'text/html;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${(draft.title || 'untitled-note').replace(/\s+/g, '-').toLowerCase()}.html` + link.click() + URL.revokeObjectURL(url) + pushToast('Exported') + }, [draft, pushToast, selectedNote]) + + const updateDraftFlag = useCallback((key: 'is_pinned' | 'is_favorite' | 'is_archived') => { + setDraft((current) => ({ ...current, [key]: !current[key] })) + }, []) + + const applyTags = useCallback((value: string) => { + const nextTags = value + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean) + + setTagInput(value) + setDraft((current) => ({ ...current, tags: nextTags })) + }, []) + + const handleCommandSelect = useCallback((command: CommandItem) => { + command.action() + setCommandOpen(false) + setCommandQuery('') + setToolbarOpen(false) + }, []) + + const uploadImageFile = useCallback(async (file: File) => { + if (!editor || !selectedNoteId) { + return + } + + setUploadBusy(true) + try { + const result = await uploadAttachment(file) + editor.chain().focus().setImage({ src: result.url, alt: file.name }).run() + setNotice('Image inserted into note.') + pushToast('Image added') + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Failed to upload image.') + pushToast('Upload failed') + } finally { + setUploadBusy(false) + } + }, [editor, pushToast, selectedNoteId]) + + async function handleAuthSubmit(event: FormEvent) { + event.preventDefault() + setAuthBusy(true) + setAuthError('') + + try { + if (authMode === 'register') { + await register(authForm) + } + await login(authForm) + const user = await getCurrentUser() + setSessionStatus('authenticated') + setAuthForm({ login: '', password: '' }) + setNotice(authMode === 'register' ? `Account created for ${user.login}.` : `Signed in as ${user.login}.`) + await loadNotes() + } catch (error) { + setAuthError(error instanceof Error ? error.message : 'Authentication failed.') + } finally { + setAuthBusy(false) + } + } + + async function handleLogout() { + setAuthBusy(true) + try { + await logout() + } finally { + setNotes([]) + setSelectedNoteId(null) + setDraft(defaultDraft) + setSessionStatus('anonymous') + setAuthBusy(false) + setNotice('Signed out.') + } + } + + async function handleUploadImage() { + const file = fileInputRef.current?.files?.[0] + if (!file) { + return + } + + try { + await uploadImageFile(file) + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + + useEffect(() => { + function onKeyDown(event: globalThis.KeyboardEvent) { + const meta = event.metaKey || event.ctrlKey + + if (meta && event.key.toLowerCase() === 's') { + event.preventDefault() + void persistCurrentNote('Saved manually.') + } + + if (meta && event.key.toLowerCase() === 'n') { + event.preventDefault() + void handleCreateNote() + } + + if (meta && event.key.toLowerCase() === 'k') { + event.preventDefault() + setCommandOpen(true) + } + + if (event.key === '/' && editor?.isFocused) { + const currentBlockText = editor.state.selection.$from.parent.textContent.trim() + if (!currentBlockText) { + event.preventDefault() + setCommandOpen(true) + } + } + + if (event.key === 'Escape') { + setCommandOpen(false) + } + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [editor, handleCreateNote, persistCurrentNote]) + + if (sessionStatus === 'booting') { + return ( +
+
+ +

Opening notes…

+
+
+ ) + } + + if (sessionStatus === 'anonymous') { + return ( +
+
+
+ Cloud Notes +

Dark, focused notes with visual formatting.

+

+ Apple Notes inspired workspace with secure sign in, formatting buttons, note + library and autosave. +

+
+ +
+ + + + + {authError ?

{authError}

: null} + + + + +
+
+
+ ) + } + + return ( +
+ + +
+
+
+ +
+ +
+
+ + + + + + + + + + + +
+ + {toolbarOpen ? ( +
+ + + + + + + + + +
+ ) : null} +
+ +
+ + {selectedNote ? ( + <> + + + + + ) : null} + + + + setSearchQuery(event.target.value)} + /> +
+
+ +
+
+ {selectedNote ? formatDate(selectedNote.edit_time) : 'No note selected'} + {saveState === 'saving' ? 'Saving…' : saveState === 'dirty' ? 'Unsaved' : notice} + {selectedNote ? ( + + {draft.is_pinned ? 'Pinned' : draft.is_favorite ? 'Favorite' : draft.is_archived ? 'Archived' : 'Draft'} + + ) : null} +
+ + {selectedNote ? ( +
{ + event.preventDefault() + dragDepthRef.current += 1 + setDragActive(true) + }} + onDragOver={(event) => { + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + }} + onDragLeave={(event) => { + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) { + setDragActive(false) + } + }} + onDrop={(event) => { + event.preventDefault() + dragDepthRef.current = 0 + setDragActive(false) + + const file = Array.from(event.dataTransfer.files).find((item) => + item.type.startsWith('image/'), + ) + + if (file) { + void uploadImageFile(file) + } + }} + > + {dragActive ? ( +
+ Drop image to insert + PNG, JPG, WEBP or GIF +
+ ) : null} + +
+
+ {wordCount} words + {readingTime} min read + {draft.tags.length} tags + {uploadBusy ? Uploading image… : null} +
+ + +
+ + + setDraft((current) => ({ ...current, title: event.target.value })) + } + /> + + {draft.tags.length ? ( +
+ {draft.tags.map((tag) => ( + + ))} +
+ ) : null} + +
+ + + + + +
+ + + +
+
+ {wordCount} words + {readingTime} min read + {draft.tags.length ? `${draft.tags.length} tags` : 'No tags'} + {draft.summary ? `${draft.summary.length} summary chars` : 'No summary'} +
+
+ + + + + +
+
+
+ ) : ( +
+

No note selected

+

Create a note to start writing.

+ +
+ )} +
+
+ + void handleUploadImage()} + /> + +
+ {toasts.map((toast) => ( +
+ {toast.text} +
+ ))} +
+ + {commandOpen ? ( +
setCommandOpen(false)}> +
event.stopPropagation()}> +
+ + setCommandQuery(event.target.value)} + /> +
+ +
+ {filteredCommands.map((command) => { + const Icon = command.icon + return ( + + ) + })} + {!filteredCommands.length ? ( +

No commands found.

+ ) : null} +
+
+
+ ) : null} +
+ ) +} + +export default App diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7806ab2 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,56 @@ +@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap'); + +:root { + font-family: var(--font-sans); + line-height: 1.5; + font-weight: 400; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(231, 135, 62, 0.16), transparent 28%), + radial-gradient(circle at right 20%, rgba(27, 112, 93, 0.1), transparent 24%), + linear-gradient(180deg, #f8efe0 0%, #f5ecd9 40%, #f7f1e7 100%); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + --font-sans: 'Manrope', 'Segoe UI', sans-serif; + --font-display: 'Fraunces', Georgia, serif; + --ink: #111827; + --ink-soft: #60574a; + --accent: #e7873e; + --accent-deep: #9c531c; + --forest: #1b705d; + --danger: #9b3336; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-width: 320px; +} + +button, +input, +textarea { + font: inherit; +} + +button { + -webkit-tap-highlight-color: transparent; +} + +#root { + min-height: 100vh; +} + +a { + color: inherit; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..ba6bb3d --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,101 @@ +import type { AuthPayload, Note, NotePayload, UploadResponse, User } from '../types' + +const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '/api').replace( + /\/$/, + '', +) + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: 'include', + headers: { + Accept: 'application/json', + ...(init?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...init?.headers, + }, + ...init, + }) + + if (!response.ok) { + let message = 'Request failed.' + + try { + const data = (await response.json()) as { detail?: string } + if (data.detail) { + message = data.detail + } + } catch { + message = response.statusText || message + } + + throw new Error(message) + } + + if (response.status === 204) { + return undefined as T + } + + return (await response.json()) as T +} + +export function register(payload: AuthPayload) { + return request('/users/register', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function login(payload: AuthPayload) { + return request<{ message: string }>('/users/login', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function logout() { + return request<{ message: string }>('/users/logout', { + method: 'POST', + }) +} + +export function getCurrentUser() { + return request('/users/me') +} + +export function getNotes() { + return request('/notes') +} + +export function createNote(payload: NotePayload) { + return request('/notes', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function getNote(noteId: number) { + return request(`/notes/${noteId}`) +} + +export function updateNote(noteId: number, payload: NotePayload) { + return request(`/notes/${noteId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }) +} + +export function deleteNote(noteId: number) { + return request<{ message: string }>(`/notes/${noteId}`, { + method: 'DELETE', + }) +} + +export function uploadAttachment(file: File) { + const formData = new FormData() + formData.append('file', file) + + return request('/attachments', { + method: 'POST', + body: formData, + }) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..04876e7 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,38 @@ +export interface User { + id: number + login: string + theme: string +} + +export interface Note { + id: number + title: string + text: string | null + summary: string | null + tags: string[] + is_pinned: boolean + is_favorite: boolean + is_archived: boolean + created_time: string + edit_time: string + creator_id: number +} + +export interface AuthPayload { + login: string + password: string +} + +export interface NotePayload { + title: string + text: string | null + summary: string | null + tags: string[] + is_pinned: boolean + is_favorite: boolean + is_archived: boolean +} + +export interface UploadResponse { + url: string +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..a133858 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + }, + preview: { + host: '0.0.0.0', + port: 4173, + }, +})