diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5322794..2955441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: Cloud Notes Continuous Integration on: push: - branches: [ main ] + branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] jobs: test: @@ -30,12 +30,18 @@ jobs: - name: Install Linting Tools run: | python -m pip install --upgrade pip - pip install flake8 + pip install flake8 -r backend/requirements.txt - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 backend/app backend/migrations backend/tests \ + --count --select=E9,F63,F7,F82 --show-source --statistics + + - name: Run backend tests + env: + PYTHONPATH: backend + run: python -m unittest discover -s backend/tests -v - name: Validate Docker Compose run: docker compose config --quiet diff --git a/README.md b/README.md index 9d17103..3e1e114 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Self-hosted cloud notes with a focused React editor, FastAPI backend, PostgreSQL [![Caddy](https://img.shields.io/badge/caddy-grey?style=for-the-badge&logo=caddy)](https://caddyserver.com) - **Frontend:** React 19, TypeScript, Vite, TipTap, and Lucide icons -- **Backend:** FastAPI, Pydantic, and async SQLAlchemy 2.0 +- **Backend:** FastAPI, Pydantic, async SQLAlchemy 2.0, and Redis pub/sub - **Database:** PostgreSQL 16 - **Authentication:** JWT in HttpOnly cookies with bcrypt password hashing - **Web server:** Caddy providing automatic HTTPS in front of the internal Nginx service @@ -63,7 +63,7 @@ Docker or OrbStack is the only requirement. Python, Node.js, Nginx, and PostgreS - Application: `https://notes.example.com` - API documentation: `https://notes.example.com/api/docs` -Caddy obtains and renews the public TLS certificate automatically. PostgreSQL, FastAPI, Nginx, and the Vite build are reachable only through the private Docker network. +Caddy obtains and renews the public TLS certificate automatically. PostgreSQL, Redis, FastAPI, Nginx, and the Vite build are reachable only through the private Docker network. ## 🧪 Local HTTP Testing @@ -138,6 +138,20 @@ Apply configuration changes with: docker compose up -d --build ``` +Database migrations run automatically before the API starts. To inspect or apply +them manually, use: + +```bash +docker compose run --rm server alembic current +docker compose run --rm server alembic upgrade head +``` + +Create a migration after changing SQLAlchemy models with: + +```bash +docker compose run --rm server alembic revision --autogenerate -m "describe change" +``` + ## ✅ Production Checklist Before exposing the application to the internet: @@ -193,6 +207,7 @@ Before exposing the application to the internet: | Nginx frontend | 80 | Not published | Docker network only | | FastAPI backend | 8000 | Not published | Docker network only | | PostgreSQL | 5432 | Not published | Docker network only | +| Redis | 6379 | Not published | Docker network only | Do not forward ports 5432, 8000, or the former frontend port 8080 from the router. diff --git a/backend/Dockerfile b/backend/Dockerfile index 6628b5e..52ecadf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,4 +19,4 @@ USER appuser EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] +CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=*"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..fc4ec07 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = migrations +prepend_sys_path = . + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index 178c1d0..477d6c1 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -1,7 +1,6 @@ -import json import re -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, case, desc @@ -31,12 +30,12 @@ def normalize_tags(tags: list[str]) -> list[str]: return normalized[:12] -def serialize_note(note: Note) -> NotePublic: - try: - tags = json.loads(note.tags or "[]") - except json.JSONDecodeError: - tags = [] +def event_source(request: Request) -> str | None: + client_id = request.headers.get("X-Client-Id", "").strip() + return client_id[:128] or None + +def serialize_note(note: Note) -> NotePublic: normalized_text = LEGACY_ATTACHMENT_URL.sub( "/api/attachments/download/", note.text or "", @@ -47,7 +46,7 @@ def serialize_note(note: Note) -> NotePublic: title=note.title, text=normalized_text, summary=note.summary, - tags=tags if isinstance(tags, list) else [], + tags=note.tags if isinstance(note.tags, list) else [], is_pinned=note.is_pinned, is_favorite=note.is_favorite, is_archived=note.is_archived, @@ -59,12 +58,17 @@ def serialize_note(note: Note) -> NotePublic: @notes_router.post("", response_model=NotePublic) -async def create_note(userdata: NoteCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def create_note( + userdata: NoteCreate, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): note = Note( title=userdata.title, text=userdata.text, summary=userdata.summary, - tags=json.dumps(normalize_tags(userdata.tags)), + tags=normalize_tags(userdata.tags), is_pinned=userdata.is_pinned, is_favorite=userdata.is_favorite, is_archived=userdata.is_archived, @@ -76,7 +80,11 @@ async def create_note(userdata: NoteCreate, db: AsyncSession = Depends(get_db), result = serialize_note(note) await realtime_hub.publish( current_user.id, - {"type": "note_created", "note": result.model_dump(mode="json")}, + { + "type": "note_created", + "note": result.model_dump(mode="json"), + "source_client_id": event_source(request), + }, ) return result @@ -103,7 +111,12 @@ async def get_note(note_id: int, db: AsyncSession = Depends(get_db), current_use 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)): +async def delete_note( + note_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): query = select(Note).where(Note.creator_id == current_user.id, Note.id == note_id) result = await db.execute(query) note = result.scalar_one_or_none() @@ -113,7 +126,11 @@ async def delete_note(note_id: int, db: AsyncSession = Depends(get_db), current_ await db.commit() await realtime_hub.publish( current_user.id, - {"type": "note_deleted", "note_id": note_id}, + { + "type": "note_deleted", + "note_id": note_id, + "source_client_id": event_source(request), + }, ) return {"message": "Note deleted successfully"} @@ -121,6 +138,7 @@ async def delete_note(note_id: int, db: AsyncSession = Depends(get_db), current_ async def update_note( note_id: int, userdata: NoteUpdate, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -145,7 +163,7 @@ 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.tags = normalize_tags(userdata.tags) note.is_pinned = userdata.is_pinned note.is_favorite = userdata.is_favorite note.is_archived = userdata.is_archived @@ -155,6 +173,10 @@ async def update_note( result = serialize_note(note) await realtime_hub.publish( current_user.id, - {"type": "note_updated", "note": result.model_dump(mode="json")}, + { + "type": "note_updated", + "note": result.model_dump(mode="json"), + "source_client_id": event_source(request), + }, ) return result diff --git a/backend/app/api/system.py b/backend/app/api/system.py index 786b7da..408ddab 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -1,3 +1,5 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession @@ -6,11 +8,16 @@ from ..database import get_db system_router = APIRouter(prefix="/system", tags=["System"]) +logger = logging.getLogger(__name__) @system_router.get("/health-check") async def health_check(db: AsyncSession = Depends(get_db)): try: await db.execute(text("""SELECT 1""")) return {"status": "ok", "database": "connected", "message": "Health check successful"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file + except Exception as exception: + logger.exception("Database health check failed") + raise HTTPException( + status_code=503, + detail="Database is unavailable", + ) from exception diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 06deca7..ffe2155 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -4,24 +4,20 @@ from sqlalchemy import select import os -import time - from ..schemas import Register, Login, ThemeUpdate, 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 from ..realtime import realtime_hub +from ..rate_limit import login_rate_limiter user_router = APIRouter(prefix="/users", tags=["Users"]) COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true" REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "true").lower() == "true" -LOGIN_WINDOW_SECONDS = 60 -LOGIN_MAX_ATTEMPTS = 5 # PRODUCTION: configure a shorter lifetime in .env when persistent login is not required. SESSION_MAX_AGE_SECONDS = int(os.getenv("SESSION_MAX_AGE_SECONDS", str(10 * 365 * 24 * 60 * 60))) -login_attempts: dict[str, list[float]] = {} @user_router.post("/register", response_model=UserPublic) async def register(userdata:Register, db: AsyncSession = Depends(get_db)): @@ -44,12 +40,7 @@ async def register(userdata:Register, db: AsyncSession = Depends(get_db)): @user_router.post("/login") async def login(userdata:Login, request: Request, response:Response, db: AsyncSession = Depends(get_db)): client_id = request.client.host if request.client else "unknown" - now = time.monotonic() - recent_attempts = [ - attempt for attempt in login_attempts.get(client_id, []) - if now - attempt < LOGIN_WINDOW_SECONDS - ] - if len(recent_attempts) >= LOGIN_MAX_ATTEMPTS: + if await login_rate_limiter.is_blocked(client_id): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many login attempts. Try again in one minute.", @@ -59,14 +50,14 @@ async def login(userdata:Login, request: Request, response:Response, db: AsyncSe result = await db.execute(query) user = result.scalar_one_or_none() if user is None: - login_attempts[client_id] = [*recent_attempts, now] + await login_rate_limiter.record_failure(client_id) raise HTTPException(status_code=401, detail="Invalid credentials") if not verify_password(userdata.password, user.pass_hash): - login_attempts[client_id] = [*recent_attempts, now] + await login_rate_limiter.record_failure(client_id) raise HTTPException(status_code=401, detail="Invalid credentials") - login_attempts.pop(client_id, None) + await login_rate_limiter.clear(client_id) token = create_access_token(user_id=user.id) diff --git a/backend/app/main.py b/backend/app/main.py index 390e11a..c34a967 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,36 +1,24 @@ import os -import asyncio import shutil from pathlib import Path from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware -from .database import Base, engine -from .models import User, Note +from .database import engine from .api.users import user_router from .api.notes import notes_router from .api.system import system_router from .api.attachments import attachments_router from .realtime import realtime_hub +from .rate_limit import login_rate_limiter from .utils import decode_access_token @asynccontextmanager async def lifespan(app: FastAPI): - retries = 5 - while retries > 0: - try: - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - print("Successfully connected to the database and created tables!") - break - except Exception as e: - retries -= 1 - print(f"Database is not ready yet. Retrying in 2 seconds... ({retries} retries left)") - if retries == 0: - raise RuntimeError("Could not connect to the database") from e - await asyncio.sleep(2) - + redis_url = os.getenv("REDIS_URL") + await realtime_hub.start(redis_url) + await login_rate_limiter.start(redis_url) upload_dir = Path(os.getenv("UPLOAD_DIR", "uploads")) legacy_upload_dir = Path("/legacy-uploads") upload_dir.mkdir(parents=True, exist_ok=True) @@ -43,6 +31,8 @@ async def lifespan(app: FastAPI): try: yield finally: + await login_rate_limiter.stop() + await realtime_hub.stop() await engine.dispose() app = FastAPI( @@ -85,6 +75,8 @@ async def realtime_events(websocket: WebSocket): while True: await websocket.receive_text() except WebSocketDisconnect: + pass + finally: realtime_hub.disconnect(user_id, websocket) @app.get("/") diff --git a/backend/app/models.py b/backend/app/models.py index dc840c9..1c2c295 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, Boolean +from sqlalchemy import String, Integer, ForeignKey, Text, DateTime, Boolean, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship from .database import Base @@ -22,7 +22,7 @@ class Note(Base): 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) + tags: Mapped[list[str]] = mapped_column(JSON, default=list, 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) @@ -38,4 +38,4 @@ class Attachment(Base): id: Mapped[str] = mapped_column(String(256), primary_key=True) original_name: Mapped[str] = mapped_column(String(256), nullable=False) - creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) \ No newline at end of file + creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py new file mode 100644 index 0000000..3382026 --- /dev/null +++ b/backend/app/rate_limit.py @@ -0,0 +1,77 @@ +import asyncio +import logging +import time + +from redis.asyncio import Redis + + +logger = logging.getLogger(__name__) + + +class LoginRateLimiter: + def __init__(self, max_attempts: int, window_seconds: int) -> None: + self.max_attempts = max_attempts + self.window_seconds = window_seconds + self._redis: Redis | None = None + self._local_attempts: dict[str, list[float]] = {} + self._local_lock = asyncio.Lock() + + async def start(self, redis_url: str | None) -> None: + if not redis_url: + logger.warning("REDIS_URL is not set; login limits are local only") + return + self._redis = Redis.from_url(redis_url, decode_responses=True) + await self._redis.ping() + + async def stop(self) -> None: + if self._redis is not None: + await self._redis.aclose() + self._redis = None + + async def is_blocked(self, client_id: str) -> bool: + if self._redis is not None: + attempts = await self._redis.get(self._key(client_id)) + return attempts is not None and int(attempts) >= self.max_attempts + return len(await self._recent_local(client_id)) >= self.max_attempts + + async def record_failure(self, client_id: str) -> None: + if self._redis is not None: + key = self._key(client_id) + attempts = await self._redis.incr(key) + if attempts == 1: + await self._redis.expire(key, self.window_seconds) + return + async with self._local_lock: + recent = self._recent_local_unlocked(client_id) + self._local_attempts[client_id] = [*recent, time.monotonic()] + + async def clear(self, client_id: str) -> None: + if self._redis is not None: + await self._redis.delete(self._key(client_id)) + return + async with self._local_lock: + self._local_attempts.pop(client_id, None) + + async def _recent_local(self, client_id: str) -> list[float]: + async with self._local_lock: + recent = self._recent_local_unlocked(client_id) + if recent: + self._local_attempts[client_id] = recent + else: + self._local_attempts.pop(client_id, None) + return recent + + def _recent_local_unlocked(self, client_id: str) -> list[float]: + now = time.monotonic() + return [ + attempt + for attempt in self._local_attempts.get(client_id, []) + if now - attempt < self.window_seconds + ] + + @staticmethod + def _key(client_id: str) -> str: + return f"cloud-notes:login-attempts:{client_id}" + + +login_rate_limiter = LoginRateLimiter(max_attempts=5, window_seconds=60) diff --git a/backend/app/realtime.py b/backend/app/realtime.py index 41e5df8..5cbf1c6 100644 --- a/backend/app/realtime.py +++ b/backend/app/realtime.py @@ -1,11 +1,59 @@ +import asyncio +import json +import logging from collections import defaultdict from fastapi import WebSocket +from redis.asyncio import Redis + + +logger = logging.getLogger(__name__) +CHANNEL = "cloud-notes:events" class RealtimeHub: def __init__(self) -> None: self._connections: dict[int, set[WebSocket]] = defaultdict(set) + self._redis: Redis | None = None + self._listener: asyncio.Task | None = None + + async def start(self, redis_url: str | None) -> None: + if not redis_url: + logger.warning("REDIS_URL is not set; realtime events are local only") + return + self._redis = Redis.from_url(redis_url, decode_responses=True) + await self._redis.ping() + pubsub = self._redis.pubsub() + await pubsub.subscribe(CHANNEL) + self._listener = asyncio.create_task(self._listen(pubsub)) + + async def stop(self) -> None: + if self._listener is not None: + self._listener.cancel() + try: + await self._listener + except asyncio.CancelledError: + pass + self._listener = None + if self._redis is not None: + await self._redis.aclose() + self._redis = None + + async def _listen(self, pubsub) -> None: + try: + async for message in pubsub.listen(): + if message["type"] != "message": + continue + try: + envelope = json.loads(message["data"]) + await self._publish_local( + int(envelope["user_id"]), + envelope["event"], + ) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + logger.warning("Ignored an invalid realtime event") + finally: + await pubsub.aclose() async def connect(self, user_id: int, websocket: WebSocket) -> None: await websocket.accept() @@ -20,11 +68,21 @@ def disconnect(self, user_id: int, websocket: WebSocket) -> None: self._connections.pop(user_id, None) async def publish(self, user_id: int, event: dict) -> None: + if self._redis is not None: + await self._redis.publish( + CHANNEL, + json.dumps({"user_id": user_id, "event": event}), + ) + return + await self._publish_local(user_id, event) + + async def _publish_local(self, user_id: int, event: dict) -> None: stale: list[WebSocket] = [] for websocket in tuple(self._connections.get(user_id, ())): try: await websocket.send_json(event) except Exception: + logger.debug("Removing a disconnected realtime client") stale.append(websocket) for websocket in stale: self.disconnect(user_id, websocket) diff --git a/backend/app/utils.py b/backend/app/utils.py index 17b143c..883a9fb 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -34,5 +34,5 @@ def decode_access_token(token: str) -> int | None: try: d_token = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return int(d_token.get("sub")) - except jwt.InvalidTokenError: + except (jwt.InvalidTokenError, TypeError, ValueError): return None diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..3338545 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,57 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import async_engine_from_config +from sqlalchemy.pool import NullPool + +from app.database import Base, DATABASE_URL +from app import models # noqa: F401 + + +config = context.config +config.set_main_option("sqlalchemy.url", DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=DATABASE_URL, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_sync_migrations(connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(run_sync_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_async_migrations()) diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/20260814_01_initial_schema.py b/backend/migrations/versions/20260814_01_initial_schema.py new file mode 100644 index 0000000..887142f --- /dev/null +++ b/backend/migrations/versions/20260814_01_initial_schema.py @@ -0,0 +1,62 @@ +"""Create the initial Cloud Notes schema.""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260814_01" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + connection = op.get_bind() + existing_tables = set(sa.inspect(connection).get_table_names()) + + if "users" not in existing_tables: + op.create_table( + "users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("login", sa.String(length=50), nullable=False), + sa.Column("pass_hash", sa.String(length=255), nullable=False), + sa.Column("theme", sa.String(length=20), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("login"), + ) + + if "notes" not in existing_tables: + op.create_table( + "notes", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("title", sa.String(length=100), nullable=False), + sa.Column("text", sa.Text(), nullable=True), + sa.Column("summary", sa.String(length=280), nullable=True), + sa.Column("tags", sa.Text(), nullable=False), + sa.Column("is_pinned", sa.Boolean(), nullable=False), + sa.Column("is_favorite", sa.Boolean(), nullable=False), + sa.Column("is_archived", sa.Boolean(), nullable=False), + sa.Column("created_time", sa.DateTime(), nullable=False), + sa.Column("edit_time", sa.DateTime(), nullable=False), + sa.Column("creator_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["creator_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + + if "attachments" not in existing_tables: + op.create_table( + "attachments", + sa.Column("id", sa.String(length=256), nullable=False), + sa.Column("original_name", sa.String(length=256), nullable=False), + sa.Column("creator_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["creator_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("attachments") + op.drop_table("notes") + op.drop_table("users") diff --git a/backend/migrations/versions/20260814_02_store_tags_as_json.py b/backend/migrations/versions/20260814_02_store_tags_as_json.py new file mode 100644 index 0000000..5edcc6e --- /dev/null +++ b/backend/migrations/versions/20260814_02_store_tags_as_json.py @@ -0,0 +1,37 @@ +"""Store note tags as native JSON.""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260814_02" +down_revision: Union[str, None] = "20260814_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "notes", + "tags", + existing_type=sa.Text(), + type_=sa.JSON(), + existing_nullable=False, + postgresql_using=( + "CASE WHEN pg_input_is_valid(tags, 'json') " + "THEN tags::json ELSE '[]'::json END" + ), + ) + + +def downgrade() -> None: + op.alter_column( + "notes", + "tags", + existing_type=sa.JSON(), + type_=sa.Text(), + existing_nullable=False, + postgresql_using="tags::text", + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index e5bbf07..0d9635e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,9 +1,11 @@ fastapi>=0.110.0 uvicorn[standard]>=0.28.0 sqlalchemy>=2.0.0 +alembic>=1.13.0 asyncpg>=0.29.0 passlib[bcrypt] bcrypt==4.0.1 pydantic pyjwt python-multipart +redis>=5.0.0 diff --git a/backend/tests/test_notes.py b/backend/tests/test_notes.py new file mode 100644 index 0000000..4bdc1ac --- /dev/null +++ b/backend/tests/test_notes.py @@ -0,0 +1,17 @@ +import unittest + +from app.api.notes import normalize_tags + + +class NormalizeTagsTests(unittest.TestCase): + def test_normalizes_deduplicates_and_limits_tags(self): + tags = [" Work ", "WORK", "", *[f"tag-{index}" for index in range(20)]] + + result = normalize_tags(tags) + + self.assertEqual(result[0], "work") + self.assertEqual(len(result), 12) + self.assertEqual(len(result), len(set(result))) + + def test_limits_tag_length(self): + self.assertEqual(normalize_tags(["x" * 30]), ["x" * 24]) diff --git a/backend/tests/test_rate_limit.py b/backend/tests/test_rate_limit.py new file mode 100644 index 0000000..296ffcc --- /dev/null +++ b/backend/tests/test_rate_limit.py @@ -0,0 +1,22 @@ +import unittest + +from app.rate_limit import LoginRateLimiter + + +class LoginRateLimiterTests(unittest.IsolatedAsyncioTestCase): + async def test_blocks_after_configured_number_of_failures(self): + limiter = LoginRateLimiter(max_attempts=2, window_seconds=60) + + self.assertFalse(await limiter.is_blocked("client")) + await limiter.record_failure("client") + self.assertFalse(await limiter.is_blocked("client")) + await limiter.record_failure("client") + self.assertTrue(await limiter.is_blocked("client")) + + async def test_clear_removes_failures(self): + limiter = LoginRateLimiter(max_attempts=1, window_seconds=60) + await limiter.record_failure("client") + + await limiter.clear("client") + + self.assertFalse(await limiter.is_blocked("client")) diff --git a/backend/tests/test_realtime.py b/backend/tests/test_realtime.py new file mode 100644 index 0000000..4290231 --- /dev/null +++ b/backend/tests/test_realtime.py @@ -0,0 +1,55 @@ +import unittest + +from app.realtime import RealtimeHub + + +class FakeWebSocket: + def __init__(self, fail_send=False): + self.accepted = False + self.closed_with = None + self.events = [] + self.fail_send = fail_send + + async def accept(self): + self.accepted = True + + async def send_json(self, event): + if self.fail_send: + raise ConnectionError("closed") + self.events.append(event) + + async def close(self, code): + self.closed_with = code + + +class RealtimeHubTests(unittest.IsolatedAsyncioTestCase): + async def test_publishes_to_connected_user_only(self): + hub = RealtimeHub() + first = FakeWebSocket() + second = FakeWebSocket() + await hub.connect(1, first) + await hub.connect(2, second) + + await hub.publish(1, {"type": "note_updated"}) + + self.assertTrue(first.accepted) + self.assertEqual(first.events, [{"type": "note_updated"}]) + self.assertEqual(second.events, []) + + async def test_removes_stale_connection(self): + hub = RealtimeHub() + stale = FakeWebSocket(fail_send=True) + await hub.connect(1, stale) + + await hub.publish(1, {"type": "note_updated"}) + + self.assertNotIn(1, hub._connections) + + async def test_disconnect_user_closes_all_connections(self): + hub = RealtimeHub() + websocket = FakeWebSocket() + await hub.connect(1, websocket) + + await hub.disconnect_user(1) + + self.assertEqual(websocket.closed_with, 1000) diff --git a/backend/tests/test_system.py b/backend/tests/test_system.py new file mode 100644 index 0000000..6980547 --- /dev/null +++ b/backend/tests/test_system.py @@ -0,0 +1,19 @@ +import unittest + +from fastapi import HTTPException + +from app.api.system import health_check + + +class FailingSession: + async def execute(self, _query): + raise ConnectionError("database host and password must stay private") + + +class HealthCheckTests(unittest.IsolatedAsyncioTestCase): + async def test_does_not_expose_database_exception(self): + with self.assertRaises(HTTPException) as raised: + await health_check(FailingSession()) + + self.assertEqual(raised.exception.status_code, 503) + self.assertEqual(raised.exception.detail, "Database is unavailable") diff --git a/backend/tests/test_tokens.py b/backend/tests/test_tokens.py new file mode 100644 index 0000000..f20cefc --- /dev/null +++ b/backend/tests/test_tokens.py @@ -0,0 +1,11 @@ +import unittest + +from app.utils import create_access_token, decode_access_token + + +class TokenTests(unittest.TestCase): + def test_round_trip(self): + self.assertEqual(decode_access_token(create_access_token(42)), 42) + + def test_rejects_invalid_token(self): + self.assertIsNone(decode_access_token("not-a-token")) diff --git a/docker-compose.yml b/docker-compose.yml index 96ae08a..9043289 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,16 @@ services: + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + networks: + - app-network + db: image: postgres:16-alpine restart: unless-stopped @@ -37,6 +49,7 @@ services: REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-true} UPLOAD_DIR: /data/uploads ROOT_PATH: /api + REDIS_URL: redis://redis:6379/0 volumes: - uploads:/data/uploads - ./backend/uploads:/legacy-uploads:ro @@ -45,6 +58,8 @@ services: depends_on: db: condition: service_healthy + redis: + condition: service_healthy healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/system/health-check', timeout=3)"] interval: 10s diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2500ba9..2796a30 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1329,7 +1329,6 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1339,7 +1338,6 @@ "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==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1381,7 +1379,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -1713,9 +1710,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -1807,9 +1804,9 @@ } }, "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==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -1827,7 +1824,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml deleted file mode 100644 index 30840a1..0000000 --- a/frontend/pnpm-lock.yaml +++ /dev/null @@ -1,1486 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@tiptap/extension-code-block': - specifier: 3.27.4 - version: 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/extension-color': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/extension-text-style@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))) - '@tiptap/extension-highlight': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-image': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-link': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/extension-placeholder': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/extensions@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-table': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/extension-table-cell': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/extension-table@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-table-header': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/extension-table@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-table-row': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/extension-table@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-task-item': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-task-list': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-text-align': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-text-style': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-underline': - specifier: ^3.27.4 - version: 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/react': - specifier: ^3.27.4 - version: 3.27.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tiptap/starter-kit': - specifier: ^3.27.4 - version: 3.27.4 - lucide-react: - specifier: ^1.24.0 - version: 1.24.0(react@19.2.7) - react: - specifier: ^19.2.7 - version: 19.2.7 - react-dom: - specifier: ^19.2.7 - version: 19.2.7(react@19.2.7) - devDependencies: - '@types/node': - specifier: ^24.13.2 - version: 24.13.3 - '@types/react': - specifier: ^19.2.17 - version: 19.2.17 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) - '@vitejs/plugin-react': - specifier: ^6.0.3 - version: 6.0.3(vite@8.1.4(@types/node@24.13.3)) - oxlint: - specifier: ^1.71.0 - version: 1.74.0 - typescript: - specifier: ~6.0.2 - version: 6.0.3 - vite: - specifier: ^8.1.1 - version: 8.1.4(@types/node@24.13.3) - -packages: - - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - - '@floating-ui/core@1.8.0': - resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - - '@floating-ui/dom@1.8.0': - resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} - - '@floating-ui/utils@0.2.12': - resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - - '@oxlint/binding-android-arm-eabi@1.74.0': - resolution: {integrity: sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxlint/binding-android-arm64@1.74.0': - resolution: {integrity: sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxlint/binding-darwin-arm64@1.74.0': - resolution: {integrity: sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxlint/binding-darwin-x64@1.74.0': - resolution: {integrity: sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxlint/binding-freebsd-x64@1.74.0': - resolution: {integrity: sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxlint/binding-linux-arm-gnueabihf@1.74.0': - resolution: {integrity: sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm-musleabihf@1.74.0': - resolution: {integrity: sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm64-gnu@1.74.0': - resolution: {integrity: sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-arm64-musl@1.74.0': - resolution: {integrity: sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-ppc64-gnu@1.74.0': - resolution: {integrity: sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-gnu@1.74.0': - resolution: {integrity: sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-musl@1.74.0': - resolution: {integrity: sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-s390x-gnu@1.74.0': - resolution: {integrity: sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-gnu@1.74.0': - resolution: {integrity: sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-musl@1.74.0': - resolution: {integrity: sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxlint/binding-openharmony-arm64@1.74.0': - resolution: {integrity: sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxlint/binding-win32-arm64-msvc@1.74.0': - resolution: {integrity: sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxlint/binding-win32-ia32-msvc@1.74.0': - resolution: {integrity: sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxlint/binding-win32-x64-msvc@1.74.0': - resolution: {integrity: sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@tiptap/core@3.27.4': - resolution: {integrity: sha512-8W/GwlEn0JwNdpyVfTWcXwHYUpj9BWwO++YxtizmgjJzlwigSh7/xLVJMwVykuQHQ2fCq5rkUvmBRtpHOMLUQA==} - peerDependencies: - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-blockquote@3.27.4': - resolution: {integrity: sha512-d1tOHgP3R5cOE+Ot8qL/dkLXRByajgn+j6cCXHqDtmJO2wsK9knmbKQ0SEjbKrU6OgHrTnY/EotNxBEBW9HGoA==} - peerDependencies: - '@tiptap/core': 3.27.4 - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-bold@3.27.4': - resolution: {integrity: sha512-wTtJUUAxCAZ01ICH2DNlOBzzHKRQ1ZST8aRYtIhBPzqEUhnJaKGcjnDB4X49fqPi48iXaPxzhsInDl+rVUujWg==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-bubble-menu@3.27.4': - resolution: {integrity: sha512-Poy7xwcD3POG5ew/TW7mYXv7m++vCchvHxPUqIfnTxBxvvvqDZkPYFWZS1lvPrSBtm1DcfUTQAgVutM5NDZ99Q==} - peerDependencies: - '@tiptap/core': 3.27.4 - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-bullet-list@3.27.4': - resolution: {integrity: sha512-rvja0N1RnwGJAVwDdbUfDIJ4NoT+KjPFaZudKiPuEMfMHfbqe4xcbbC2hsfs61JNcl2xmx+ohV6lzD9YxxJl1w==} - peerDependencies: - '@tiptap/extension-list': 3.27.4 - - '@tiptap/extension-code-block@3.27.4': - resolution: {integrity: sha512-a5caWfWN6Z6usy48vzJDDOhWoA6+rFFCHGpQM7jXn/7rRzYPcvBzTZUGptjEbltj4YqtrQ2tVwTJcCtbb+mknA==} - peerDependencies: - '@tiptap/core': 3.27.4 - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-code@3.27.4': - resolution: {integrity: sha512-aPc7opCR1ylK4m4c2lsjLsGpEBD1fLQQKWd5PbZiJvrTF8gkdGZlYLt9A6VukpxeJyHhb22Jaj4fxgKmGMeTtw==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-color@3.27.4': - resolution: {integrity: sha512-uGbgErKGKO4OTBGqnXOA1CGXa3IqoBaiPBGcHu/px01fS8EVFu0A7q5HS6vxFiMK6qW5x71sEpCA+FUINMAb3g==} - peerDependencies: - '@tiptap/extension-text-style': 3.27.4 - - '@tiptap/extension-document@3.27.4': - resolution: {integrity: sha512-7nAqgfkgb9HADBeCTnOHuTiyZuxfxvMPT3nH4OZeY+cmtkI1On3QffqlmtcUPvNbkhT3o9ehA1hVfCnQ1Ye4LQ==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-dropcursor@3.27.4': - resolution: {integrity: sha512-RiZasQJuUTUO3aME16Bn8eJH7cYnvhT5JCFDFq0ya/1iFI9wUQA2NJC5tb5TrZ74+sQwkYU9VzexnchM481Y9w==} - peerDependencies: - '@tiptap/extensions': 3.27.4 - - '@tiptap/extension-floating-menu@3.27.4': - resolution: {integrity: sha512-tnZywwoNDuEcUZmYYIztXl3PpIKUq+gKeaYPuZhpYEVTThU44tzK3ZuFOmd+qf2aAa1MQwxKWqUuLpNK77bwNw==} - peerDependencies: - '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.27.4 - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-gapcursor@3.27.4': - resolution: {integrity: sha512-svLwSKcFhzpcJeXvxxKkRFuQpykmXrQefVhEsaXq0L95yJIIAGKMRmQC3mxKdzL2j0P9cY7V41bNVSyOAyvclw==} - peerDependencies: - '@tiptap/extensions': 3.27.4 - - '@tiptap/extension-hard-break@3.27.4': - resolution: {integrity: sha512-W+Z9pmDgqjbdu3NeZOQrzA15iM4w60Yd8l2CYzxcdApPVIfYzb2S3a7+u1RqW9wnTYb6xyZjASmFNfxXS4P4cg==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-heading@3.27.4': - resolution: {integrity: sha512-RgvpxzuYk6QEK+az+eiXpWvGlUso42zNcGnjyUrvskoZjS47MbhSg8ylRYQSRtXE0ETlXhAx4J7iGlGr72kyIw==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-highlight@3.27.4': - resolution: {integrity: sha512-STRX1qJLhTZslBF8fEE5qpTGrFd/g7Ufidjxt84p0uT8FrtcbfPUwyweeYwhZp7Iw8n+qODGYnheJTOpfaW2JA==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-horizontal-rule@3.27.4': - resolution: {integrity: sha512-2eQU/55nE5mhMJHALtLMuBL3dcVJUDVVT7n+uZYMaYE63BtCvC4VS08YLFSR7JZSVJIlgVAmdt5nAw0B+rEPNA==} - peerDependencies: - '@tiptap/core': 3.27.4 - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-image@3.27.4': - resolution: {integrity: sha512-yQ8CazyOL4z1/NbV1NLGv6DvchVhOXHH3uQ7md5VX/IGZruFpnm8IpF9MDpdAUxUFmTsXJDYyO2lWGO1PYWG8A==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-italic@3.27.4': - resolution: {integrity: sha512-PeZT4XbyxAp7Lqo/hfA1k5LI27g1RlgS+YgXp2CeHXIrUfSpO5HlZXh02Bvb0pOdl3RFw2tEKtlHzjt8Y1+Nwg==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-link@3.27.4': - resolution: {integrity: sha512-6K/FkNwMLWWQbNWKlycrUPTN7YcyVFdFwZncoBXe5WyarRjLTGw7ywafnCI9PDIWSq7ttzVL4NgjN2IN8kBXww==} - peerDependencies: - '@tiptap/core': 3.27.4 - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-list-item@3.27.4': - resolution: {integrity: sha512-z5TVuPw2mkK0B/x+gFg3uUV7tBdaElDFg0zVgnXZCqlSVTLfIyInOOnG5LTWoAd9BdzBjGrzE3PohDcLVDDGBQ==} - peerDependencies: - '@tiptap/extension-list': 3.27.4 - - '@tiptap/extension-list-keymap@3.27.4': - resolution: {integrity: sha512-on7JNDi7Eqz7UdZeZdiO83bQHo0flVDHzjmtR+v/nrCGW9H15D3CHs5+4ozLDiCvTK8tbkBuut/l9AWNxcCE/Q==} - peerDependencies: - '@tiptap/extension-list': 3.27.4 - - '@tiptap/extension-list@3.27.4': - resolution: {integrity: sha512-A0BgmRO1RE0yLCx9w7GQITtKfS9wLE5cdngSYDiSpwulcXJhJjKm5mZ4OUZmks2VN4HO5jMl2BWCGt2NSDhA+w==} - peerDependencies: - '@tiptap/core': 3.27.4 - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-ordered-list@3.27.4': - resolution: {integrity: sha512-bHwLiof0FqJfWzB0act7oEKMTZatEKQ4IYCvmyF5EktjMs4kxEatkPp4Yx/1LSYSjLy1MMT7oLELyaz2FFYyXA==} - peerDependencies: - '@tiptap/extension-list': 3.27.4 - - '@tiptap/extension-paragraph@3.27.4': - resolution: {integrity: sha512-8Dnr1J5s/s4XYYuEF3b784NnCxLjXOlQpmGyXRxTAzW7JaOP08tIUJWVNvSMekfXc2vXa33HUbqjxyyWZEQ6LQ==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-placeholder@3.27.4': - resolution: {integrity: sha512-7hBoFLeddCv1WzkqB0x3coZ1Hp9WZ9wLoRXIUtUhRKMpzFq2IlTtW1iw88g9pTJnL98bCCElN4DZ4mYtaQvmgA==} - peerDependencies: - '@tiptap/extensions': 3.27.4 - - '@tiptap/extension-strike@3.27.4': - resolution: {integrity: sha512-8OXwcPKuV3ToBBgyvDxH1jQdObK5FIKCGiyIim6qNWiOpi9BhM3XYD+aO1khjv8qIjtoI/DYbizF4ewj09fX2g==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-table-cell@3.27.4': - resolution: {integrity: sha512-1B7J4ZiaXaGxT2IB3hrwtz0433bFVSWsDB+B125vt2DZQr9bgdX40GLesSPlymqOdMxKJwksCPMan2ON2LkVAg==} - peerDependencies: - '@tiptap/extension-table': 3.27.4 - - '@tiptap/extension-table-header@3.27.4': - resolution: {integrity: sha512-V/O690Z6VcHMAWDfgVFiOCwx3eE2/HM2gH+Fp0eQe3WnajQj2DPViW4VWR/rNVl3DspHoL/EU+eEsnhcvO34Vw==} - peerDependencies: - '@tiptap/extension-table': 3.27.4 - - '@tiptap/extension-table-row@3.27.4': - resolution: {integrity: sha512-2RTJtcy90Tc2+HpW1JyKMTwg/zz4u6hHof4CTcJS/eXLk3g1L60DDzYvz4GzBxQvsOT3QVcbaU9QOk9A3Sx7Fg==} - peerDependencies: - '@tiptap/extension-table': 3.27.4 - - '@tiptap/extension-table@3.27.4': - resolution: {integrity: sha512-ejQjqt8GjUn4YswG/SsiLr/W3LZApZGUEDW0N7NoOduE0dBZ/pVJHPuqWu33kK+phJjSNCIN+bSAkoWE01rZSw==} - peerDependencies: - '@tiptap/core': 3.27.4 - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-task-item@3.27.4': - resolution: {integrity: sha512-0/sGxmuUgintoCIh7fF7bblM1Hlr5W498njBLUPSJAd7cO78XXCjoB/UUEiCoBEPEyhHcDYesnYdZxsNfjChGg==} - peerDependencies: - '@tiptap/extension-list': 3.27.4 - - '@tiptap/extension-task-list@3.27.4': - resolution: {integrity: sha512-nlFw+pOhnj9pF1wN0CyjqL0JppM6KNw05XtZWD5kt68tBUpBb7+aEy02tDXpZoRefiFhcIouKVJSeSzILZlCrA==} - peerDependencies: - '@tiptap/extension-list': 3.27.4 - - '@tiptap/extension-text-align@3.27.4': - resolution: {integrity: sha512-ArfL7GLOXCSmrmiBiaWwAf7RPHXDdxLGPru29qKDLQDthjXcNOdlwlPBbolRu7mXwlSmaYpqWGrG75/AEat3mw==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-text-style@3.27.4': - resolution: {integrity: sha512-Wmj64TQXY85gc7lUNbubW32sDCnVOJlpGraMeARRC9Z0EKBX1JpAxddo/64F17bn3kzLxXVszBFyLRcNgTdU2g==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-text@3.27.4': - resolution: {integrity: sha512-lKQH/hP4FBXsziHypd6Ywj8JFvMLM5GVkK1xsH6yApNuXbHq95rd42ZOYWpYILIBib7tlaz93z61d74UrJiuiw==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extension-underline@3.27.4': - resolution: {integrity: sha512-nRJGvRyEXDtINlHTW+C2oWcL3vmX1URVxAPpkD3Zwn5Rb/vEeOU/pk/w97I0iid816MR4iVbvl1XhbUVegK9gQ==} - peerDependencies: - '@tiptap/core': 3.27.4 - - '@tiptap/extensions@3.27.4': - resolution: {integrity: sha512-d8opkg2iGtVwJmNGIqv0blfRxnvWOJp1brz+Z8CsP4ojSS2ZtaE46d6JSQ5OeJ7nMpjhT+9wh4UQcA7OSEO59w==} - peerDependencies: - '@tiptap/core': 3.27.4 - '@tiptap/pm': 3.27.4 - - '@tiptap/pm@3.27.4': - resolution: {integrity: sha512-UB8lcyomfWk7YGI2PZKNqcYXfyRA+PFj+QntlsUXyrsiA5JJIaE8SHKYjxKlGG/xtW3EtPm1b0p38T9Mk4xiFw==} - - '@tiptap/react@3.27.4': - resolution: {integrity: sha512-rTY1V9Y1jzwmo5ItRi3v2Og/mbcYsr9AjUvGoqpXzR9Z31WhXYphw0y05aYzryh0MHXYzkiE+gbGvrbg+cjwEg==} - 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 - - '@tiptap/starter-kit@3.27.4': - resolution: {integrity: sha512-/sb6rFxNt5BO4hWpUwvHh+Yh1kNyCQuuz3oDpGef5HUUjSdu9p9rfNiHWIUKBadK8VXuw5es7N+UlZ4hma+gvA==} - - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - - '@types/use-sync-external-store@0.0.6': - resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - - '@vitejs/plugin-react@6.0.3': - resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} - 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 - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - fast-equals@5.4.1: - resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==} - engines: {node: '>=6.0.0'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - linkifyjs@4.3.3: - resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} - - lucide-react@1.24.0: - resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - orderedmap@2.1.1: - resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} - - oxlint@1.74.0: - resolution: {integrity: sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=0.24.0' - vite-plus: '*' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - vite-plus: - optional: true - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - - postcss@8.5.19: - resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} - engines: {node: ^10 || ^12 || >=14} - - prosemirror-changeset@2.4.1: - resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} - - prosemirror-commands@1.7.1: - resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} - - prosemirror-dropcursor@1.8.3: - resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==} - - prosemirror-gapcursor@1.4.1: - resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} - - prosemirror-history@1.5.0: - resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} - - prosemirror-inputrules@1.5.1: - resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} - - prosemirror-keymap@1.2.3: - resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} - - prosemirror-model@1.25.11: - resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} - - prosemirror-schema-list@1.5.1: - resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} - - prosemirror-state@1.4.4: - resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} - - prosemirror-tables@1.8.5: - resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - - prosemirror-transform@1.12.0: - resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - - prosemirror-view@1.42.1: - resolution: {integrity: sha512-rRqzZnRgkyh69XoOMrfFJHwauHscLBmHbq772kwbic1ymQAM8gXjzEbJse5j1ep2UO2HRIAQL0bY3kZ/RoqjVw==} - - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} - peerDependencies: - react: ^19.2.7 - - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} - engines: {node: '>=0.10.0'} - - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - rope-sequence@1.3.4: - resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - vite@8.1.4: - resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - 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 - - w3c-keyname@2.2.8: - resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - -snapshots: - - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@floating-ui/core@1.8.0': - dependencies: - '@floating-ui/utils': 0.2.12 - optional: true - - '@floating-ui/dom@1.8.0': - dependencies: - '@floating-ui/core': 1.8.0 - '@floating-ui/utils': 0.2.12 - optional: true - - '@floating-ui/utils@0.2.12': - optional: true - - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@oxc-project/types@0.139.0': {} - - '@oxlint/binding-android-arm-eabi@1.74.0': - optional: true - - '@oxlint/binding-android-arm64@1.74.0': - optional: true - - '@oxlint/binding-darwin-arm64@1.74.0': - optional: true - - '@oxlint/binding-darwin-x64@1.74.0': - optional: true - - '@oxlint/binding-freebsd-x64@1.74.0': - optional: true - - '@oxlint/binding-linux-arm-gnueabihf@1.74.0': - optional: true - - '@oxlint/binding-linux-arm-musleabihf@1.74.0': - optional: true - - '@oxlint/binding-linux-arm64-gnu@1.74.0': - optional: true - - '@oxlint/binding-linux-arm64-musl@1.74.0': - optional: true - - '@oxlint/binding-linux-ppc64-gnu@1.74.0': - optional: true - - '@oxlint/binding-linux-riscv64-gnu@1.74.0': - optional: true - - '@oxlint/binding-linux-riscv64-musl@1.74.0': - optional: true - - '@oxlint/binding-linux-s390x-gnu@1.74.0': - optional: true - - '@oxlint/binding-linux-x64-gnu@1.74.0': - optional: true - - '@oxlint/binding-linux-x64-musl@1.74.0': - optional: true - - '@oxlint/binding-openharmony-arm64@1.74.0': - optional: true - - '@oxlint/binding-win32-arm64-msvc@1.74.0': - optional: true - - '@oxlint/binding-win32-ia32-msvc@1.74.0': - optional: true - - '@oxlint/binding-win32-x64-msvc@1.74.0': - optional: true - - '@rolldown/binding-android-arm64@1.1.5': - optional: true - - '@rolldown/binding-darwin-arm64@1.1.5': - optional: true - - '@rolldown/binding-darwin-x64@1.1.5': - optional: true - - '@rolldown/binding-freebsd-x64@1.1.5': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.1.5': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.1.5': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.1.5': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.1.5': - optional: true - - '@rolldown/binding-linux-x64-musl@1.1.5': - optional: true - - '@rolldown/binding-openharmony-arm64@1.1.5': - optional: true - - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.1.5': - optional: true - - '@rolldown/pluginutils@1.0.1': {} - - '@tiptap/core@3.27.4(@tiptap/pm@3.27.4)': - dependencies: - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-blockquote@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-bold@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-bubble-menu@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)': - dependencies: - '@floating-ui/dom': 1.8.0 - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - optional: true - - '@tiptap/extension-bullet-list@3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extension-list': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-code-block@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-code@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-color@3.27.4(@tiptap/extension-text-style@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)))': - dependencies: - '@tiptap/extension-text-style': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - - '@tiptap/extension-document@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-dropcursor@3.27.4(@tiptap/extensions@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extensions': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-floating-menu@3.27.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)': - dependencies: - '@floating-ui/dom': 1.8.0 - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - optional: true - - '@tiptap/extension-gapcursor@3.27.4(@tiptap/extensions@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extensions': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-hard-break@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-heading@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-highlight@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-horizontal-rule@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-image@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-italic@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-link@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - linkifyjs: 4.3.3 - - '@tiptap/extension-list-item@3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extension-list': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-list-keymap@3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extension-list': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-ordered-list@3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extension-list': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-paragraph@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-placeholder@3.27.4(@tiptap/extensions@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extensions': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-strike@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-table-cell@3.27.4(@tiptap/extension-table@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extension-table': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-table-header@3.27.4(@tiptap/extension-table@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extension-table': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-table-row@3.27.4(@tiptap/extension-table@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extension-table': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-table@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - - '@tiptap/extension-task-item@3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extension-list': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-task-list@3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/extension-list': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - - '@tiptap/extension-text-align@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-text-style@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-text@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extension-underline@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - - '@tiptap/extensions@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - - '@tiptap/pm@3.27.4': - dependencies: - prosemirror-changeset: 2.4.1 - prosemirror-commands: 1.7.1 - prosemirror-dropcursor: 1.8.3 - prosemirror-gapcursor: 1.4.1 - prosemirror-history: 1.5.0 - prosemirror-inputrules: 1.5.1 - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.11 - prosemirror-schema-list: 1.5.1 - prosemirror-state: 1.4.4 - prosemirror-tables: 1.8.5 - prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.1 - - '@tiptap/react@3.27.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@types/use-sync-external-store': 0.0.6 - fast-equals: 5.4.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - use-sync-external-store: 1.6.0(react@19.2.7) - optionalDependencies: - '@tiptap/extension-bubble-menu': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/extension-floating-menu': 3.27.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - transitivePeerDependencies: - - '@floating-ui/dom' - - '@tiptap/starter-kit@3.27.4': - dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/extension-blockquote': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/extension-bold': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-bullet-list': 3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-code': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-code-block': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/extension-document': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-dropcursor': 3.27.4(@tiptap/extensions@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-gapcursor': 3.27.4(@tiptap/extensions@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-hard-break': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-heading': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-horizontal-rule': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/extension-italic': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-link': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/extension-list': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/extension-list-item': 3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-list-keymap': 3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-ordered-list': 3.27.4(@tiptap/extension-list@3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)) - '@tiptap/extension-paragraph': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-strike': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-text': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extension-underline': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4)) - '@tiptap/extensions': 3.27.4(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 - - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/node@24.13.3': - dependencies: - undici-types: 7.18.2 - - '@types/react-dom@19.2.3(@types/react@19.2.17)': - dependencies: - '@types/react': 19.2.17 - - '@types/react@19.2.17': - dependencies: - csstype: 3.2.3 - - '@types/use-sync-external-store@0.0.6': {} - - '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@24.13.3))': - dependencies: - '@rolldown/pluginutils': 1.0.1 - vite: 8.1.4(@types/node@24.13.3) - - csstype@3.2.3: {} - - detect-libc@2.1.2: {} - - fast-equals@5.4.1: {} - - fdir@6.5.0(picomatch@4.0.5): - optionalDependencies: - picomatch: 4.0.5 - - fsevents@2.3.3: - optional: true - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - 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 - - linkifyjs@4.3.3: {} - - lucide-react@1.24.0(react@19.2.7): - dependencies: - react: 19.2.7 - - nanoid@3.3.16: {} - - orderedmap@2.1.1: {} - - oxlint@1.74.0: - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.74.0 - '@oxlint/binding-android-arm64': 1.74.0 - '@oxlint/binding-darwin-arm64': 1.74.0 - '@oxlint/binding-darwin-x64': 1.74.0 - '@oxlint/binding-freebsd-x64': 1.74.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.74.0 - '@oxlint/binding-linux-arm-musleabihf': 1.74.0 - '@oxlint/binding-linux-arm64-gnu': 1.74.0 - '@oxlint/binding-linux-arm64-musl': 1.74.0 - '@oxlint/binding-linux-ppc64-gnu': 1.74.0 - '@oxlint/binding-linux-riscv64-gnu': 1.74.0 - '@oxlint/binding-linux-riscv64-musl': 1.74.0 - '@oxlint/binding-linux-s390x-gnu': 1.74.0 - '@oxlint/binding-linux-x64-gnu': 1.74.0 - '@oxlint/binding-linux-x64-musl': 1.74.0 - '@oxlint/binding-openharmony-arm64': 1.74.0 - '@oxlint/binding-win32-arm64-msvc': 1.74.0 - '@oxlint/binding-win32-ia32-msvc': 1.74.0 - '@oxlint/binding-win32-x64-msvc': 1.74.0 - - picocolors@1.1.1: {} - - picomatch@4.0.5: {} - - postcss@8.5.19: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prosemirror-changeset@2.4.1: - dependencies: - prosemirror-transform: 1.12.0 - - prosemirror-commands@1.7.1: - dependencies: - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - - prosemirror-dropcursor@1.8.3: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.1 - - prosemirror-gapcursor@1.4.1: - dependencies: - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-view: 1.42.1 - - prosemirror-history@1.5.0: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.1 - rope-sequence: 1.3.4 - - prosemirror-inputrules@1.5.1: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - - prosemirror-keymap@1.2.3: - dependencies: - prosemirror-state: 1.4.4 - w3c-keyname: 2.2.8 - - prosemirror-model@1.25.11: - dependencies: - orderedmap: 2.1.1 - - prosemirror-schema-list@1.5.1: - dependencies: - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - - prosemirror-state@1.4.4: - dependencies: - prosemirror-model: 1.25.11 - prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.1 - - prosemirror-tables@1.8.5: - dependencies: - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.1 - - prosemirror-transform@1.12.0: - dependencies: - prosemirror-model: 1.25.11 - - prosemirror-view@1.42.1: - dependencies: - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - - react-dom@19.2.7(react@19.2.7): - dependencies: - react: 19.2.7 - scheduler: 0.27.0 - - react@19.2.7: {} - - rolldown@1.1.5: - dependencies: - '@oxc-project/types': 0.139.0 - '@rolldown/pluginutils': 1.0.1 - 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 - - rope-sequence@1.3.4: {} - - scheduler@0.27.0: {} - - source-map-js@1.2.1: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - - tslib@2.8.1: - optional: true - - typescript@6.0.3: {} - - undici-types@7.18.2: {} - - use-sync-external-store@1.6.0(react@19.2.7): - dependencies: - react: 19.2.7 - - vite@8.1.4(@types/node@24.13.3): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.5 - postcss: 8.5.19 - rolldown: 1.1.5 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.13.3 - fsevents: 2.3.3 - - w3c-keyname@2.2.8: {} diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/frontend/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/App.css b/frontend/src/App.css index 042c771..54736f5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -206,7 +206,6 @@ .notes-toolbar__left, .notes-toolbar__center, .notes-toolbar__right, -.notes-stage__meta, .notes-footer, .notes-footer__stats, .notes-footer__actions { @@ -301,7 +300,6 @@ .note-row span, .muted, -.notes-stage__meta, .notes-footer__stats { color: #8e8e93; } @@ -336,13 +334,15 @@ .notes-toolbar { flex: 0 0 auto; - justify-content: space-between; - gap: 20px; + justify-content: flex-start; + align-items: flex-start; + flex-wrap: wrap; + gap: 8px; position: sticky; top: 0; z-index: 10; min-height: 54px; - padding: 6px 14px; + padding: 8px 14px; box-sizing: border-box; border-bottom: 1px solid #dedee2; background: rgba(250, 250, 250, 0.88); @@ -357,24 +357,168 @@ .notes-toolbar__center { position: relative; + order: 2; + width: 100%; + min-width: 0; + flex: 1 1 100%; justify-content: center; +} + +.notes-toolbar__right { + order: 1; + width: 100%; min-width: 0; - flex: 1 1 auto; + flex: 1 1 100%; + flex-wrap: wrap; + gap: 8px; +} + +.toolbar-action-group, +.toolbar-filter-field, +.toolbar-preferences-group { + display: flex; + align-items: center; + height: 48px; + box-sizing: border-box; + min-width: 0; + border: 1px solid #d8d8dc; + border-radius: 999px; + background: #f1f1f4; +} + +.toolbar-action-group { + gap: 2px; + padding: 3px; +} + +.toolbar-filter-field { + gap: 6px; + padding: 3px 12px; +} + +.toolbar-filter-field--tags { + flex: 0 1 190px; +} + +.toolbar-filter-field--search { + flex: 1 1 280px; +} + +.toolbar-filter-field__icon { + flex: 0 0 auto; + color: #6b6b70; +} + +.toolbar-filter-field .toolbar-tags, +.toolbar-filter-field .toolbar-search { + width: 100%; + min-width: 0; + height: 38px; + padding: 0; + border: 0; + background: transparent; +} + +.toolbar-preferences-group { + gap: 8px; + padding: 0; + border: 0; + background: transparent; +} + +.toolbar-preferences-group .toolbar-theme-button, +.toolbar-preferences-group .locale-button { + width: 38px; + min-width: 38px; + height: 38px; + border: 0; + background: transparent; +} + +.toolbar-preferences-group .toolbar-signout { + min-height: 38px; + padding: 8px 14px; } .toolbar-pill { display: inline-flex; align-items: center; gap: 2px; + width: fit-content; max-width: 100%; padding: 4px; border-radius: 999px; background: #ececef; border: 1px solid #dedee2; box-shadow: none; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + margin-inline: auto; +} + +.toolbar-pill::-webkit-scrollbar { + display: none; +} + +.toolbar-format-window { + position: relative; + display: inline-flex; + max-width: 100%; + padding: 6px; + border: 1px solid #d8d8dc; + border-radius: 22px; + background: transparent; + box-shadow: none; +} + +@media (min-width: 761px) { + .notes-toolbar { + overflow: visible; + } + + .notes-toolbar__center { + position: absolute; + top: calc(100% + 16px); + left: 0; + z-index: 20; + pointer-events: none; + } + + .toolbar-format-window { + width: fit-content; + min-height: 0; + padding: 3px; + align-items: center; + justify-content: center; + border-radius: 999px; + pointer-events: auto; + } + + .toolbar-format-window .toolbar-pill { + width: fit-content; + max-width: 100%; + margin: 0; + border: 0; + border-radius: 0; + background: transparent; + } + + .toolbar-format-window .toolbar-pill__button { + min-width: 30px; + height: 30px; + padding: 0 7px; + } + + .notes-stage { + padding-top: 42px; + box-sizing: border-box; + } } .toolbar-pill__button { + flex: 0 0 auto; min-width: 34px; height: 34px; border: 0; @@ -416,6 +560,14 @@ background: #f1f1f4; } +.notes-toolbar .toolbar-signout { + width: 48px; + min-width: 48px; + height: 48px; + padding: 0; + gap: 0; +} + .locale-code { font-size: 0.82rem; font-weight: 600; @@ -432,6 +584,21 @@ border-color: #48484a; } +.notes-app--dark .toolbar-preferences-group { + border: 0; + background: transparent; +} + +.notes-app--dark .toolbar-action-group, +.notes-app--dark .toolbar-filter-field { + border-color: #48484a; + background: #2c2c2e; +} + +.notes-app--dark .toolbar-filter-field__icon { + color: #aeaeb2; +} + .toolbar-pill__button.is-active, .toolbar-pill__button:hover, .format-popover button:hover, @@ -543,20 +710,6 @@ margin: 0; } -.notes-stage__meta { - flex: 0 0 auto; - justify-content: center; - gap: 28px; - font-size: 0.9rem; - min-height: 32px; - margin: 0; - padding: 6px 24px; - box-sizing: border-box; - border-bottom: 1px solid #eeeeef; - flex-wrap: wrap; - text-align: center; -} - .notes-canvas { position: relative; display: flex; @@ -565,7 +718,7 @@ height: auto; min-height: 0; overflow: hidden; - padding: 28px clamp(28px, 5vw, 80px) 24px; + padding: 28px clamp(18px, 2.5vw, 40px) 24px; box-sizing: border-box; border-radius: 0; background: #ffffff; @@ -612,7 +765,7 @@ font-size: 2rem; font-weight: 700; line-height: 1.25; - margin-bottom: 14px; + margin-bottom: 10px; padding: 3px 0 5px; font-family: inherit; overflow: hidden; @@ -623,6 +776,21 @@ color: #aeaeb2; } +.notes-title-divider { + width: 100%; + height: 1px; + flex: 0 0 auto; + margin: 0 0 16px; + background: linear-gradient( + 90deg, + transparent, + rgba(229, 229, 234, 0.7) 12%, + #e5e5ea 50%, + rgba(229, 229, 234, 0.7) 88%, + transparent + ); +} + .tag-cloud { display: flex; align-items: center; @@ -630,6 +798,34 @@ flex-wrap: wrap; } +.notes-footer__action-button { + width: 42px; + height: 42px; + border: 1px solid #d8d8dc; + border-radius: 14px; + background: #f7f7f8; + color: #3a3a3c; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.notes-footer__action-button:hover { + background: #e9e9ed; +} + +.notes-footer__action-button:disabled { + cursor: default; + opacity: 0.65; +} + +.notes-footer__action-button--danger { + color: #b42318; + background: #fff1f0; + border-color: #f2b8b5; +} + .tag-cloud { margin: 0 0 16px; } @@ -979,7 +1175,17 @@ border-color: #3a3a3c; } -.notes-app--dark .notes-stage__meta, +.notes-app--dark .notes-title-divider { + background: linear-gradient( + 90deg, + transparent, + rgba(53, 53, 55, 0.7) 12%, + #353537 50%, + rgba(53, 53, 55, 0.7) 88%, + transparent + ); +} + .notes-app--dark .notes-footer { border-color: #353537; } @@ -1005,15 +1211,37 @@ border-color: #48484a; } +.notes-app--dark .toolbar-format-window { + background: transparent; + border-color: #48484a; +} + +.notes-app--dark .toolbar-format-window .toolbar-pill { + background: transparent; + border: 0; +} + + .notes-app--dark .toolbar-search, .notes-app--dark .toolbar-tags, .notes-app--dark .tag-chip, -.notes-app--dark .ghost-button { +.notes-app--dark .ghost-button, +.notes-app--dark .notes-footer__action-button { background: #2c2c2e; border-color: #48484a; color: #f2f2f7; } +.notes-app--dark .notes-footer__action-button:hover { + background: #3a3a3c; +} + +.notes-app--dark .notes-footer__action-button--danger { + color: #ff6961; + background: rgba(180, 35, 24, 0.2); + border-color: rgba(255, 105, 97, 0.42); +} + .notes-app--dark .format-popover { background: rgba(44, 44, 46, 0.98); border-color: #48484a; @@ -1138,7 +1366,7 @@ } } -@media (max-width: 980px) { +@media (max-width: 760px) { .auth-screen__panel, .notes-app { grid-template-columns: 1fr; @@ -1177,15 +1405,16 @@ .notes-toolbar__right { min-width: 0; flex: 1 1 100%; - flex-wrap: wrap; + flex-wrap: nowrap; } - .toolbar-pill { - width: 100%; +.toolbar-pill { + width: fit-content; overflow-x: auto; overscroll-behavior-inline: contain; scrollbar-width: none; - -webkit-overflow-scrolling: touch; + -webkit-overflow-scrolling: touch; + margin-inline: auto; } .toolbar-pill::-webkit-scrollbar { @@ -1214,8 +1443,7 @@ .notes-toolbar, .notes-footer, - .notes-toolbar__right, - .notes-stage__meta { + .notes-toolbar__right { flex-wrap: wrap; } @@ -1286,3 +1514,419 @@ grid-template-columns: minmax(0, 1fr); } } + +.mobile-app-bar, +.mobile-actions-panel, +.mobile-editor-toolbar, +.mobile-sidebar-backdrop, +.notes-sidebar__close { + display: none; +} + +@media (min-width: 761px) and (max-width: 1500px) { + .notes-app { + grid-template-columns: 240px minmax(0, 1fr); + } + + .notes-toolbar { + align-items: flex-start; + flex-wrap: wrap; + gap: 8px; + padding: 8px 12px; + } + + .notes-toolbar__center { + order: 2; + width: 100%; + flex: 1 1 100%; + justify-content: center; + } + + .notes-toolbar__right { + order: 1; + width: 100%; + min-width: 0; + flex: 1 1 100%; + flex-wrap: wrap; + gap: 8px; + } + + .toolbar-action-group { + grid-area: auto; + } + + .toolbar-filter-field--tags { + grid-area: auto; + min-width: 0; + } + + .toolbar-filter-field--search { + grid-area: auto; + min-width: 0; + } + + .toolbar-preferences-group { + grid-area: auto; + margin-left: 0; + } + + .toolbar-pill { + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + } + + .toolbar-pill::-webkit-scrollbar { + display: none; + } + + .toolbar-pill__button { + flex: 0 0 auto; + } + + @media (max-width: 820px) { + .notes-footer { + gap: 12px; + } + + .notes-footer__stats { + flex: 1 1 0; + gap: 8px 12px; + } + + .notes-footer__actions { + flex: 0 0 auto; + flex-wrap: nowrap; + gap: 8px; + } + + .notes-footer__action-button { + width: 38px; + height: 38px; + border-radius: 12px; + } + } + + .toolbar-search { + width: min(220px, 100%); + flex: 1 1 180px; + } + + .toolbar-tags { + width: min(150px, 100%); + flex: 1 1 130px; + } + + .toolbar-signout { + padding: 10px 12px; + } + + .toolbar-filter-field--tags { + flex: 0 1 70px; + } + + .toolbar-filter-field--search { + flex: 1 1 100px; + } + + .notes-toolbar .toolbar-theme-button, + .notes-toolbar .locale-button, + .notes-toolbar .toolbar-signout { + width: 48px; + min-width: 48px; + height: 48px; + flex-basis: 48px; + } + + .toolbar-preferences-group { + gap: 6px; + } + + @media (max-width: 820px) { + .toolbar-action-group, + .toolbar-filter-field { + height: 40px; + } + + .toolbar-action-group .icon-button { + width: 32px; + height: 32px; + } + + .toolbar-filter-field .toolbar-tags, + .toolbar-filter-field .toolbar-search { + height: 30px; + } + + .notes-toolbar .toolbar-theme-button, + .notes-toolbar .locale-button, + .notes-toolbar .toolbar-signout { + width: 40px; + min-width: 40px; + height: 40px; + flex-basis: 40px; + } + } + +} + +@media (max-width: 760px) { + html, + body, + #root, + .app-shell, + .notes-app, + .notes-workspace { + min-height: 100dvh; + } + + .notes-app { + display: block; + } + + .notes-workspace { + height: 100dvh; + min-height: 0; + overflow: hidden; + background: #ffffff; + } + + .notes-toolbar { + display: none; + } + + .mobile-app-bar { + display: flex; + flex: 0 0 auto; + align-items: center; + min-height: 56px; + padding: max(6px, env(safe-area-inset-top)) 10px 6px; + border-bottom: 1px solid #dedee2; + background: rgba(250, 250, 250, 0.94); + backdrop-filter: blur(20px) saturate(180%); + } + + .mobile-app-bar strong { + min-width: 0; + flex: 1 1 auto; + padding: 0 10px; + overflow: hidden; + color: #1d1d1f; + font-size: 1.05rem; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mobile-app-bar__actions { + display: flex; + gap: 2px; + } + + .mobile-add-icon { + font-size: 1.65rem; + font-weight: 300; + line-height: 1; + } + + .mobile-actions-panel { + display: grid; + flex: 0 0 auto; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: + 'search tags' + 'note-actions preferences'; + align-items: center; + gap: 8px 10px; + padding: 10px 14px; + border-bottom: 1px solid #dedee2; + background: #f2f2f7; + } + + .mobile-actions-panel__search, + .mobile-actions-panel__tags { + width: 100%; + min-height: 42px; + border: 1px solid #d8d8dc; + border-radius: 12px; + padding: 10px 12px; + background: #ffffff; + color: #1d1d1f; + font: inherit; + } + + .mobile-actions-panel__search { + grid-area: search; + } + + .mobile-actions-panel__tags { + grid-area: tags; + } + + .mobile-actions-panel__note-actions, + .mobile-actions-panel__preferences { + display: flex; + align-items: center; + gap: 8px; + } + + .mobile-actions-panel__note-actions { + grid-area: note-actions; + } + + .mobile-actions-panel__preferences { + grid-area: preferences; + justify-content: flex-end; + } + + .mobile-actions-panel__preferences .toolbar-signout { + width: 40px; + min-width: 40px; + height: 40px; + flex: 0 0 40px; + margin-left: 0; + padding: 0; + gap: 0; + } + + .notes-sidebar { + position: fixed; + z-index: 30; + inset: 0 auto 0 0; + width: min(88vw, 360px); + min-height: 100dvh; + padding: max(14px, env(safe-area-inset-top)) 12px max(18px, env(safe-area-inset-bottom)); + border-right: 1px solid #d8d8dc; + border-bottom: 0; + box-shadow: 18px 0 42px rgba(0, 0, 0, 0.2); + overflow-y: auto; + transform: translateX(-105%); + transition: transform 180ms ease; + } + + .notes-sidebar--mobile-open { + transform: translateX(0); + } + + .notes-sidebar__top { + justify-content: space-between; + margin-bottom: 18px; + } + + .notes-sidebar__close, + .mobile-sidebar-backdrop { + display: inline-flex; + } + + .mobile-sidebar-backdrop { + display: block; + position: fixed; + z-index: 20; + inset: 0; + width: 100%; + border: 0; + background: rgba(0, 0, 0, 0.36); + } + + .notes-stage { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: hidden; + } + + .notes-canvas { + display: flex; + flex: 1 1 auto; + min-height: 0; + padding: 16px; + overflow: hidden; + } + + .notes-title { + min-height: 2.25rem; + margin-bottom: 8px; + font-size: 1.55rem; + } + + .notes-editor { + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + + .notes-editor__content { + min-height: 42vh; + font-size: 1rem; + } + + .mobile-editor-toolbar { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-start; + margin: 10px -2px 0; + padding: 5px; + border: 1px solid #dedee2; + border-radius: 14px; + background: #f2f2f7; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + } + + .mobile-editor-toolbar::-webkit-scrollbar { + display: none; + } + + .mobile-editor-toolbar .icon-button { + width: 44px; + height: 40px; + flex: 0 0 44px; + } + + .notes-footer { + display: none; + } + + .notes-empty { + min-height: 0; + flex: 1 1 auto; + padding: 24px 18px; + } + + .toast-stack { + right: 12px; + bottom: max(12px, env(safe-area-inset-bottom)); + max-width: calc(100vw - 24px); + } + + .notes-app--dark .notes-workspace, + .notes-app--dark .notes-canvas { + background: #1c1c1e; + } + + .notes-app--dark .mobile-app-bar { + border-color: #3a3a3c; + background: rgba(38, 38, 40, 0.94); + } + + .notes-app--dark .mobile-app-bar strong { + color: #f2f2f7; + } + + .notes-app--dark .mobile-actions-panel, + .notes-app--dark .mobile-editor-toolbar { + border-color: #48484a; + background: #2c2c2e; + } + + .notes-app--dark .mobile-actions-panel__search, + .notes-app--dark .mobile-actions-panel__tags { + border-color: #48484a; + background: #1c1c1e; + color: #f2f2f7; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e605af4..35a1e73 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ import { Code2, Copy, Download, + Ellipsis, Highlighter, Heading1, Heading2, @@ -41,21 +42,19 @@ import { Link2, List, ListOrdered, - Languages, LogOut, + Menu, Moon, Pin, Star, Quote, Search, SquareCode, - SquarePen, Strikethrough, Table2, Trash2, Underline as UnderlineIcon, Unlink, - Focus, Sun, X, } from 'lucide-react' @@ -79,86 +78,28 @@ import type { NoteEvent } from './lib/api' import type { AuthPayload, Note, NotePayload } from './types' import { translate } from './i18n' import type { Locale, TranslationKey } from './i18n' +import { AuthScreen } from './components/AuthScreen' +import { NotesSidebar } from './components/NotesSidebar' +import type { Shelf } from './components/NotesSidebar' +import { + defaultDraft, + extractSummary, + formatDate, + htmlToPlainText, + noteToPayload, + payloadEqualsNote, + sortNotes, +} from './lib/note-utils' 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 } -const defaultDraft: NotePayload = { - title: '', - text: '', - summary: '', - tags: [], - is_pinned: false, - is_favorite: false, - is_archived: false, -} - -function formatDate(value: string, locale: Locale) { - return new Intl.DateTimeFormat(locale === 'ru' ? 'ru-RU' : 'en-US', { - 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 RemovableImageView({ node, deleteNode, selected }: NodeViewProps) { return ( ('idle') const [notesBusy, setNotesBusy] = useState(false) const [deleteBusy, setDeleteBusy] = useState(false) - const [notice, setNotice] = useState('Private notes synced with secure cookie auth.') + const [, setNotice] = useState('Private notes synced with secure cookie auth.') const [searchQuery, setSearchQuery] = useState('') const [activeShelf, setActiveShelf] = useState('all') const [toolbarOpen, setToolbarOpen] = useState(false) - const [focusMode, setFocusMode] = useState(false) + const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false) + const [mobileActionsOpen, setMobileActionsOpen] = useState(false) const [darkMode, setDarkMode] = useState(() => { const savedTheme = window.localStorage.getItem('notes-theme') return savedTheme @@ -333,6 +275,9 @@ function App() { ) const selectedNote = notes.find((note) => note.id === selectedNoteId) ?? null + const activeShelfTitle = t( + activeShelf === 'all' ? 'notes' : activeShelf === 'archived' ? 'archive' : activeShelf, + ) draftRef.current = draft notesRef.current = notes selectedNoteIdRef.current = selectedNoteId @@ -387,7 +332,7 @@ function App() { setToasts((current) => [...current, nextToast]) window.setTimeout(() => { setToasts((current) => current.filter((toast) => toast.id !== nextToast.id)) - }, 2600) + }, 7000) }, []) useEffect(() => { @@ -521,6 +466,8 @@ function App() { codeBlock: currentEditor?.isActive('codeBlock') ?? false, highlight: currentEditor?.isActive('highlight') ?? false, link: currentEditor?.isActive('link') ?? false, + heading1: currentEditor?.isActive('heading', { level: 1 }) ?? false, + heading2: currentEditor?.isActive('heading', { level: 2 }) ?? false, bulletList: currentEditor?.isActive('bulletList') ?? false, orderedList: currentEditor?.isActive('orderedList') ?? false, taskList: currentEditor?.isActive('taskList') ?? false, @@ -536,7 +483,6 @@ function App() { 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 syncDraft = useCallback((nextNote: Note | null) => { const payload = nextNote ? noteToPayload(nextNote) : defaultDraft @@ -549,7 +495,10 @@ function App() { setToolbarOpen(false) if (editor) { - editor.commands.setContent(payload.text || '

', { emitUpdate: false }) + const content = payload.text || '

' + if (editor.getHTML() !== content) { + editor.commands.setContent(content, { emitUpdate: false }) + } } }, [editor]) @@ -591,7 +540,7 @@ function App() { const user = await getCurrentUser() setDarkMode(user.theme === 'dark') setSessionStatus('authenticated') - setNotice(t('welcomeBack', { login: user.login })) + setNotice('') await loadNotes() } catch { setNotes([]) @@ -637,6 +586,9 @@ function App() { socket = openNoteEvents() socket.onmessage = (message) => { const event = JSON.parse(message.data as string) as NoteEvent + if (event.source_client_id === window.localStorage.getItem('cloud-notes-client-id')) { + return + } if (event.type === 'note_deleted') { setNotes((current) => current.filter((note) => note.id !== event.note_id)) if (selectedNoteIdRef.current === event.note_id) { @@ -827,6 +779,7 @@ function App() { } setSelectedNoteId(nextNoteId) + setMobileNavigationOpen(false) }, [dirty, persistCurrentNote, selectedNoteId, t]) const handleCreateNote = useCallback(async () => { @@ -838,6 +791,7 @@ function App() { setNotes((current) => sortNotes([note, ...current.filter((item) => item.id !== note.id)])) setSelectedNoteId(note.id) + setMobileNavigationOpen(false) setNotice(t('newNoteCreated')) pushToast(t('newNoteToast')) window.setTimeout(() => titleRef.current?.focus(), 60) @@ -1149,112 +1103,171 @@ function App() { if (sessionStatus === 'anonymous') { return ( -
-
-
- - - - - {authError ?

{authError}

: null} - - - - - -
-
-
+ setLocale((current) => (current === 'en' ? 'ru' : 'en'))} + onToggleMode={() => + setAuthMode((current) => (current === 'login' ? 'register' : 'login')) + } + /> ) } return ( -
- + + + + {mobileActionsOpen ? ( +
+ setSearchQuery(event.target.value)} + /> + {selectedNote ? ( +
+ + + + +
+ ) : null} + applyTags(tagInput)} + onChange={(event) => setTagInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyTags(tagInput) + } + }} + /> +
+ + + +
+
+ ) : null} -
-
+
+
-
- - {toolbarOpen ? ( -
+
+ {toolbarOpen ? ( +
-
- ) : null} +
+ ) : null} +
- - {selectedNote ? ( - <> - - - - - ) : null} - - - applyTags(tagInput)} - onChange={(event) => setTagInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - applyTags(tagInput) - } - }} - /> - setSearchQuery(event.target.value)} - /> - - - +
+ {selectedNote ? ( + <> + + + + + ) : null} + +
+
+ applyTags(tagInput)} + onChange={(event) => setTagInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyTags(tagInput) + } + }} + /> +
+
+
+
+ + + +
-
- {selectedNote ? formatDate(selectedNote.edit_time, locale) : t('noNoteSelected')} - {saveState === 'saving' ? t('saving') : saveState === 'dirty' ? t('unsaved') : notice} - {selectedNote ? ( - - {draft.is_pinned ? t('pinned') : draft.is_favorite ? t('favorite') : draft.is_archived ? t('archived') : t('saved')} - - ) : null} -
- {selectedNote ? (
+