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
[](https://docker.com)
[](https://www.jwt.io)
[](https://pydantic.dev)
+[](https://react.dev)
+[](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 (
+
+
+
+ )
+ }
+
+ 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.
+
+
+
+
+
+
+ )
+ }
+
+ 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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
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 @@
+
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,
+ },
+})