From 04b994cf751eba69653c08e24c7ef99248874241 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:06:18 +0700 Subject: [PATCH 01/14] Removed unused frontend files and improved connection cleanup. --- backend/app/main.py | 2 + backend/app/utils.py | 2 +- frontend/pnpm-lock.yaml | 1486 --------------------------------- frontend/public/icons.svg | 24 - frontend/src/assets/hero.png | Bin 13057 -> 0 bytes frontend/src/assets/react.svg | 1 - frontend/src/assets/vite.svg | 1 - 7 files changed, 3 insertions(+), 1513 deletions(-) delete mode 100644 frontend/pnpm-lock.yaml delete mode 100644 frontend/public/icons.svg delete mode 100644 frontend/src/assets/hero.png delete mode 100644 frontend/src/assets/react.svg delete mode 100644 frontend/src/assets/vite.svg diff --git a/backend/app/main.py b/backend/app/main.py index 390e11a..6051dc3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -85,6 +85,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/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/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/assets/hero.png b/frontend/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite From 2c965fea79163fba2893a9c92e31d73196d5cab5 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:14:44 +0700 Subject: [PATCH 02/14] Added managed database migrations. --- README.md | 14 +++++ backend/Dockerfile | 2 +- backend/alembic.ini | 37 +++++++++++ backend/app/main.py | 18 +----- backend/migrations/env.py | 57 +++++++++++++++++ backend/migrations/script.py.mako | 24 +++++++ .../versions/20260814_01_initial_schema.py | 62 +++++++++++++++++++ backend/requirements.txt | 1 + 8 files changed, 197 insertions(+), 18 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/20260814_01_initial_schema.py diff --git a/README.md b/README.md index 9d17103..92ff8bb 100644 --- a/README.md +++ b/README.md @@ -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: 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/main.py b/backend/app/main.py index 6051dc3..fb9d69e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,13 +1,11 @@ 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 @@ -17,20 +15,6 @@ @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) - upload_dir = Path(os.getenv("UPLOAD_DIR", "uploads")) legacy_upload_dir = Path("/legacy-uploads") upload_dir.mkdir(parents=True, exist_ok=True) 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/requirements.txt b/backend/requirements.txt index e5bbf07..7daff9f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,7 @@ 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 From 9c2ef9f76c2a3e79291d40d6b7e344272cb08569 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:16:28 +0700 Subject: [PATCH 03/14] Scaled realtime updates across server instances. --- README.md | 5 ++-- backend/app/main.py | 2 ++ backend/app/realtime.py | 57 ++++++++++++++++++++++++++++++++++++++++ backend/requirements.txt | 1 + docker-compose.yml | 15 +++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 92ff8bb..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 @@ -207,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/app/main.py b/backend/app/main.py index fb9d69e..d106d59 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): + await realtime_hub.start(os.getenv("REDIS_URL")) upload_dir = Path(os.getenv("UPLOAD_DIR", "uploads")) legacy_upload_dir = Path("/legacy-uploads") upload_dir.mkdir(parents=True, exist_ok=True) @@ -27,6 +28,7 @@ async def lifespan(app: FastAPI): try: yield finally: + await realtime_hub.stop() await engine.dispose() app = FastAPI( diff --git a/backend/app/realtime.py b/backend/app/realtime.py index 41e5df8..2c6a659 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,6 +68,15 @@ 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: diff --git a/backend/requirements.txt b/backend/requirements.txt index 7daff9f..0d9635e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,4 @@ bcrypt==4.0.1 pydantic pyjwt python-multipart +redis>=5.0.0 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 From 1db908e727e72ddb833bc90611fb8873678d4746 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:18:12 +0700 Subject: [PATCH 04/14] Shared login rate limits across server instances. --- backend/app/api/users.py | 19 +++------- backend/app/main.py | 6 ++- backend/app/rate_limit.py | 77 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 backend/app/rate_limit.py 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 d106d59..c34a967 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,11 +11,14 @@ 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): - await realtime_hub.start(os.getenv("REDIS_URL")) + 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) @@ -28,6 +31,7 @@ async def lifespan(app: FastAPI): try: yield finally: + await login_rate_limiter.stop() await realtime_hub.stop() await engine.dispose() 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) From c30c8794ea97aea22ff516145f2baf62ea210a66 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:19:34 +0700 Subject: [PATCH 05/14] Split the frontend bundle into cacheable chunks. --- frontend/vite.config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a133858..d4bd91e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,21 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes('node_modules')) return + if (id.includes('@tiptap') || id.includes('prosemirror')) { + return 'editor-vendor' + } + if (id.includes('lucide-react')) return 'icons-vendor' + if (id.includes('react')) return 'react-vendor' + return 'vendor' + }, + }, + }, + }, server: { host: '0.0.0.0', port: 5173, From d40c7f3f13ecaccc6858ab7b7e5668043136d660 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:20:19 +0700 Subject: [PATCH 06/14] Updated vulnerable frontend dependencies. --- frontend/package-lock.json | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) 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" }, From 925bbaa6b06089b382ff2f6aee788a2537271a20 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:21:37 +0700 Subject: [PATCH 07/14] Added backend tests to continuous integration. --- .github/workflows/ci.yml | 14 +++++--- backend/tests/test_notes.py | 17 ++++++++++ backend/tests/test_rate_limit.py | 22 +++++++++++++ backend/tests/test_realtime.py | 55 ++++++++++++++++++++++++++++++++ backend/tests/test_tokens.py | 11 +++++++ 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_notes.py create mode 100644 backend/tests/test_rate_limit.py create mode 100644 backend/tests/test_realtime.py create mode 100644 backend/tests/test_tokens.py 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/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_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")) From 2c2f18378dac5f3834ed5f0c82a0d44e872305ab Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:24:55 +0700 Subject: [PATCH 08/14] Protected server errors from leaking internal details. --- backend/app/api/system.py | 11 +++++++++-- backend/app/realtime.py | 1 + backend/tests/test_system.py | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_system.py 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/realtime.py b/backend/app/realtime.py index 2c6a659..5cbf1c6 100644 --- a/backend/app/realtime.py +++ b/backend/app/realtime.py @@ -82,6 +82,7 @@ async def _publish_local(self, user_id: int, event: dict) -> None: 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/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") From 6c54bdd90659e968d1367c60635fbbf4841828dd Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:25:35 +0700 Subject: [PATCH 09/14] Stored note tags as native JSON. --- backend/app/api/notes.py | 12 ++---- backend/app/models.py | 6 +-- .../20260814_02_store_tags_as_json.py | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/versions/20260814_02_store_tags_as_json.py diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index 178c1d0..011a9dd 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -1,4 +1,3 @@ -import json import re from fastapi import APIRouter, Depends, HTTPException @@ -32,11 +31,6 @@ 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 = [] - normalized_text = LEGACY_ATTACHMENT_URL.sub( "/api/attachments/download/", note.text or "", @@ -47,7 +41,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, @@ -64,7 +58,7 @@ async def create_note(userdata: NoteCreate, db: AsyncSession = Depends(get_db), 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, @@ -145,7 +139,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 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/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", + ) From 2a2bef6888f85adf8dca12b93adf77d4707ba8d0 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:26:27 +0700 Subject: [PATCH 10/14] Separated note transformations from the web interface. --- frontend/src/App.tsx | 79 +++++----------------------------- frontend/src/lib/note-utils.ts | 66 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 69 deletions(-) create mode 100644 frontend/src/lib/note-utils.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e605af4..5f31c9e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -79,6 +79,16 @@ import type { NoteEvent } from './lib/api' import type { AuthPayload, Note, NotePayload } from './types' import { translate } from './i18n' import type { Locale, TranslationKey } from './i18n' +import { + defaultDraft, + estimateReadingTime, + extractSummary, + formatDate, + htmlToPlainText, + noteToPayload, + payloadEqualsNote, + sortNotes, +} from './lib/note-utils' type SessionStatus = 'booting' | 'anonymous' | 'authenticated' type AuthMode = 'login' | 'register' @@ -90,75 +100,6 @@ interface Toast { 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 ( + new Date(right.edit_time).getTime() - new Date(left.edit_time).getTime(), + ) +} + +export function htmlToPlainText(value: string) { + if (!value) return '' + + const document = new DOMParser().parseFromString(value, 'text/html') + return document.body.textContent?.replace(/\s+/g, ' ').trim() ?? '' +} + +export function extractSummary(value: string) { + return htmlToPlainText(value).slice(0, 280) +} + +export function estimateReadingTime(words: number) { + return Math.max(1, Math.ceil(words / 180)) +} + +export 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, + } +} + +export 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)) +} From d2f5f35c6dfa1eed9515226b98b1de2e16f219b9 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:27:22 +0700 Subject: [PATCH 11/14] Separated authentication from the web workspace. --- frontend/src/App.tsx | 68 +++++--------------- frontend/src/components/AuthScreen.tsx | 88 ++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/AuthScreen.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f31c9e..8554bee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,7 +41,6 @@ import { Link2, List, ListOrdered, - Languages, LogOut, Moon, Pin, @@ -79,6 +78,7 @@ 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 { defaultDraft, estimateReadingTime, @@ -1090,58 +1090,20 @@ function App() { if (sessionStatus === 'anonymous') { return ( -
-
-
- - - - - {authError ?

{authError}

: null} - - - - - -
-
-
+ setLocale((current) => (current === 'en' ? 'ru' : 'en'))} + onToggleMode={() => + setAuthMode((current) => (current === 'login' ? 'register' : 'login')) + } + /> ) } diff --git a/frontend/src/components/AuthScreen.tsx b/frontend/src/components/AuthScreen.tsx new file mode 100644 index 0000000..a1c8fca --- /dev/null +++ b/frontend/src/components/AuthScreen.tsx @@ -0,0 +1,88 @@ +import type { Dispatch, FormEventHandler, SetStateAction } from 'react' +import { Languages } from 'lucide-react' + +import type { Locale, TranslationKey } from '../i18n' +import type { AuthPayload } from '../types' + +type AuthMode = 'login' | 'register' +type Translator = ( + key: TranslationKey, + values?: Record, +) => string + +interface AuthScreenProps { + busy: boolean + error: string + form: AuthPayload + locale: Locale + mode: AuthMode + t: Translator + onFormChange: Dispatch> + onSubmit: FormEventHandler + onToggleLocale: () => void + onToggleMode: () => void +} + +export function AuthScreen({ + busy, + error, + form, + locale, + mode, + t, + onFormChange, + onSubmit, + onToggleLocale, + onToggleMode, +}: AuthScreenProps) { + return ( +
+
+
+ + + + + {error ?

{error}

: null} + + + + + +
+
+
+ ) +} From b540ae30eaa9a3031adb45fc5e0a052a6963ebb7 Mon Sep 17 00:00:00 2001 From: reallyshould Date: Fri, 14 Aug 2026 13:28:07 +0700 Subject: [PATCH 12/14] Separated the note list from the web workspace. --- frontend/src/App.tsx | 60 ++++----------- frontend/src/components/NotesSidebar.tsx | 93 ++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/NotesSidebar.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8554bee..3a5313f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -48,7 +48,6 @@ import { Quote, Search, SquareCode, - SquarePen, Strikethrough, Table2, Trash2, @@ -79,6 +78,8 @@ 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, estimateReadingTime, @@ -93,7 +94,6 @@ import { 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 @@ -1109,50 +1109,18 @@ function App() { return (
- +