diff --git a/package-lock.json b/package-lock.json index 1aecae0..8509657 100644 --- a/package-lock.json +++ b/package-lock.json @@ -135,6 +135,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1695,6 +1696,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1718,6 +1720,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1739,6 +1742,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2344,30 +2348,19 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/dom": { "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "optional": true, "dependencies": { - "@floating-ui/core": "^1.7.4", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT", "optional": true }, @@ -4869,6 +4862,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz", "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5104,6 +5098,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz", "integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5235,6 +5230,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz", "integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5249,6 +5245,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz", "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -5353,8 +5350,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", @@ -5442,7 +5438,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5453,7 +5448,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -5520,6 +5514,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -5530,6 +5525,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -5604,6 +5600,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -6216,7 +6213,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -6226,29 +6222,25 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -6259,15 +6251,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -6280,7 +6270,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -6290,7 +6279,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -6299,15 +6287,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -6324,7 +6310,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -6338,7 +6323,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -6351,7 +6335,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -6366,7 +6349,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -6376,21 +6358,20 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6403,7 +6384,6 @@ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -6453,7 +6433,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -6471,7 +6450,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6487,8 +6465,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-escapes": { "version": "7.3.0", @@ -6886,6 +6863,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7053,7 +7031,6 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -7264,7 +7241,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -7597,8 +7575,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -7615,7 +7592,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -7993,6 +7969,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8179,6 +8156,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8475,7 +8453,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -8967,8 +8944,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/minimatch": { "version": "10.1.1", @@ -9773,7 +9749,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -9788,7 +9763,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9834,6 +9808,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9891,8 +9866,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", @@ -10436,7 +10410,6 @@ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" }, @@ -10636,7 +10609,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10685,7 +10657,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10712,8 +10683,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -10744,7 +10714,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -10754,7 +10723,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -10894,8 +10862,7 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/next": { "version": "15.3.1", @@ -11391,6 +11358,7 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -11432,7 +11400,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11448,7 +11415,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -11585,6 +11551,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -11614,6 +11581,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -11662,6 +11630,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -11744,6 +11713,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11769,6 +11739,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11782,6 +11753,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -11839,8 +11811,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", @@ -11952,7 +11923,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -12139,6 +12111,7 @@ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -12321,7 +12294,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -12358,7 +12330,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -12370,8 +12341,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.3", @@ -13170,7 +13140,6 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -13260,6 +13229,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13493,6 +13463,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13714,6 +13685,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13881,7 +13853,6 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -13905,7 +13876,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -13954,7 +13924,6 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -13963,15 +13932,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13985,7 +13952,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -14309,6 +14275,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14346,6 +14313,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/screenshots/video-player-demo/video-player-bookmarks-panel.png b/screenshots/video-player-demo/video-player-bookmarks-panel.png new file mode 100644 index 0000000..cadfad8 Binary files /dev/null and b/screenshots/video-player-demo/video-player-bookmarks-panel.png differ diff --git a/screenshots/video-player-demo/video-player-default.png b/screenshots/video-player-demo/video-player-default.png new file mode 100644 index 0000000..6e388fc Binary files /dev/null and b/screenshots/video-player-demo/video-player-default.png differ diff --git a/screenshots/video-player-demo/video-player-notes-panel.png b/screenshots/video-player-demo/video-player-notes-panel.png new file mode 100644 index 0000000..ddcd305 Binary files /dev/null and b/screenshots/video-player-demo/video-player-notes-panel.png differ diff --git a/screenshots/video-player-demo/video-player-transcript-panel.png b/screenshots/video-player-demo/video-player-transcript-panel.png new file mode 100644 index 0000000..d678af0 Binary files /dev/null and b/screenshots/video-player-demo/video-player-transcript-panel.png differ diff --git a/src/app/api/bookmarks/route.ts b/src/app/api/bookmarks/route.ts new file mode 100644 index 0000000..bb44c38 --- /dev/null +++ b/src/app/api/bookmarks/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server'; + +type PersistedVideoBookmark = { + id: string; + time: number; + title: string; + note?: string; + createdAt: string; // ISO + updatedAt: string; // ISO +}; + +const bookmarksStore = new Map(); + +const keyFor = (userId: string | undefined, lessonId: string) => { + const safeUserId = encodeURIComponent(userId ?? 'anon'); + return `${safeUserId}::${encodeURIComponent(lessonId)}`; +}; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const lessonId = searchParams.get('lessonId'); + const userId = searchParams.get('userId') ?? undefined; + + if (!lessonId) { + return NextResponse.json({ success: false, message: 'lessonId is required' }, { status: 400 }); + } + + return NextResponse.json({ + data: bookmarksStore.get(keyFor(userId, lessonId)) ?? [], + success: true, + }); +} + +export async function POST(request: Request) { + const body = (await request.json()) as { + userId?: string; + lessonId: string; + bookmark: { id?: string; time: number; title: string; note?: string }; + }; + + if (!body?.lessonId || !body?.bookmark?.time || !body?.bookmark?.title) { + return NextResponse.json({ success: false, message: 'Invalid payload' }, { status: 400 }); + } + + const now = new Date().toISOString(); + const id = body.bookmark.id ?? `bookmark-${Date.now()}`; + + const persisted: PersistedVideoBookmark = { + id, + time: Math.max(0, body.bookmark.time), + title: body.bookmark.title.trim(), + note: body.bookmark.note?.trim() ? body.bookmark.note.trim() : undefined, + createdAt: now, + updatedAt: now, + }; + + const key = keyFor(body.userId, body.lessonId); + const prev = bookmarksStore.get(key) ?? []; + const next = [persisted, ...prev.filter((b) => b.id !== persisted.id)]; + bookmarksStore.set(key, next); + + return NextResponse.json({ success: true, data: persisted }); +} + +export async function PATCH(request: Request) { + const body = (await request.json()) as { + userId?: string; + lessonId: string; + id: string; + title: string; + note?: string; + time?: number; + }; + + if (!body?.lessonId || !body?.id || !body?.title) { + return NextResponse.json({ success: false, message: 'Invalid payload' }, { status: 400 }); + } + + const key = keyFor(body.userId, body.lessonId); + const prev = bookmarksStore.get(key) ?? []; + const now = new Date().toISOString(); + + const next = prev.map((b) => + b.id === body.id + ? { + ...b, + title: body.title.trim(), + note: body.note?.trim() ? body.note.trim() : undefined, + time: typeof body.time === 'number' ? Math.max(0, body.time) : b.time, + updatedAt: now, + } + : b, + ); + + bookmarksStore.set(key, next); + return NextResponse.json({ success: true }); +} + +export async function DELETE(request: Request) { + const body = (await request.json()) as { userId?: string; lessonId: string; id: string }; + if (!body?.lessonId || !body?.id) { + return NextResponse.json({ success: false, message: 'Invalid payload' }, { status: 400 }); + } + + const key = keyFor(body.userId, body.lessonId); + const prev = bookmarksStore.get(key) ?? []; + bookmarksStore.set( + key, + prev.filter((b) => b.id !== body.id), + ); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts new file mode 100644 index 0000000..d932f6f --- /dev/null +++ b/src/app/api/notes/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from 'next/server'; + +type PersistedVideoNote = { + id: string; + time: number; + text: string; + createdAt: string; // ISO + updatedAt: string; // ISO +}; + +const notesStore = new Map(); + +const keyFor = (userId: string | undefined, lessonId: string) => { + const safeUserId = encodeURIComponent(userId ?? 'anon'); + return `${safeUserId}::${encodeURIComponent(lessonId)}`; +}; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const lessonId = searchParams.get('lessonId'); + const userId = searchParams.get('userId') ?? undefined; + + if (!lessonId) { + return NextResponse.json({ success: false, message: 'lessonId is required' }, { status: 400 }); + } + + return NextResponse.json({ + data: notesStore.get(keyFor(userId, lessonId)) ?? [], + success: true, + }); +} + +export async function POST(request: Request) { + const body = (await request.json()) as { + userId?: string; + lessonId: string; + note: { id?: string; time: number; text: string }; + }; + + if (!body?.lessonId || !body?.note?.time || !body?.note?.text) { + return NextResponse.json({ success: false, message: 'Invalid payload' }, { status: 400 }); + } + + const now = new Date().toISOString(); + const id = body.note.id ?? `note-${Date.now()}`; + const persisted: PersistedVideoNote = { + id, + time: Math.max(0, body.note.time), + text: body.note.text.trim(), + createdAt: now, + updatedAt: now, + }; + + const key = keyFor(body.userId, body.lessonId); + const prev = notesStore.get(key) ?? []; + const next = [persisted, ...prev.filter((n) => n.id !== persisted.id)]; + notesStore.set(key, next); + + return NextResponse.json({ success: true, data: persisted }); +} + +export async function PATCH(request: Request) { + const body = (await request.json()) as { + userId?: string; + lessonId: string; + id: string; + text: string; + time?: number; + }; + + if (!body?.lessonId || !body?.id || !body?.text) { + return NextResponse.json({ success: false, message: 'Invalid payload' }, { status: 400 }); + } + + const key = keyFor(body.userId, body.lessonId); + const prev = notesStore.get(key) ?? []; + const now = new Date().toISOString(); + + const next = prev.map((n) => + n.id === body.id + ? { + ...n, + text: body.text.trim(), + time: typeof body.time === 'number' ? Math.max(0, body.time) : n.time, + updatedAt: now, + } + : n, + ); + + notesStore.set(key, next); + return NextResponse.json({ success: true }); +} + +export async function DELETE(request: Request) { + const body = (await request.json()) as { userId?: string; lessonId: string; id: string }; + if (!body?.lessonId || !body?.id) { + return NextResponse.json({ success: false, message: 'Invalid payload' }, { status: 400 }); + } + + const key = keyFor(body.userId, body.lessonId); + const prev = notesStore.get(key) ?? []; + notesStore.set( + key, + prev.filter((n) => n.id !== body.id), + ); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/video-analytics/route.ts b/src/app/api/video-analytics/route.ts new file mode 100644 index 0000000..9f9b67b --- /dev/null +++ b/src/app/api/video-analytics/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; + +type AnalyticsEvent = { + userId?: string; + lessonId: string; + eventType: string; + payload: Record; +}; + +const analyticsStore = new Map(); + +const keyFor = (userId: string | undefined, lessonId: string) => { + const safeUserId = encodeURIComponent(userId ?? 'anon'); + return `${safeUserId}::${encodeURIComponent(lessonId)}`; +}; + +export async function POST(request: Request) { + const body = (await request.json()) as { + userId?: string; + lessonId: string; + eventType: string; + payload?: Record; + }; + + if (!body?.lessonId || !body?.eventType) { + return NextResponse.json({ success: false, message: 'Invalid payload' }, { status: 400 }); + } + + const event: AnalyticsEvent = { + userId: body.userId, + lessonId: body.lessonId, + eventType: body.eventType, + payload: body.payload ?? {}, + }; + + const key = keyFor(body.userId, body.lessonId); + const prev = analyticsStore.get(key) ?? []; + analyticsStore.set(key, [event, ...prev].slice(0, 1000)); // cap for memory safety + + return NextResponse.json({ success: true }); +} diff --git a/src/app/components/video/AdvancedVideoPlayer.tsx b/src/app/components/video/AdvancedVideoPlayer.tsx new file mode 100644 index 0000000..e476672 --- /dev/null +++ b/src/app/components/video/AdvancedVideoPlayer.tsx @@ -0,0 +1,636 @@ +'use client'; + +/* eslint-disable jsx-a11y/role-has-required-aria-props */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Play, + Pause, + Volume2, + VolumeX, + Maximize, + Minimize, + PictureInPicture, + PictureInPicture2, + Bookmark, + FileText, + MessageSquare, +} from 'lucide-react'; +import { useVideoPlayer } from '../../hooks/useVideoPlayer'; +import { PlaybackControls } from './PlaybackControls'; +import { VideoNotes } from './VideoNotes'; +import { VideoBookmarks } from './VideoBookmarks'; +import { TranscriptView } from './TranscriptView'; +import { clamp, formatTime } from '@/utils/videoUtils'; +import { usePlaybackAnalytics } from './PlaybackAnalytics'; + +export type VideoQualityOption = { + label: string; + value: string; + src: string; + width?: number; + height?: number; + bitrate?: number; +}; + +export type AdvancedVideoPlayerProps = { + lessonId: string; + userId?: string; + src: string; // fallback/default src + poster?: string; + qualities?: VideoQualityOption[]; + transcript?: Array<{ time: number; text: string; speaker?: string }>; + className?: string; +}; + +export function AdvancedVideoPlayer(props: AdvancedVideoPlayerProps) { + const { lessonId, userId, src, poster, qualities, transcript = [], className = '' } = props; + + const videoRef = useRef(null); + const containerRef = useRef(null); + + const [showControls, setShowControls] = useState(true); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isPiP, setIsPiP] = useState(false); + const [showTranscript, setShowTranscript] = useState(false); + const [showNotes, setShowNotes] = useState(false); + const [showBookmarks, setShowBookmarks] = useState(false); + + const [announcement, setAnnouncement] = useState(''); + const [touchStartX, setTouchStartX] = useState(0); + const [touchStartTime, setTouchStartTime] = useState(0); + const [lastTapTime, setLastTapTime] = useState(0); + + const [autoQuality, setAutoQuality] = useState(true); + const initialQualityValue = qualities?.[0]?.value ?? ''; + const [selectedQualityValue, setSelectedQualityValue] = useState(initialQualityValue); + + const activeQualityValue = autoQuality ? 'auto' : selectedQualityValue; + const activeSrc = useMemo(() => { + if (!qualities || qualities.length === 0) return src; + if (autoQuality) return src; + return qualities.find((q) => q.value === selectedQualityValue)?.src ?? src; + }, [autoQuality, qualities, selectedQualityValue, src]); + + const qualitiesForControls = useMemo(() => { + if (!qualities?.length) return undefined; + return qualities.map((q) => ({ + label: q.label, + value: q.value, + width: q.width ?? 0, + height: q.height ?? 0, + bitrate: q.bitrate, + })); + }, [qualities]); + + const { + isPlaying, + currentTime, + duration, + volume, + playbackRate, + buffered, + isLoading, + error, + retryCount, + maxRetries, + play, + pause, + seekTo, + setVolume, + setPlaybackRate, + toggleMute, + retry, + resetError, + isMuted, + } = useVideoPlayer(videoRef); + + const analytics = usePlaybackAnalytics({ + lessonId, + userId, + isPlaying, + currentTime, + duration, + playbackRate, + qualityValue: activeQualityValue, + }); + + const clampSeekTime = useCallback( + (time: number) => clamp(time, 0, Number.isFinite(duration) && duration > 0 ? duration : 0), + [duration], + ); + + const seekToLearning = useCallback( + (time: number) => { + const safeTime = clampSeekTime(time); + analytics.registerSeek(currentTime, safeTime); + seekTo(safeTime); + }, + [analytics, clampSeekTime, currentTime, seekTo], + ); + + const setPlaybackRateLearning = useCallback( + (rate: number) => { + analytics.registerPlaybackRateChange(rate); + setPlaybackRate(rate); + }, + [analytics, setPlaybackRate], + ); + + const setQualityLearning = useCallback( + (qualityValue: string) => { + setSelectedQualityValue(qualityValue); + setAutoQuality(false); + analytics.registerQualityChange(qualityValue); + }, + [analytics], + ); + + const setAutoQualityLearning = useCallback( + (nextAuto: boolean) => { + setAutoQuality(nextAuto); + const val = nextAuto ? 'auto' : selectedQualityValue; + analytics.registerQualityChange(val); + }, + [analytics, selectedQualityValue], + ); + + // Keep a pending seek when switching quality src (avoid counting it as a user seek). + const pendingQualitySeekRef = useRef<{ time: number; shouldPlay: boolean } | null>(null); + useEffect(() => { + pendingQualitySeekRef.current = { time: currentTime, shouldPlay: isPlaying }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeSrc]); + + // Auto-hide controls while playing. + useEffect(() => { + if (!isPlaying) return; + const timer = window.setTimeout(() => setShowControls(false), 3000); + return () => window.clearTimeout(timer); + }, [isPlaying]); + + useEffect(() => { + if (isPlaying) { + setAnnouncement('Video playing'); + } else if (!isPlaying && currentTime > 0) { + setAnnouncement('Video paused'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPlaying]); + + useEffect(() => { + const handleFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, []); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const handleEnterPiP = () => setIsPiP(true); + const handleLeavePiP = () => setIsPiP(false); + video.addEventListener('enterpictureinpicture', handleEnterPiP); + video.addEventListener('leavepictureinpicture', handleLeavePiP); + return () => { + video.removeEventListener('enterpictureinpicture', handleEnterPiP); + video.removeEventListener('leavepictureinpicture', handleLeavePiP); + }; + }, [videoRef]); + + const togglePiP = useCallback(async () => { + if (!videoRef.current) return; + try { + if (document.pictureInPictureElement) { + await document.exitPictureInPicture(); + } else if (document.pictureInPictureEnabled) { + await videoRef.current.requestPictureInPicture(); + } + } catch { + // ignore + } + }, []); + + const toggleFullscreen = useCallback(() => { + if (!containerRef.current) return; + if (!document.fullscreenElement) { + containerRef.current.requestFullscreen?.(); + } else { + document.exitFullscreen?.(); + } + }, []); + + // Keyboard shortcuts. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!containerRef.current?.contains(document.activeElement)) return; + + switch (e.key) { + case ' ': + e.preventDefault(); + if (isPlaying) pause(); + else play(); + break; + case 'ArrowLeft': + e.preventDefault(); + seekToLearning(currentTime - 10); + break; + case 'ArrowRight': + e.preventDefault(); + seekToLearning(currentTime + 10); + break; + case 'ArrowUp': + e.preventDefault(); + setVolume(Math.min(1, volume + 0.1)); + break; + case 'ArrowDown': + e.preventDefault(); + setVolume(Math.max(0, volume - 0.1)); + break; + case 'f': + e.preventDefault(); + toggleFullscreen(); + break; + case 'm': + e.preventDefault(); + toggleMute(); + break; + case 'p': + e.preventDefault(); + if (document.pictureInPictureEnabled) void togglePiP(); + break; + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [ + currentTime, + isPlaying, + pause, + play, + seekToLearning, + setVolume, + toggleFullscreen, + toggleMute, + togglePiP, + volume, + ]); + + // Touch gesture handlers (double tap play/pause, swipe seek). + const handleTouchStart = (e: React.TouchEvent) => { + const touch = e.touches[0]; + setTouchStartX(touch.clientX); + setTouchStartTime(Date.now()); + + const current = Date.now(); + if (current - lastTapTime < 300) { + e.preventDefault(); + if (isPlaying) pause(); + else play(); + setLastTapTime(0); + } else { + setLastTapTime(current); + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + e.preventDefault(); + }; + + const handleTouchEnd = (e: React.TouchEvent) => { + const touch = e.changedTouches[0]; + const touchEndX = touch.clientX; + const touchEndTime = Date.now(); + + const deltaX = touchEndX - touchStartX; + const deltaTime = touchEndTime - touchStartTime; + + if (Math.abs(deltaX) > 50 && deltaTime < 500) { + e.preventDefault(); + const seekAmount = deltaX > 0 ? 10 : -10; + seekToLearning(clampSeekTime(currentTime + seekAmount)); + } + }; + + if (error) { + return ( +
+
+

Video error

+

{error.message}

+ {retryCount < maxRetries ? ( + + ) : ( + + )} +
+
+ ); + } + + const seekBarCurrentWidth = duration > 0 ? `${(currentTime / duration) * 100}%` : '0%'; + const seekBarBufferedWidth = duration > 0 ? `${(buffered / duration) * 100}%` : '0%'; + + return ( +
setShowControls(true)} + onMouseLeave={() => { + if (isPlaying) setShowControls(false); + }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + role="region" + aria-label="Advanced video player" + tabIndex={0} + > +
+ {announcement} +
+ +