diff --git a/.vscode/settings.json b/.vscode/settings.json index adf13772a..789eab83d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,9 +14,7 @@ ".eslintignore": "ignore" }, "eslint.validate": ["typescript", "typescriptreact"], - "eslint.workingDirectories": [ - { "directory": "./", "changeProcessCWD": true }, - ], + "eslint.workingDirectories": [{ "directory": "./", "changeProcessCWD": true }], "typescript.tsserver.experimental.enableProjectDiagnostics": false, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", @@ -50,7 +48,8 @@ "typescript.preferences.autoImportFileExcludePatterns": [ "@mantine/core", "@mantine/modals", - "@mantine/dates" + "@mantine/dates", + "@radix-ui/react-context-menu" ], "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true, diff --git a/package.json b/package.json index 1f8a4f812..fefba49c0 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@mantine/modals": "^8.2.8", "@mantine/notifications": "^8.2.8", "@offlegacy/nuqs-hash-router": "^0.1.1", + "@radix-ui/react-context-menu": "^2.2.16", "@tanstack/react-query": "^5.89.0", "@tanstack/react-query-devtools": "^5.89.0", "@tanstack/react-query-persist-client": "^5.89.0", @@ -105,7 +106,6 @@ "immer": "^9.0.21", "is-electron": "^2.2.2", "lodash": "^4.17.21", - "mantine-contextmenu": "^8.2.0", "md5": "^2.3.0", "memoize-one": "^6.0.0", "motion": "^12.18.1", @@ -118,6 +118,7 @@ "postcss-simple-vars": "^7.0.1", "qs": "^6.14.0", "react": "^19.1.0", + "react-call": "^1.8.1", "react-dom": "^19.1.0", "react-error-boundary": "^3.1.4", "react-i18next": "^11.18.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4e76fdc8..eb72415fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@offlegacy/nuqs-hash-router': specifier: ^0.1.1 version: 0.1.1(nuqs@2.7.1(react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@radix-ui/react-context-menu': + specifier: ^2.2.16 + version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-query': specifier: ^5.89.0 version: 5.89.0(react@19.1.0) @@ -143,9 +146,6 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 - mantine-contextmenu: - specifier: ^8.2.0 - version: 8.2.0(@mantine/core@8.2.8(@mantine/hooks@8.2.8(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.2.8(react@19.1.0))(clsx@2.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) md5: specifier: ^2.3.0 version: 2.3.0 @@ -182,6 +182,9 @@ importers: react: specifier: ^19.1.0 version: 19.1.0 + react-call: + specifier: ^1.8.1 + version: 1.8.1(react@19.1.0) react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) @@ -1597,87 +1600,353 @@ packages: '@radix-ui/primitive@1.0.0': resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.0.0': resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.0.0': resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.0.0': resolution: {integrity: sha512-Yn9YU+QlHYLWwV1XfKiqnGVpWYWk6MeBVM6x/bcoyPvxgjQGoeT35482viLPctTMWoMw0PoHgqfSox7Ig+957Q==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.0.0': resolution: {integrity: sha512-n7kDRfx+LB1zLueRDvZ1Pd0bxdJWDUZNQ/GWoxDn2prnuJKRdxsjulejX/ePkOsLi2tTm6P24mDqlMSgQpsT6g==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.0.0': resolution: {integrity: sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-focus-scope@1.0.0': resolution: {integrity: sha512-C4SWtsULLGf/2L4oGeIHlvWQx7Rf+7cX/vKOAD2dXW0A1b5QXwi3wWeaEgW+wn+SEVrraMUk05vLU9fZZz5HbQ==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.0.0': resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.0.0': resolution: {integrity: sha512-a8qyFO/Xb99d8wQdu4o7qnigNjTPG123uADNecz0eX4usnQEj7o+cG4ZX4zkqq98NYekT7UoEQIjxBNWIFuqTA==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.0.0': resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@1.0.0': resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.0.0': resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.0.0': resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.0.0': resolution: {integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.0.0': resolution: {integrity: sha512-JwfBCUIfhXRxKExgIqGa4CQsiMemo1Xt0W/B4ei3fpzpvPENKpMKQ8mZSB6Acj3ebrAEgi2xiQvcI1PAAodvyg==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.0.0': resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/pluginutils@1.0.0-beta.9': resolution: {integrity: sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==} @@ -3897,15 +4166,6 @@ packages: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - mantine-contextmenu@8.2.0: - resolution: {integrity: sha512-GKxC13wTnwCmToh6UvQtXN/vVbdbnScwXYtgzyKOzVGGEPBDmkqhKjG/IYq+JqSIqf/t9WoVHPm/81Jqi5FJgg==} - peerDependencies: - '@mantine/core': '>=8.2' - '@mantine/hooks': '>=8.2' - clsx: '>=2' - react: '>=19' - react-dom: '>=19' - map-stream@0.1.0: resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} @@ -4489,6 +4749,11 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + react-call@1.8.1: + resolution: {integrity: sha512-HztXMrthuK+XHeUcOJjBsT2U+oKpc2nKcUWii1aYBIs3vpgTFRQ7nm/RbjU8JCC41djRpT0GC0KgF8XYVgxOUQ==} + peerDependencies: + react: '>=18' + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -7157,16 +7422,65 @@ snapshots: dependencies: '@babel/runtime': 7.27.1 + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-compose-refs@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 react: 19.1.0 + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.23)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-context@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 react: 19.1.0 + '@radix-ui/react-context@1.1.2(@types/react@18.3.23)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-dialog@1.0.0(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 @@ -7189,6 +7503,12 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@radix-ui/react-direction@1.1.1(@types/react@18.3.23)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-dismissable-layer@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 @@ -7200,11 +7520,30 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-focus-guards@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 react: 19.1.0 + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.23)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-focus-scope@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 @@ -7214,12 +7553,74 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-id@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 '@radix-ui/react-use-layout-effect': 1.0.0(react@19.1.0) react: 19.1.0 + '@radix-ui/react-id@1.1.1(@types/react@18.3.23)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.0(@types/react@18.3.23)(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/rect': 1.1.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-portal@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 @@ -7227,6 +7628,16 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-presence@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 @@ -7235,6 +7646,16 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-primitive@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 @@ -7242,34 +7663,117 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-slot@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 '@radix-ui/react-compose-refs': 1.0.0(react@19.1.0) react: 19.1.0 + '@radix-ui/react-slot@1.2.3(@types/react@18.3.23)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-use-callback-ref@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 react: 19.1.0 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-use-controllable-state@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 '@radix-ui/react-use-callback-ref': 1.0.0(react@19.1.0) react: 19.1.0 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.23)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.23)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.23)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-use-escape-keydown@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 '@radix-ui/react-use-callback-ref': 1.0.0(react@19.1.0) react: 19.1.0 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.23)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-use-layout-effect@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 react: 19.1.0 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.23)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.23)(react@19.1.0)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.23)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.23 + + '@radix-ui/rect@1.1.1': {} + '@rolldown/pluginutils@1.0.0-beta.9': {} '@rollup/plugin-babel@5.3.1(@babel/core@7.27.1)(@types/babel__core@7.20.5)(rollup@2.79.2)': @@ -9867,14 +10371,6 @@ snapshots: - bluebird - supports-color - mantine-contextmenu@8.2.0(@mantine/core@8.2.8(@mantine/hooks@8.2.8(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.2.8(react@19.1.0))(clsx@2.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - '@mantine/core': 8.2.8(@mantine/hooks@8.2.8(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@mantine/hooks': 8.2.8(react@19.1.0) - clsx: 2.1.1 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - map-stream@0.1.0: {} matcher-collection@2.0.1: @@ -10389,6 +10885,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + react-call@1.8.1(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d7e5ac17e..8812039e4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -11,6 +11,10 @@ "moveToNext": "move to next", "moveToBottom": "move to bottom", "moveToTop": "move to top", + "moveItems": "move items", + "shuffle": "shuffle", + "shuffleAll": "shuffle all", + "shuffleSelected": "shuffle selected", "refresh": "$t(common.refresh)", "removeFromFavorites": "remove from $t(entity.favorite_other)", "removeFromPlaylist": "remove from $t(entity.playlist_one)", @@ -366,6 +370,7 @@ "deletePlaylist": "$t(action.deletePlaylist)", "deselectAll": "$t(action.deselectAll)", "download": "download", + "moveItems": "$t(action.moveItems)", "moveToNext": "$t(action.moveToNext)", "moveToBottom": "$t(action.moveToBottom)", "moveToTop": "$t(action.moveToTop)", @@ -378,6 +383,7 @@ "setRating": "$t(action.setRating)", "playShuffled": "$t(player.shuffle)", "shareItem": "share item", + "goTo": "go to", "goToAlbum": "go to $t(entity.album_one)", "goToAlbumArtist": "go to $t(entity.albumArtist_one)", "showDetails": "get info" diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 429e19167..28536a154 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -24,8 +24,8 @@ import { AppRouter } from '/@/renderer/router/app-router'; import { useCssSettings, useHotkeySettings, useSettingsStore } from '/@/renderer/store'; import { useAppTheme } from '/@/renderer/themes/use-app-theme'; import { sanitizeCss } from '/@/renderer/utils/sanitize'; -import '/styles/overlayscrollbars.css'; import { WebAudio } from '/@/shared/types/types'; +import '/styles/overlayscrollbars.css'; ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); diff --git a/src/renderer/components/context-menu/context-menu.module.css b/src/renderer/components/context-menu/context-menu.module.css deleted file mode 100644 index cc8d7ea71..000000000 --- a/src/renderer/components/context-menu/context-menu.module.css +++ /dev/null @@ -1,35 +0,0 @@ -.container { - position: absolute; - z-index: 1000; - padding: var(--theme-spacing-xs); - background: var(--theme-colors-surface); - border: 1px solid var(--theme-colors-border); - border-radius: var(--theme-radius-md); - box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%); -} - -.context-menu-button { - display: flex; - padding: var(--theme-spacing-sm); - font-family: var(--theme-content-font-family); - font-size: var(--theme-font-size-sm); - font-weight: 500; - color: var(--theme-colors-surface-foreground); - text-align: left; - cursor: default; - background: var(--theme-colors-surface); - border: none; - - &:hover { - background: lighten(var(--theme-colors-surface), 10%); - } - - &:disabled { - background: transparent; - opacity: 0.6; - } -} - -.left { - margin-right: 3rem; -} diff --git a/src/renderer/components/context-menu/context-menu.tsx b/src/renderer/components/context-menu/context-menu.tsx deleted file mode 100644 index a1d5f69f3..000000000 --- a/src/renderer/components/context-menu/context-menu.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { motion, Variants } from 'motion/react'; -import { ComponentPropsWithoutRef, forwardRef, ReactNode, Ref } from 'react'; - -import styles from './context-menu.module.css'; - -import { Group } from '/@/shared/components/group/group'; - -interface ContextMenuProps { - children: ReactNode; - maxWidth?: number; - minWidth?: number; - xPos: number; - yPos: number; -} - -export const ContextMenuButton = forwardRef( - ( - { - children, - leftIcon, - rightIcon, - ...props - }: ComponentPropsWithoutRef<'button'> & { - leftIcon?: ReactNode; - rightIcon?: ReactNode; - }, - ref: any, - ) => { - return ( - - ); - }, -); - -const variants: Variants = { - closed: { - opacity: 0, - transition: { - duration: 0.1, - }, - }, - open: { - opacity: 1, - transition: { - duration: 0.1, - }, - }, -}; - -export const ContextMenu = forwardRef( - ({ children, maxWidth, minWidth, xPos, yPos }: ContextMenuProps, ref: Ref) => { - return ( - - {children} - - ); - }, -); diff --git a/src/renderer/components/item-list/helpers/item-list-controls.ts b/src/renderer/components/item-list/helpers/item-list-controls.ts index 7cd3c599d..eaf66f981 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router'; import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path'; import { ItemListStateItemWithRequiredProperties } from '/@/renderer/components/item-list/helpers/item-list-state'; import { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types'; +import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { LibraryItem, QueueSong } from '/@/shared/types/domain-types'; import { Play, TableColumn } from '/@/shared/types/types'; @@ -230,8 +231,38 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs player.setFavorite(item._serverId, [item.id], itemType, favorite); }, - onMore: ({ internalState, item, itemType }: DefaultItemControlProps) => { - console.log('handleItemMore', item, itemType, internalState); + onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => { + if (!item || !internalState || !event) { + return; + } + + const rowId = internalState.extractRowId(item); + + if (!rowId) return; + + // If none selected, select this item + if (internalState.getSelected().length === 0) { + internalState.setSelected([item]); + return ContextMenuController.call({ + cmd: { items: [item] as any[], type: itemType as any }, + event, + }); + } + // If this item is not already selected, replace the selection with this item + else if (!internalState.isSelected(rowId)) { + internalState.setSelected([item]); + return ContextMenuController.call({ + cmd: { items: [item] as any[], type: itemType as any }, + event, + }); + } + + const selectedItems = internalState.getSelected(); + + return ContextMenuController.call({ + cmd: { items: selectedItems as any[], type: itemType as any }, + event, + }); }, onPlay: ({ diff --git a/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx b/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx new file mode 100644 index 000000000..2d5f3a261 --- /dev/null +++ b/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx @@ -0,0 +1,331 @@ +import { openContextModal } from '@mantine/modals'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import Fuse from 'fuse.js'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + getAlbumArtistSongsById, + getAlbumSongsById, + getGenreSongsById, + getPlaylistSongsById, +} from '/@/renderer/features/player/utils'; +import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; +import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists'; +import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation'; +import { useCurrentServer, useCurrentServerId } from '/@/renderer/store'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { toast } from '/@/shared/components/toast/toast'; +import { LibraryItem, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types'; + +interface AddToPlaylistActionProps { + items: string[]; + itemType: LibraryItem; +} + +export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProps) => { + const { t } = useTranslation(); + const server = useCurrentServer(); + const serverId = useCurrentServerId(); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(''); + const addToPlaylistMutation = useAddToPlaylist({}); + + const playlistsQuery = useQuery( + playlistsQueries.list({ + query: { + _custom: { + navidrome: { + smart: false, + }, + }, + sortBy: PlaylistListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: server?.id, + }), + ); + + const { recentPlaylistId } = useRecentPlaylists(serverId); + + const fuse = useMemo(() => { + if (!playlistsQuery.data?.items) return null; + + return new Fuse(playlistsQuery.data.items, { + fieldNormWeight: 1, + ignoreLocation: true, + keys: ['name'], + threshold: 0.3, + }); + }, [playlistsQuery.data?.items]); + + const recentPlaylist = useMemo(() => { + if (!playlistsQuery.data?.items || !recentPlaylistId) return null; + + const playlist = playlistsQuery.data.items.find((p) => p.id === recentPlaylistId); + if (!playlist) return null; + + if (searchTerm && fuse) { + const results = fuse.search(searchTerm); + const found = results.find((result) => result.item.id === recentPlaylistId); + if (!found) return null; + } + + return playlist; + }, [playlistsQuery.data?.items, recentPlaylistId, searchTerm, fuse]); + + const filteredPlaylists = useMemo(() => { + if (!playlistsQuery.data?.items) return []; + if (!searchTerm || !fuse) { + // Exclude recent playlist from the list if it exists + return recentPlaylistId + ? playlistsQuery.data.items.filter((p) => p.id !== recentPlaylistId) + : playlistsQuery.data.items; + } + + const results = fuse.search(searchTerm); + const filtered = results.map((result) => result.item); + // Exclude recent playlist from the filtered results if it exists + return recentPlaylistId ? filtered.filter((p) => p.id !== recentPlaylistId) : filtered; + }, [playlistsQuery.data?.items, searchTerm, fuse, recentPlaylistId]); + + const getSongsByAlbum = useCallback( + async (albumId: string) => { + if (!server) return null; + return getAlbumSongsById({ + id: [albumId], + queryClient, + server, + }); + }, + [queryClient, server], + ); + + const getSongsByArtist = useCallback( + async (artistId: string) => { + if (!server) return null; + return getAlbumArtistSongsById({ + id: [artistId], + queryClient, + server, + }); + }, + [queryClient, server], + ); + + const getSongsByGenre = useCallback( + async (genreIds: string[]) => { + if (!server) return null; + return getGenreSongsById({ + id: genreIds, + queryClient, + server, + }); + }, + [queryClient, server], + ); + + const getSongsByPlaylist = useCallback( + async (playlistId: string) => { + if (!server) return null; + return getPlaylistSongsById({ + id: playlistId, + queryClient, + server, + }); + }, + [queryClient, server], + ); + + const handleAddToPlaylist = useCallback( + async (playlistId: string) => { + if (items.length === 0 || !serverId) return; + + try { + let allSongIds: string[] = []; + + if (itemType === LibraryItem.SONG) { + allSongIds = items; + } else if (itemType === LibraryItem.ALBUM) { + for (const id of items) { + const songs = await getSongsByAlbum(id); + allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + } + } else if ( + itemType === LibraryItem.ALBUM_ARTIST || + itemType === LibraryItem.ARTIST + ) { + for (const id of items) { + const songs = await getSongsByArtist(id); + allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + } + } else if (itemType === LibraryItem.GENRE) { + const songs = await getSongsByGenre(items); + allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + } else if (itemType === LibraryItem.PLAYLIST) { + for (const id of items) { + const songs = await getSongsByPlaylist(id); + allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + } + } + + if (allSongIds.length === 0) { + toast.error({ + message: t('error.noItemsSelected', { postProcess: 'sentenceCase' }), + }); + return; + } + + addToPlaylistMutation.mutate( + { + apiClientProps: { serverId }, + body: { + songId: allSongIds, + }, + query: { + id: playlistId, + }, + }, + { + onError: (err) => { + toast.error({ + message: err.message, + title: t('error.genericError', { postProcess: 'sentenceCase' }), + }); + }, + onSuccess: () => {}, + }, + ); + } catch (error) { + toast.error({ + message: (error as Error).message, + title: t('error.genericError', { postProcess: 'sentenceCase' }), + }); + } + }, + [ + addToPlaylistMutation, + getSongsByAlbum, + getSongsByArtist, + getSongsByGenre, + getSongsByPlaylist, + itemType, + items, + serverId, + t, + ], + ); + + const handleOpenModal = useCallback(() => { + const modalProps: { + albumId?: string[]; + artistId?: string[]; + genreId?: string[]; + initialSelectedIds?: string[]; + playlistId?: string[]; + songId?: string[]; + } = {}; + + switch (itemType) { + case LibraryItem.ALBUM: + modalProps.albumId = items; + break; + case LibraryItem.ALBUM_ARTIST: + case LibraryItem.ARTIST: + modalProps.artistId = items; + break; + case LibraryItem.GENRE: + modalProps.genreId = items; + break; + case LibraryItem.PLAYLIST: + modalProps.playlistId = items; + break; + case LibraryItem.PLAYLIST_SONG: + case LibraryItem.QUEUE_SONG: + case LibraryItem.SONG: + modalProps.songId = items; + break; + default: + return; + } + + openContextModal({ + innerProps: { + itemIds: items, + resourceType: itemType, + }, + modal: 'addToPlaylist', + title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }), + }); + }, [itemType, items, t]); + + if (items.length === 0) return null; + + const searchInput = ( + } + onChange={(e) => setSearchTerm(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + pb="xs" + placeholder={t('common.search', { postProcess: 'sentenceCase' })} + size="sm" + value={searchTerm} + /> + ); + + return ( + + + + {t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' })} + + + + {playlistsQuery.isLoading && ( + + + + )} + {playlistsQuery.isError && ( + + {t('error.genericError', { postProcess: 'sentenceCase' })} + + )} + {recentPlaylist && ( + <> + handleAddToPlaylist(recentPlaylist.id)} + > + {recentPlaylist.name} + + {filteredPlaylists.length > 0 && } + + )} + {filteredPlaylists.length === 0 && !playlistsQuery.isLoading && ( + + {t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })} + + )} + {filteredPlaylists.map((playlist) => ( + handleAddToPlaylist(playlist.id)} + > + {playlist.name} + + ))} + + + ); +}; diff --git a/src/renderer/features/context-menu/actions/delete-playlist-action.tsx b/src/renderer/features/context-menu/actions/delete-playlist-action.tsx new file mode 100644 index 000000000..ef8ab53af --- /dev/null +++ b/src/renderer/features/context-menu/actions/delete-playlist-action.tsx @@ -0,0 +1,72 @@ +import { closeAllModals, openModal } from '@mantine/modals'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; + +import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; +import { AppRoute } from '/@/renderer/router/routes'; +import { useCurrentServerId } from '/@/renderer/store'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { ConfirmModal } from '/@/shared/components/modal/modal'; +import { toast } from '/@/shared/components/toast/toast'; +import { Playlist } from '/@/shared/types/domain-types'; + +interface DeletePlaylistActionProps { + items: Playlist[]; +} + +export const DeletePlaylistAction = ({ items }: DeletePlaylistActionProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const serverId = useCurrentServerId(); + const deletePlaylistMutation = useDeletePlaylist({}); + + const handleDeletePlaylist = useCallback(() => { + if (items.length === 0 || !serverId) return; + + const playlist = items[0]; + + deletePlaylistMutation.mutate( + { + apiClientProps: { serverId }, + query: { id: playlist.id }, + }, + { + onError: (err) => { + toast.error({ + message: err.message, + title: t('error.genericError', { postProcess: 'sentenceCase' }), + }); + }, + onSuccess: () => { + navigate(AppRoute.PLAYLISTS, { replace: true }); + toast.success({ + message: t('action.deletePlaylist', { postProcess: 'sentenceCase' }), + }); + }, + }, + ); + closeAllModals(); + }, [deletePlaylistMutation, items, navigate, serverId, t]); + + const openDeletePlaylistModal = useCallback(() => { + if (items.length === 0) return; + + openModal({ + children: ( + + {t('common.areYouSure', { postProcess: 'sentenceCase' })} + + ), + title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }), + }); + }, [handleDeletePlaylist, items.length, t]); + + if (items.length === 0) return null; + + return ( + + {t('action.deletePlaylist', { postProcess: 'sentenceCase' })} + + ); +}; diff --git a/src/renderer/features/context-menu/actions/download-action.tsx b/src/renderer/features/context-menu/actions/download-action.tsx new file mode 100644 index 000000000..01de6df1c --- /dev/null +++ b/src/renderer/features/context-menu/actions/download-action.tsx @@ -0,0 +1,51 @@ +import isElectron from 'is-electron'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { api } from '/@/renderer/api'; +import { useCurrentServer } from '/@/renderer/store'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { toast } from '/@/shared/components/toast/toast'; + +interface DownloadActionProps { + ids: string[]; +} + +const utils = isElectron() ? window.api.utils : null; + +export const DownloadAction = ({ ids }: DownloadActionProps) => { + const { t } = useTranslation(); + const server = useCurrentServer(); + + const onSelect = useCallback(async () => { + if (!utils) { + return; + } + + try { + for (const id of ids) { + const downloadUrl = api.controller.getDownloadUrl({ + apiClientProps: { serverId: server.id }, + query: { id }, + }); + + utils.download(downloadUrl); + } + + toast.success({ + message: t('action.downloadStarted', { + count: ids.length, + postProcess: 'sentenceCase', + }), + }); + } catch (error) { + console.error('Failed to download items:', error); + } + }, [ids, server, t]); + + return ( + 1} leftIcon="download" onSelect={onSelect}> + {t('page.contextMenu.download', { postProcess: 'sentenceCase' })} + + ); +}; diff --git a/src/renderer/features/context-menu/actions/get-info-action.tsx b/src/renderer/features/context-menu/actions/get-info-action.tsx new file mode 100644 index 000000000..665a83136 --- /dev/null +++ b/src/renderer/features/context-menu/actions/get-info-action.tsx @@ -0,0 +1,36 @@ +import { openModal } from '@mantine/modals'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + ItemDetailsModal, + ItemDetailsModalProps, +} from '/@/renderer/features/item-details/components/item-details-modal'; +import { useCurrentServer } from '/@/renderer/store'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; + +interface GetInfoActionProps { + disabled?: boolean; + item: ItemDetailsModalProps['item']; +} + +export const GetInfoAction = ({ disabled, item }: GetInfoActionProps) => { + const { t } = useTranslation(); + const server = useCurrentServer(); + + const onSelect = useCallback(async () => { + if (!server) return; + + openModal({ + children: , + size: 'lg', + title: item.name || t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }), + }); + }, [item, server, t]); + + return ( + + {t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' })} + + ); +}; diff --git a/src/renderer/features/context-menu/actions/go-to-action.tsx b/src/renderer/features/context-menu/actions/go-to-action.tsx new file mode 100644 index 000000000..3ad5dd9c3 --- /dev/null +++ b/src/renderer/features/context-menu/actions/go-to-action.tsx @@ -0,0 +1,96 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { generatePath, useNavigate } from 'react-router'; + +import { AppRoute } from '/@/renderer/router/routes'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { + Album, + AlbumArtist, + Artist, + LibraryItem, + QueueSong, + Song, +} from '/@/shared/types/domain-types'; + +interface GoToActionProps { + items: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[]; +} + +export const GoToAction = ({ items }: GoToActionProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { albumArtists, albumId } = useMemo(() => { + const firstItem = items[0]; + + if (firstItem._itemType === LibraryItem.ALBUM) { + return { + albumArtists: firstItem.albumArtists || [], + albumId: firstItem.id, + }; + } else if (firstItem._itemType === LibraryItem.SONG) { + return { + albumArtists: firstItem.albumArtists || [], + albumId: firstItem.albumId, + }; + } else if ( + firstItem._itemType === LibraryItem.ARTIST || + firstItem._itemType === LibraryItem.ALBUM_ARTIST + ) { + return { + albumArtists: [{ id: firstItem.id, name: firstItem.name }], + albumId: null, + }; + } + + return { + albumArtists: [], + albumId: null, + }; + }, [items]); + + const handleGoToAlbum = useCallback(() => { + if (!albumId) return; + navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId })); + }, [albumId, navigate]); + + const handleGoToAlbumArtist = useCallback( + (albumArtistId: string) => { + navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId })); + }, + [navigate], + ); + + const hasAlbum = !!albumId; + + return ( + + + e.preventDefault()} + rightIcon="arrowRightS" + > + {t('page.contextMenu.goTo', { postProcess: 'sentenceCase' })} + + + + {hasAlbum && ( + + {t('page.contextMenu.goToAlbum', { postProcess: 'sentenceCase' })} + + )} + {albumArtists.map((albumArtist) => ( + handleGoToAlbumArtist(albumArtist.id)} + > + {`${t('page.contextMenu.goTo', { postProcess: 'sentenceCase' })} ${albumArtist.name}`} + + ))} + + + ); +}; diff --git a/src/renderer/features/context-menu/actions/move-queue-items-action.tsx b/src/renderer/features/context-menu/actions/move-queue-items-action.tsx new file mode 100644 index 000000000..a71700616 --- /dev/null +++ b/src/renderer/features/context-menu/actions/move-queue-items-action.tsx @@ -0,0 +1,52 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { usePlayer } from '/@/renderer/features/player/context/player-context'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { QueueSong } from '/@/shared/types/domain-types'; + +interface MoveQueueItemsActionProps { + items: QueueSong[]; +} + +export const MoveQueueItemsAction = ({ items }: MoveQueueItemsActionProps) => { + const { t } = useTranslation(); + const player = usePlayer(); + + const handleMoveToTop = useCallback(() => { + player.moveSelectedToTop(items); + }, [items, player]); + + const handleMoveToNext = useCallback(() => { + player.moveSelectedToNext(items); + }, [items, player]); + + const handleMoveToBottom = useCallback(() => { + player.moveSelectedToBottom(items); + }, [items, player]); + + return ( + + + e.preventDefault()} + rightIcon="arrowRightS" + > + {t('page.contextMenu.moveItems', { postProcess: 'sentenceCase' })} + + + + + {t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' })} + + + {t('page.contextMenu.moveToNext', { postProcess: 'sentenceCase' })} + + + {t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' })} + + + + ); +}; diff --git a/src/renderer/features/context-menu/actions/play-action.tsx b/src/renderer/features/context-menu/actions/play-action.tsx new file mode 100644 index 000000000..05a6732ac --- /dev/null +++ b/src/renderer/features/context-menu/actions/play-action.tsx @@ -0,0 +1,73 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { usePlayer } from '/@/renderer/features/player/context/player-context'; +import { useCurrentServerId } from '/@/renderer/store'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { LibraryItem } from '/@/shared/types/domain-types'; +import { Play } from '/@/shared/types/types'; + +interface PlayActionProps { + ids: string[]; + itemType: LibraryItem; +} + +export const PlayAction = ({ ids, itemType }: PlayActionProps) => { + const { t } = useTranslation(); + const player = usePlayer(); + const serverId = useCurrentServerId(); + + const handlePlay = useCallback( + (playType: Play) => { + if (ids.length === 0 || !serverId) return; + player.addToQueueByFetch(serverId, ids, itemType, playType); + }, + [ids, itemType, player, serverId], + ); + + const handlePlayNow = useCallback(() => { + handlePlay(Play.NOW); + }, [handlePlay]); + + const handlePlayNext = useCallback(() => { + handlePlay(Play.NEXT); + }, [handlePlay]); + + const handlePlayLast = useCallback(() => { + handlePlay(Play.LAST); + }, [handlePlay]); + + const handlePlayShuffled = useCallback(() => { + handlePlay(Play.SHUFFLE); + }, [handlePlay]); + + if (ids.length === 0) return null; + + return ( + + + e.preventDefault()} + rightIcon="arrowRightS" + > + {t('player.play', { postProcess: 'sentenceCase' })} + + + + + {t('player.play', { postProcess: 'sentenceCase' })} + + + {t('player.addNext', { postProcess: 'sentenceCase' })} + + + {t('player.addLast', { postProcess: 'sentenceCase' })} + + + {t('player.shuffle', { postProcess: 'sentenceCase' })} + + + + ); +}; diff --git a/src/renderer/features/context-menu/actions/remove-from-playlist-action.tsx b/src/renderer/features/context-menu/actions/remove-from-playlist-action.tsx new file mode 100644 index 000000000..17a3cb951 --- /dev/null +++ b/src/renderer/features/context-menu/actions/remove-from-playlist-action.tsx @@ -0,0 +1,60 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router'; + +import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation'; +import { useCurrentServerId } from '/@/renderer/store'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { toast } from '/@/shared/components/toast/toast'; +import { Song } from '/@/shared/types/domain-types'; + +interface RemoveFromPlaylistActionProps { + items: Song[]; +} + +export const RemoveFromPlaylistAction = ({ items }: RemoveFromPlaylistActionProps) => { + const { t } = useTranslation(); + const serverId = useCurrentServerId(); + const { playlistId } = useParams() as { playlistId?: string }; + const removeFromPlaylistMutation = useRemoveFromPlaylist(); + + const { ids } = useMemo(() => { + const ids = items.map((item) => item.id); + return { ids }; + }, [items]); + + const handleRemoveFromPlaylist = useCallback(() => { + if (ids.length === 0 || !serverId || !playlistId) return; + + removeFromPlaylistMutation.mutate( + { + apiClientProps: { serverId }, + query: { + id: playlistId, + songId: ids, + }, + }, + { + onError: (err) => { + toast.error({ + message: err.message, + title: t('error.genericError', { postProcess: 'sentenceCase' }), + }); + }, + onSuccess: () => { + toast.success({ + message: t('action.removeFromPlaylist', { postProcess: 'sentenceCase' }), + }); + }, + }, + ); + }, [ids, playlistId, removeFromPlaylistMutation, serverId, t]); + + if (ids.length === 0 || !playlistId) return null; + + return ( + + {t('action.removeFromPlaylist', { postProcess: 'sentenceCase' })} + + ); +}; diff --git a/src/renderer/features/context-menu/actions/remove-from-queue-action.tsx b/src/renderer/features/context-menu/actions/remove-from-queue-action.tsx new file mode 100644 index 000000000..d8183cba2 --- /dev/null +++ b/src/renderer/features/context-menu/actions/remove-from-queue-action.tsx @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { usePlayer } from '/@/renderer/features/player/context/player-context'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { QueueSong } from '/@/shared/types/domain-types'; + +interface RemoveFromQueueActionProps { + items: QueueSong[]; +} + +export const RemoveFromQueueAction = ({ items }: RemoveFromQueueActionProps) => { + const { t } = useTranslation(); + const player = usePlayer(); + + const onSelect = useCallback(() => { + player.clearSelected(items); + }, [items, player]); + + return ( + + {t('action.removeFromQueue', { postProcess: 'sentenceCase' })} + + ); +}; diff --git a/src/renderer/features/context-menu/actions/set-favorite-action.tsx b/src/renderer/features/context-menu/actions/set-favorite-action.tsx new file mode 100644 index 000000000..11b03774c --- /dev/null +++ b/src/renderer/features/context-menu/actions/set-favorite-action.tsx @@ -0,0 +1,67 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; +import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; +import { useCurrentServerId } from '/@/renderer/store'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +interface SetFavoriteActionProps { + ids: string[]; + itemType: LibraryItem; +} + +export const SetFavoriteAction = ({ ids, itemType }: SetFavoriteActionProps) => { + const { t } = useTranslation(); + const serverId = useCurrentServerId(); + + const createFavoriteMutation = useCreateFavorite({}); + const deleteFavoriteMutation = useDeleteFavorite({}); + + const handleAddToFavorites = useCallback(() => { + if (ids.length === 0 || !serverId) return; + + createFavoriteMutation.mutate({ + apiClientProps: { serverId }, + query: { + id: ids, + type: itemType, + }, + }); + }, [createFavoriteMutation, ids, itemType, serverId]); + + const handleRemoveFromFavorites = useCallback(() => { + if (ids.length === 0 || !serverId) return; + + deleteFavoriteMutation.mutate({ + apiClientProps: { serverId }, + query: { + id: ids, + type: itemType, + }, + }); + }, [deleteFavoriteMutation, ids, itemType, serverId]); + + return ( + + + e.preventDefault()} + rightIcon="arrowRightS" + > + {t('common.favorite', { postProcess: 'sentenceCase' })} + + + + + {t('action.addToFavorites', { postProcess: 'sentenceCase' })} + + + {t('action.removeFromFavorites', { postProcess: 'sentenceCase' })} + + + + ); +}; diff --git a/src/renderer/features/context-menu/actions/set-rating-action.tsx b/src/renderer/features/context-menu/actions/set-rating-action.tsx new file mode 100644 index 000000000..477997464 --- /dev/null +++ b/src/renderer/features/context-menu/actions/set-rating-action.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next'; + +import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation'; +import { useCurrentServerId } from '/@/renderer/store'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { Rating } from '/@/shared/components/rating/rating'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +interface SetRatingActionProps { + ids: string[]; + itemType: LibraryItem; +} + +export const SetRatingAction = ({ ids, itemType }: SetRatingActionProps) => { + const { t } = useTranslation(); + + const serverId = useCurrentServerId(); + + const setRatingMutation = useSetRating({}); + + const onRating = (rating: number) => { + setRatingMutation.mutate({ + apiClientProps: { serverId }, + query: { + id: ids, + rating, + type: itemType, + }, + }); + }; + + return ( + + + e.preventDefault()} + rightIcon="arrowRightS" + > + {t('action.setRating', { postProcess: 'sentenceCase' })} + + + + onRating(0)}> + + + onRating(1)}> + + + onRating(2)}> + + + onRating(3)}> + + + onRating(4)}> + + + onRating(5)}> + + + + + ); +}; diff --git a/src/renderer/features/context-menu/actions/share-action.tsx b/src/renderer/features/context-menu/actions/share-action.tsx new file mode 100644 index 000000000..3e56b63ab --- /dev/null +++ b/src/renderer/features/context-menu/actions/share-action.tsx @@ -0,0 +1,47 @@ +import { openContextModal } from '@mantine/modals'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +interface ShareActionProps { + ids: string[]; + itemType: LibraryItem; +} + +export const ShareAction = ({ ids, itemType }: ShareActionProps) => { + const { t } = useTranslation(); + + const resourceType = useMemo(() => { + switch (itemType) { + case LibraryItem.ALBUM: + return 'album'; + case LibraryItem.ALBUM_ARTIST: + return 'albumArtist'; + case LibraryItem.PLAYLIST: + return 'playlist'; + case LibraryItem.SONG: + return 'song'; + default: + return 'song'; + } + }, [itemType]); + + const onSelect = useCallback(() => { + openContextModal({ + innerProps: { + itemIds: ids, + resourceType, + }, + modal: 'shareItem', + title: t('page.contextMenu.shareItem', { postProcess: 'titleCase' }), + }); + }, [ids, resourceType, t]); + + return ( + + {t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' })} + + ); +}; diff --git a/src/renderer/features/context-menu/actions/shuffle-items-action.tsx b/src/renderer/features/context-menu/actions/shuffle-items-action.tsx new file mode 100644 index 000000000..78613e75e --- /dev/null +++ b/src/renderer/features/context-menu/actions/shuffle-items-action.tsx @@ -0,0 +1,45 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { usePlayer } from '/@/renderer/features/player/context/player-context'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { QueueSong } from '/@/shared/types/domain-types'; + +interface ShuffleItemsActionProps { + items: QueueSong[]; +} + +export const ShuffleItemsAction = ({ items }: ShuffleItemsActionProps) => { + const { t } = useTranslation(); + const player = usePlayer(); + + const handleShuffleSelected = useCallback(() => { + player.shuffleSelected(items); + }, [items, player]); + + const handleShuffleAll = useCallback(() => { + player.shuffleAll(); + }, [player]); + + return ( + + + e.preventDefault()} + rightIcon="arrowRightS" + > + {t('action.shuffle', { postProcess: 'sentenceCase' })} + + + + + {t('action.shuffleSelected', { postProcess: 'sentenceCase' })} + + + {t('action.shuffleAll', { postProcess: 'sentenceCase' })} + + + + ); +}; diff --git a/src/renderer/features/context-menu/context-menu-controller.tsx b/src/renderer/features/context-menu/context-menu-controller.tsx new file mode 100644 index 000000000..60a5f6782 --- /dev/null +++ b/src/renderer/features/context-menu/context-menu-controller.tsx @@ -0,0 +1,142 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; +import { createCallable } from 'react-call'; +import { useParams } from 'react-router'; + +import { AlbumArtistContextMenu } from '/@/renderer/features/context-menu/menus/album-artist-context-menu'; +import { AlbumContextMenu } from '/@/renderer/features/context-menu/menus/album-context-menu'; +import { ArtistContextMenu } from '/@/renderer/features/context-menu/menus/artist-context-menu'; +import { GenreContextMenu } from '/@/renderer/features/context-menu/menus/genre-context-menu'; +import { PlaylistContextMenu } from '/@/renderer/features/context-menu/menus/playlist-context-menu'; +import { PlaylistSongContextMenu } from '/@/renderer/features/context-menu/menus/playlist-song-context-menu'; +import { QueueContextMenu } from '/@/renderer/features/context-menu/menus/queue-context-menu'; +import { SongContextMenu } from '/@/renderer/features/context-menu/menus/song-context-menu'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { + Album, + AlbumArtist, + Artist, + Genre, + LibraryItem, + Playlist, + QueueSong, + Song, +} from '/@/shared/types/domain-types'; + +interface ContextMenuControllerProps { + cmd: ContextMenuCommand; + event: React.MouseEvent; +} + +export const ContextMenuController = createCallable( + ({ call, cmd, event }) => { + const { libraryId } = useParams() as { libraryId: string }; + const queryClient = useQueryClient(); + + const triggerRef = useRef(null); + const isExecuted = useRef(false); + + useEffect(() => { + if (isExecuted.current) { + return; + } + + if (!triggerRef.current) { + return; + } + + const handleContextMenu = () => { + event.preventDefault(); + + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + clientX: event.clientX, + clientY: event.clientY, + }), + ); + }; + + isExecuted.current = true; + + handleContextMenu(); + }, [call, cmd, event, event.clientX, event.clientY, libraryId, queryClient]); + + return ( + + +
+ + {cmd.type === LibraryItem.QUEUE_SONG && } + {cmd.type === LibraryItem.ALBUM && } + {cmd.type === LibraryItem.ALBUM_ARTIST && } + {cmd.type === LibraryItem.ARTIST && } + {cmd.type === LibraryItem.GENRE && } + {cmd.type === LibraryItem.PLAYLIST && } + {cmd.type === LibraryItem.PLAYLIST_SONG && } + {cmd.type === LibraryItem.SONG && } + + ); + }, +); + +export type ContextMenuCommand = + | AlbumArtistContextMenuProps + | AlbumContextMenuProps + | ArtistContextMenuProps + | GenreContextMenuProps + | PlaylistContextMenuProps + | PlaylistSongContextMenuProps + | QueueSongContextMenuProps + | SongContextMenuProps; + +type AlbumArtistContextMenuProps = { + items: AlbumArtist[]; + type: LibraryItem.ALBUM_ARTIST; +}; + +type AlbumContextMenuProps = { + items: Album[]; + type: LibraryItem.ALBUM; +}; + +type ArtistContextMenuProps = { + items: Artist[]; + type: LibraryItem.ARTIST; +}; + +type GenreContextMenuProps = { + items: Genre[]; + type: LibraryItem.GENRE; +}; + +type PlaylistContextMenuProps = { + items: Playlist[]; + type: LibraryItem.PLAYLIST; +}; + +type PlaylistSongContextMenuProps = { + items: Song[]; + type: LibraryItem.PLAYLIST_SONG; +}; + +type QueueSongContextMenuProps = { + items: QueueSong[]; + type: LibraryItem.QUEUE_SONG; +}; + +type SongContextMenuProps = { + items: Song[]; + type: LibraryItem.SONG; +}; diff --git a/src/renderer/features/context-menu/context-menu-items.tsx b/src/renderer/features/context-menu/context-menu-items.tsx index 2b16572c7..f93b5f0f5 100644 --- a/src/renderer/features/context-menu/context-menu-items.tsx +++ b/src/renderer/features/context-menu/context-menu-items.tsx @@ -1,123 +1,364 @@ -import { SetContextMenuItems } from '/@/renderer/features/context-menu/events'; +import React from 'react'; -export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ - { divider: true, id: 'removeFromQueue' }, - { id: 'moveToNextOfQueue' }, - { id: 'moveToBottomOfQueue' }, - { divider: true, id: 'moveToTopOfQueue' }, - { divider: true, id: 'addToPlaylist' }, - { id: 'addToFavorites' }, - { divider: true, id: 'removeFromFavorites' }, - { children: true, disabled: false, id: 'setRating' }, - { disabled: false, divider: true, id: 'deselectAll' }, - { id: 'download' }, - { divider: true, id: 'shareItem' }, - { id: 'goToAlbum' }, - { id: 'goToAlbumArtist' }, - { divider: true, id: 'showDetails' }, -]; +import { AppIconSelection } from '/@/shared/components/icon/icon'; -export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ - { id: 'play' }, - { id: 'playLast' }, - { id: 'playNext' }, - { id: 'playShuffled' }, - { divider: true, id: 'playSimilarSongs' }, - { divider: true, id: 'addToPlaylist' }, - { id: 'addToFavorites' }, - { divider: true, id: 'removeFromFavorites' }, - { children: true, disabled: false, divider: true, id: 'setRating' }, - { id: 'download' }, - { divider: true, id: 'shareItem' }, - { id: 'goToAlbum' }, - { id: 'goToAlbumArtist' }, - { divider: true, id: 'showDetails' }, -]; +export enum ContextMenuItemKey { + ADD_TO_FAVORITES = 'addToFavorites', + ADD_TO_PLAYLIST = 'addToPlaylist', + CREATE_PLAYLIST = 'createPlaylist', + DELETE_PLAYLIST = 'deletePlaylist', + DESELECT_ALL = 'deselectAll', + DOWNLOAD = 'download', + GO_TO_ALBUM = 'goToAlbum', + GO_TO_ALBUM_ARTIST = 'goToAlbumArtist', + MOVE_TO_BOTTOM_OF_QUEUE = 'moveToBottomOfQueue', + MOVE_TO_NEXT_OF_QUEUE = 'moveToNextOfQueue', + MOVE_TO_TOP_OF_QUEUE = 'moveToTopOfQueue', + PLAY = 'play', + PLAY_LAST = 'playLast', + PLAY_NEXT = 'playNext', + PLAY_SHUFFLED = 'playShuffled', + PLAY_SIMILAR_SONGS = 'playSimilarSongs', + REMOVE_FROM_FAVORITES = 'removeFromFavorites', + REMOVE_FROM_PLAYLIST = 'removeFromPlaylist', + REMOVE_FROM_QUEUE = 'removeFromQueue', + SET_RATING = 'setRating', + SET_RATING_1 = 'setRating1', + SET_RATING_2 = 'setRating2', + SET_RATING_3 = 'setRating3', + SET_RATING_4 = 'setRating4', + SET_RATING_5 = 'setRating5', + SHARE_ITEM = 'shareItem', + SHOW_DETAILS = 'showDetails', +} -export const SONG_ALBUM_PAGE: SetContextMenuItems = [ - { id: 'play' }, - { id: 'playLast' }, - { id: 'playNext' }, - { divider: true, id: 'playShuffled' }, - { divider: true, id: 'addToPlaylist' }, -]; +export type ContextMenuHandlers = Partial void>>; -export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ - { id: 'play' }, - { id: 'playLast' }, - { id: 'playNext' }, - { id: 'playShuffled' }, - { divider: true, id: 'playSimilarSongs' }, - { id: 'addToPlaylist' }, - { divider: true, id: 'removeFromPlaylist' }, - { id: 'addToFavorites' }, - { divider: true, id: 'removeFromFavorites' }, - { children: true, disabled: false, id: 'setRating' }, - { id: 'download' }, - { divider: true, id: 'shareItem' }, - { id: 'goToAlbum' }, - { id: 'goToAlbumArtist' }, - { divider: true, id: 'showDetails' }, -]; +export interface ContextMenuItem { + disabled?: boolean; + hidden?: boolean; + icon?: React.ReactNode; + items?: ContextMenuItem[]; + key: string; + onClick?: () => void; +} -export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ - { id: 'play' }, - { id: 'playLast' }, - { id: 'playNext' }, - { divider: true, id: 'playShuffled' }, - { divider: true, id: 'playSimilarSongs' }, - { divider: true, id: 'addToPlaylist' }, - { id: 'addToFavorites' }, - { divider: true, id: 'removeFromFavorites' }, - { children: true, disabled: false, id: 'setRating' }, - { id: 'download' }, - { divider: true, id: 'shareItem' }, - { id: 'goToAlbum' }, - { id: 'goToAlbumArtist' }, - { divider: true, id: 'showDetails' }, -]; +export type ContextMenuItemDefinition = { + children?: ContextMenuItemDefinition[]; + disabled?: boolean; + key: ContextMenuItemKeys; +}; -export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ - { id: 'play' }, - { id: 'playLast' }, - { id: 'playNext' }, - { divider: true, id: 'playShuffled' }, - { divider: true, id: 'addToPlaylist' }, - { id: 'addToFavorites' }, - { id: 'removeFromFavorites' }, - { children: true, disabled: false, divider: true, id: 'setRating' }, - { divider: true, id: 'shareItem' }, - { id: 'goToAlbumArtist' }, - { divider: true, id: 'showDetails' }, -]; +export type ContextMenuItemKeys = + | ContextMenuItemKey.ADD_TO_FAVORITES + | ContextMenuItemKey.ADD_TO_PLAYLIST + | ContextMenuItemKey.CREATE_PLAYLIST + | ContextMenuItemKey.DELETE_PLAYLIST + | ContextMenuItemKey.DESELECT_ALL + | ContextMenuItemKey.DOWNLOAD + | ContextMenuItemKey.GO_TO_ALBUM + | ContextMenuItemKey.GO_TO_ALBUM_ARTIST + | ContextMenuItemKey.MOVE_TO_BOTTOM_OF_QUEUE + | ContextMenuItemKey.MOVE_TO_NEXT_OF_QUEUE + | ContextMenuItemKey.MOVE_TO_TOP_OF_QUEUE + | ContextMenuItemKey.PLAY + | ContextMenuItemKey.PLAY_LAST + | ContextMenuItemKey.PLAY_NEXT + | ContextMenuItemKey.PLAY_SHUFFLED + | ContextMenuItemKey.PLAY_SIMILAR_SONGS + | ContextMenuItemKey.REMOVE_FROM_FAVORITES + | ContextMenuItemKey.REMOVE_FROM_PLAYLIST + | ContextMenuItemKey.REMOVE_FROM_QUEUE + | ContextMenuItemKey.SET_RATING + | ContextMenuItemKey.SET_RATING_1 + | ContextMenuItemKey.SET_RATING_2 + | ContextMenuItemKey.SET_RATING_3 + | ContextMenuItemKey.SET_RATING_4 + | ContextMenuItemKey.SET_RATING_5 + | ContextMenuItemKey.SHARE_ITEM + | ContextMenuItemKey.SHOW_DETAILS; -export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ - { id: 'play' }, - { id: 'playLast' }, - { id: 'playNext' }, - { divider: true, id: 'playShuffled' }, - { divider: true, id: 'addToPlaylist' }, -]; +export type ContextMenuItems = Array; -export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ - { id: 'play' }, - { id: 'playLast' }, - { id: 'playNext' }, - { divider: true, id: 'playShuffled' }, - { divider: true, id: 'addToPlaylist' }, - { id: 'addToFavorites' }, - { divider: true, id: 'removeFromFavorites' }, - { children: true, disabled: false, id: 'setRating' }, - { divider: true, id: 'shareItem' }, - { divider: true, id: 'showDetails' }, -]; +const ICON_MAP: Partial> = { + [ContextMenuItemKey.ADD_TO_FAVORITES]: 'favorite', + [ContextMenuItemKey.ADD_TO_PLAYLIST]: 'playlistAdd', + [ContextMenuItemKey.DELETE_PLAYLIST]: 'playlistDelete', + [ContextMenuItemKey.DESELECT_ALL]: 'remove', + [ContextMenuItemKey.DOWNLOAD]: 'download', + [ContextMenuItemKey.GO_TO_ALBUM]: 'album', + [ContextMenuItemKey.GO_TO_ALBUM_ARTIST]: 'artist', + [ContextMenuItemKey.MOVE_TO_BOTTOM_OF_QUEUE]: 'arrowDownToLine', + [ContextMenuItemKey.MOVE_TO_NEXT_OF_QUEUE]: 'mediaPlayNext', + [ContextMenuItemKey.MOVE_TO_TOP_OF_QUEUE]: 'arrowUpToLine', + [ContextMenuItemKey.PLAY]: 'mediaPlay', + [ContextMenuItemKey.PLAY_LAST]: 'mediaPlayLast', + [ContextMenuItemKey.PLAY_NEXT]: 'mediaPlayNext', + [ContextMenuItemKey.PLAY_SHUFFLED]: 'mediaShuffle', + [ContextMenuItemKey.PLAY_SIMILAR_SONGS]: 'radio', + [ContextMenuItemKey.REMOVE_FROM_FAVORITES]: 'unfavorite', + [ContextMenuItemKey.REMOVE_FROM_PLAYLIST]: 'playlistDelete', + [ContextMenuItemKey.REMOVE_FROM_QUEUE]: 'delete', + [ContextMenuItemKey.SET_RATING]: 'star', + [ContextMenuItemKey.SHARE_ITEM]: 'share', + [ContextMenuItemKey.SHOW_DETAILS]: 'info', +}; -export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ - { id: 'play' }, - { id: 'playLast' }, - { id: 'playNext' }, - { divider: true, id: 'playShuffled' }, - { divider: true, id: 'shareItem' }, - { divider: true, id: 'deletePlaylist' }, - { divider: true, id: 'showDetails' }, -]; +// export const convertToContextMenuItems = ( +// definitions: ContextMenuItemDefinition[], +// handlers: ContextMenuHandlers, +// ): ContextMenuItemOptions[] => { +// const items: ContextMenuItemOptions[] = []; + +// for (const def of definitions) { +// if ('divider' in def && def.divider) { +// items.push({ key: 'divider' }); +// continue; +// } + +// if (!('key' in def)) { +// continue; +// } + +// const handler = handlers[def.key]; + +// if (!handler) { +// continue; +// } + +// const icon = ICON_MAP[def.key]; +// const menuItem: ContextMenuItemOptions = { +// disabled: def.disabled, +// icon: icon ? : undefined, +// key: def.key, +// onClick: handler, +// }; + +// if (def.children) { +// menuItem.items = undefined; +// } + +// items.push(menuItem); +// } + +// // Remove trailing divider +// const lastItem = items[items.length - 1]; +// if (items.length > 0 && lastItem && 'type' in lastItem && lastItem.type === 'divider') { +// items.pop(); +// } + +// return items; +// }; + +// export const QUEUE_CONTEXT_MENU_ITEMS = (): ContextMenuItemOptions[] => { +// return [ +// { key: ContextMenuItemKey.REMOVE_FROM_QUEUE }, +// // { key: ContextMenuItemKey.MOVE_TO_NEXT_OF_QUEUE }, +// // { key: ContextMenuItemKey.MOVE_TO_BOTTOM_OF_QUEUE }, +// // { key: 'divider_1' }, +// // { key: ContextMenuItemKey.MOVE_TO_TOP_OF_QUEUE }, +// // { key: 'divider_2' }, +// // { key: ContextMenuItemKey.ADD_TO_PLAYLIST }, +// // { key: ContextMenuItemKey.ADD_TO_FAVORITES }, +// // { key: 'divider_3' }, +// // { key: ContextMenuItemKey.REMOVE_FROM_FAVORITES }, +// // { key: ContextMenuItemKey.SET_RATING }, +// // { key: ContextMenuItemKey.DESELECT_ALL }, +// // { key: 'divider_4' }, +// // { key: ContextMenuItemKey.DOWNLOAD }, +// // { key: 'divider_5' }, +// // { key: ContextMenuItemKey.SHARE_ITEM }, +// // { key: ContextMenuItemKey.GO_TO_ALBUM }, +// // { key: ContextMenuItemKey.GO_TO_ALBUM_ARTIST }, +// // { key: 'divider_6' }, +// // { key: ContextMenuItemKey.SHOW_DETAILS }, +// ]; +// }; + +// export const SONG_CONTEXT_MENU_ITEMS: ContextMenuItemDefinition[] = [ +// { +// children: [ +// { key: ContextMenuItemKey.PLAY_LAST }, +// { key: ContextMenuItemKey.PLAY_NEXT }, +// { key: ContextMenuItemKey.PLAY_SHUFFLED }, +// ], +// key: ContextMenuItemKey.PLAY, +// }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.PLAY_SIMILAR_SONGS }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.ADD_TO_PLAYLIST }, +// { key: ContextMenuItemKey.ADD_TO_FAVORITES }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.REMOVE_FROM_FAVORITES }, +// { +// children: [ +// { key: ContextMenuItemKey.SET_RATING_1 }, +// { key: ContextMenuItemKey.SET_RATING_2 }, +// { key: ContextMenuItemKey.SET_RATING_3 }, +// { key: ContextMenuItemKey.SET_RATING_4 }, +// { key: ContextMenuItemKey.SET_RATING_5 }, +// ], +// key: ContextMenuItemKey.SET_RATING, +// }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.DOWNLOAD }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHARE_ITEM }, +// { key: ContextMenuItemKey.GO_TO_ALBUM }, +// { key: ContextMenuItemKey.GO_TO_ALBUM_ARTIST }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHOW_DETAILS }, +// ]; + +// export const SONG_ALBUM_PAGE: ContextMenuItemDefinition[] = [ +// { key: ContextMenuItemKey.PLAY }, +// { key: ContextMenuItemKey.PLAY_LAST }, +// { key: ContextMenuItemKey.PLAY_NEXT }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.PLAY_SHUFFLED }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.ADD_TO_PLAYLIST }, +// ]; + +// export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: ContextMenuItemDefinition[] = [ +// { key: ContextMenuItemKey.PLAY }, +// { key: ContextMenuItemKey.PLAY_LAST }, +// { key: ContextMenuItemKey.PLAY_NEXT }, +// { key: ContextMenuItemKey.PLAY_SHUFFLED }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.PLAY_SIMILAR_SONGS }, +// { key: ContextMenuItemKey.ADD_TO_PLAYLIST }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.REMOVE_FROM_PLAYLIST }, +// { key: ContextMenuItemKey.ADD_TO_FAVORITES }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.REMOVE_FROM_FAVORITES }, +// { +// children: [ +// { key: ContextMenuItemKey.SET_RATING_1 }, +// { key: ContextMenuItemKey.SET_RATING_2 }, +// { key: ContextMenuItemKey.SET_RATING_3 }, +// { key: ContextMenuItemKey.SET_RATING_4 }, +// { key: ContextMenuItemKey.SET_RATING_5 }, +// ], +// key: ContextMenuItemKey.SET_RATING, +// }, +// { key: ContextMenuItemKey.DOWNLOAD }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHARE_ITEM }, +// { key: ContextMenuItemKey.GO_TO_ALBUM }, +// { key: ContextMenuItemKey.GO_TO_ALBUM_ARTIST }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHOW_DETAILS }, +// ]; + +// export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: ContextMenuItemDefinition[] = [ +// { key: ContextMenuItemKey.PLAY }, +// { key: ContextMenuItemKey.PLAY_LAST }, +// { key: ContextMenuItemKey.PLAY_NEXT }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.PLAY_SHUFFLED }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.PLAY_SIMILAR_SONGS }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.ADD_TO_PLAYLIST }, +// { key: ContextMenuItemKey.ADD_TO_FAVORITES }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.REMOVE_FROM_FAVORITES }, +// { +// children: [ +// { key: ContextMenuItemKey.SET_RATING_1 }, +// { key: ContextMenuItemKey.SET_RATING_2 }, +// { key: ContextMenuItemKey.SET_RATING_3 }, +// { key: ContextMenuItemKey.SET_RATING_4 }, +// { key: ContextMenuItemKey.SET_RATING_5 }, +// ], +// key: ContextMenuItemKey.SET_RATING, +// }, +// { key: ContextMenuItemKey.DOWNLOAD }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHARE_ITEM }, +// { key: ContextMenuItemKey.GO_TO_ALBUM }, +// { key: ContextMenuItemKey.GO_TO_ALBUM_ARTIST }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHOW_DETAILS }, +// ]; + +// export const ALBUM_CONTEXT_MENU_ITEMS: ContextMenuItemDefinition[] = [ +// { key: ContextMenuItemKey.PLAY }, +// { key: ContextMenuItemKey.PLAY_LAST }, +// { key: ContextMenuItemKey.PLAY_NEXT }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.PLAY_SHUFFLED }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.ADD_TO_PLAYLIST }, +// { key: ContextMenuItemKey.ADD_TO_FAVORITES }, +// { key: ContextMenuItemKey.REMOVE_FROM_FAVORITES }, +// { +// children: [ +// { key: ContextMenuItemKey.SET_RATING_1 }, +// { key: ContextMenuItemKey.SET_RATING_2 }, +// { key: ContextMenuItemKey.SET_RATING_3 }, +// { key: ContextMenuItemKey.SET_RATING_4 }, +// { key: ContextMenuItemKey.SET_RATING_5 }, +// ], +// key: ContextMenuItemKey.SET_RATING, +// }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHARE_ITEM }, +// { key: ContextMenuItemKey.GO_TO_ALBUM_ARTIST }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHOW_DETAILS }, +// ]; + +// export const GENRE_CONTEXT_MENU_ITEMS: ContextMenuItemDefinition[] = [ +// { key: ContextMenuItemKey.PLAY }, +// { key: ContextMenuItemKey.PLAY_LAST }, +// { key: ContextMenuItemKey.PLAY_NEXT }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.PLAY_SHUFFLED }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.ADD_TO_PLAYLIST }, +// ]; + +// export const ARTIST_CONTEXT_MENU_ITEMS: ContextMenuItemDefinition[] = [ +// { key: ContextMenuItemKey.PLAY }, +// { key: ContextMenuItemKey.PLAY_LAST }, +// { key: ContextMenuItemKey.PLAY_NEXT }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.PLAY_SHUFFLED }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.ADD_TO_PLAYLIST }, +// { key: ContextMenuItemKey.ADD_TO_FAVORITES }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.REMOVE_FROM_FAVORITES }, +// { +// children: [ +// { key: ContextMenuItemKey.SET_RATING_1 }, +// { key: ContextMenuItemKey.SET_RATING_2 }, +// { key: ContextMenuItemKey.SET_RATING_3 }, +// { key: ContextMenuItemKey.SET_RATING_4 }, +// { key: ContextMenuItemKey.SET_RATING_5 }, +// ], +// key: ContextMenuItemKey.SET_RATING, +// }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHARE_ITEM }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHOW_DETAILS }, +// ]; + +// export const PLAYLIST_CONTEXT_MENU_ITEMS: ContextMenuItemDefinition[] = [ +// { key: ContextMenuItemKey.PLAY }, +// { key: ContextMenuItemKey.PLAY_LAST }, +// { key: ContextMenuItemKey.PLAY_NEXT }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.PLAY_SHUFFLED }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHARE_ITEM }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.DELETE_PLAYLIST }, +// { key: ContextMenuItemKey.DIVIDER }, +// { key: ContextMenuItemKey.SHOW_DETAILS }, +// ]; diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx deleted file mode 100644 index 07a44c29f..000000000 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ /dev/null @@ -1,1048 +0,0 @@ -// import { RowNode } from '@ag-grid-community/core'; -// import { -// useClickOutside, -// useMergedRef, -// useResizeObserver, -// useSetState, -// useViewportSize, -// } from '@mantine/hooks'; -// import { closeAllModals, openContextModal, openModal } from '@mantine/modals'; -// import isElectron from 'is-electron'; -// import { AnimatePresence } from 'motion/react'; -// import { -// createContext, -// Fragment, -// ReactNode, -// useCallback, -// useEffect, -// useMemo, -// useState, -// } from 'react'; -// import { useTranslation } from 'react-i18next'; -// import { generatePath, useNavigate } from 'react-router-dom'; - -// import { api } from '/@/renderer/api'; -// import { controller } from '/@/renderer/api/controller'; -// import { ContextMenu, ContextMenuButton } from '/@/renderer/components/context-menu/context-menu'; -// import { -// ContextMenuItemType, -// OpenContextMenuProps, -// useContextMenuEvents, -// } from '/@/renderer/features/context-menu/events'; -// import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal'; -// import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; -// import { updateSong } from '/@/renderer/features/player/update-remote-song'; -// import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; -// import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation'; -// import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; -// import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; -// import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation'; -// import { AppRoute } from '/@/renderer/router/routes'; -// import { -// getServerById, -// useAuthStore, -// useCurrentServer, -// usePlayerStore, -// useSettingsStore, -// } from '/@/renderer/store'; -// import { usePlaybackType } from '/@/renderer/store/settings.store'; -// import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data'; -// import { hasFeature } from '/@/shared/api/utils'; -// import { Divider } from '/@/shared/components/divider/divider'; -// import { Group } from '/@/shared/components/group/group'; -// import { HoverCard } from '/@/shared/components/hover-card/hover-card'; -// import { Icon } from '/@/shared/components/icon/icon'; -// import { ConfirmModal } from '/@/shared/components/modal/modal'; -// import { Portal } from '/@/shared/components/portal/portal'; -// import { Rating } from '/@/shared/components/rating/rating'; -// import { Stack } from '/@/shared/components/stack/stack'; -// import { Text } from '/@/shared/components/text/text'; -// import { toast } from '/@/shared/components/toast/toast'; -// import { -// AnyLibraryItem, -// AnyLibraryItems, -// LibraryItem, -// ServerType, -// } from '/@/shared/types/domain-types'; -// import { ServerFeature } from '/@/shared/types/features-types'; -// import { Play, PlaybackType } from '/@/shared/types/types'; - -// type ContextMenuContextProps = { -// closeContextMenu: () => void; -// openContextMenu: (args: OpenContextMenuProps) => void; -// }; - -// type ContextMenuItem = { -// children?: ContextMenuItem[]; -// disabled?: boolean; -// id: string; -// label: ReactNode | string; -// leftIcon?: ReactNode; -// onClick?: (...args: any) => any; -// rightIcon?: ReactNode; -// }; - -// const ContextMenuContext = createContext({ -// closeContextMenu: () => {}, -// openContextMenu: (args: OpenContextMenuProps) => { -// return args; -// }, -// }); - -// const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareItem']; -// // const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = []; -// // const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = []; - -// const utils = isElectron() ? window.api.utils : null; - -// export interface ContextMenuProviderProps { -// children: ReactNode; -// } - -// export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { -// const disabledItems = useSettingsStore((state) => state.general.disabledContextMenu); -// const { t } = useTranslation(); -// const [opened, setOpened] = useState(false); - -// const clickOutsideRef = useClickOutside(() => setOpened(false), ['mousedown', 'touchstart']); - -// const viewport = useViewportSize(); -// const server = useCurrentServer(); -// const serverType = server?.type; -// const [ref, menuRect] = useResizeObserver(); -// const [ctx, setCtx] = useSetState({ -// data: [], -// dataNodes: [], -// menuItems: [], -// resetGridCache: undefined, -// tableApi: undefined, -// type: LibraryItem.SONG, -// xPos: 0, -// yPos: 0, -// }); - -// const [rating, setRating] = useState(0); - -// useEffect(() => { -// if (opened && ctx.data.length > 0) { -// if (ctx.data.length === 1) { -// setRating(ctx.data[0].userRating ?? 0); -// } else { -// const firstRating = ctx.data[0].userRating ?? 0; -// const allSameRating = ctx.data.every( -// (item) => (item.userRating ?? 0) === firstRating, -// ); -// setRating(allSameRating ? firstRating : 0); -// } -// } else { -// setRating(0); -// } -// }, [ctx.data, opened]); - -// const handlePlayQueueAdd = usePlayQueueAdd(); -// const navigate = useNavigate(); - -// const openContextMenu = useCallback( -// (args: OpenContextMenuProps) => { -// const { -// context, -// data, -// dataNodes, -// menuItems, -// resetGridCache, -// tableApi, -// type, -// xPos, -// yPos, -// } = args; - -// const serverType = data[0]?.serverType || useAuthStore.getState().currentServer?.type; -// let validMenuItems = menuItems.filter((item) => !disabledItems[item.id]); - -// if (serverType === ServerType.JELLYFIN) { -// validMenuItems = menuItems.filter( -// (item) => !JELLYFIN_IGNORED_MENU_ITEMS.includes(item.id), -// ); -// } - -// // If the context menu dimension can't be automatically calculated, calculate it manually -// // This is a hacky way since resize observer may not automatically recalculate when not rendered -// const menuHeight = menuRect.height || (validMenuItems.length + 1) * 40; -// const menuWidth = menuRect.width || 220; - -// const shouldReverseY = yPos + menuHeight > viewport.height; -// const shouldReverseX = xPos + menuWidth > viewport.width; - -// const calculatedXPos = shouldReverseX ? xPos - menuWidth : xPos; -// const calculatedYPos = shouldReverseY ? yPos - menuHeight : yPos; - -// setCtx({ -// context, -// data, -// dataNodes, -// menuItems: validMenuItems, -// resetGridCache, -// tableApi, -// type, -// xPos: calculatedXPos, -// yPos: calculatedYPos, -// }); -// setOpened(true); -// }, -// [disabledItems, menuRect.height, menuRect.width, setCtx, viewport.height, viewport.width], -// ); - -// const closeContextMenu = useCallback(() => { -// setOpened(false); -// setCtx({ -// data: [], -// dataNodes: [], -// menuItems: [], -// tableApi: undefined, -// type: LibraryItem.SONG, -// xPos: 0, -// yPos: 0, -// }); -// }, [setCtx]); - -// useContextMenuEvents({ -// closeContextMenu, -// openContextMenu, -// }); - -// const handlePlay = useCallback( -// (playType: Play) => { -// switch (ctx.type) { -// case LibraryItem.ALBUM: -// handlePlayQueueAdd?.({ -// byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, -// playType, -// }); -// break; -// case LibraryItem.ALBUM_ARTIST: -// handlePlayQueueAdd?.({ -// byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, -// playType, -// }); -// break; -// case LibraryItem.ARTIST: -// handlePlayQueueAdd?.({ -// byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, -// playType, -// }); -// break; -// case LibraryItem.GENRE: -// handlePlayQueueAdd?.({ -// byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, -// playType, -// }); -// break; -// case LibraryItem.PLAYLIST: -// for (const item of ctx.data) { -// handlePlayQueueAdd?.({ -// byItemType: { id: [item.id], type: ctx.type }, -// playType, -// }); -// } - -// break; -// case LibraryItem.SONG: -// handlePlayQueueAdd?.({ byData: ctx.data, playType }); -// break; -// } -// }, -// [ctx.data, ctx.type, handlePlayQueueAdd], -// ); - -// const deletePlaylistMutation = useDeletePlaylist({}); - -// const handleDeletePlaylist = useCallback(() => { -// for (const item of ctx.data) { -// deletePlaylistMutation?.mutate( -// { query: { id: item.id }, serverId: item.serverId }, -// { -// onError: (err) => { -// toast.error({ -// message: err.message, -// title: t('error.genericError', { postProcess: 'sentenceCase' }), -// }); -// }, -// onSuccess: () => { -// toast.success({ -// message: `Playlist has been deleted`, -// }); - -// ctx.tableApi?.refreshInfiniteCache(); -// ctx.resetGridCache?.(); -// }, -// }, -// ); -// } - -// closeAllModals(); -// }, [ctx, deletePlaylistMutation, t]); - -// const openDeletePlaylistModal = useCallback(() => { -// openModal({ -// children: ( -// -// -// {t('common.areYouSure', { postProcess: 'sentenceCase' })} -//
    -// {ctx.data.map((item) => ( -//
  • -// -// —{item.name} -// -//
  • -// ))} -//
-//
-//
-// ), -// title: t('page.contextMenu.deletePlaylist', { postProcess: 'titleCase' }), -// }); -// }, [ctx.data, handleDeletePlaylist, t]); - -// const createFavoriteMutation = useCreateFavorite({}); -// const deleteFavoriteMutation = useDeleteFavorite({}); -// const handleAddToFavorites = useCallback(() => { -// if (!ctx.dataNodes && !ctx.data) return; - -// if (ctx.dataNodes) { -// const nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite); - -// const nodesByServerId = nodesToFavorite.reduce( -// (acc, node) => { -// if (!acc[node.data.serverId]) { -// acc[node.data.serverId] = []; -// } -// acc[node.data.serverId].push(node); -// return acc; -// }, -// {} as Record[]>, -// ); - -// for (const serverId of Object.keys(nodesByServerId)) { -// const nodes = nodesByServerId[serverId]; -// const items = nodes.map((node) => node.data); - -// createFavoriteMutation.mutate( -// { -// query: { -// id: items.map((item) => item.id), -// type: ctx.type, -// }, -// serverId, -// }, -// { -// onError: (err) => { -// toast.error({ -// message: err.message, -// title: t('error.genericError', { postProcess: 'sentenceCase' }), -// }); -// }, -// onSuccess: () => { -// for (const node of nodes) { -// node.setData({ ...node.data, userFavorite: true }); -// } -// }, -// }, -// ); -// } -// } else { -// const itemsToFavorite = ctx.data.filter((item) => !item.userFavorite); -// const itemsByServerId = (itemsToFavorite as any[]).reduce( -// (acc, item) => { -// if (!acc[item.serverId]) { -// acc[item.serverId] = []; -// } -// acc[item.serverId].push(item); -// return acc; -// }, -// {} as Record, -// ); - -// for (const serverId of Object.keys(itemsByServerId)) { -// const items = itemsByServerId[serverId]; - -// createFavoriteMutation.mutate( -// { -// query: { -// id: items.map((item: AnyLibraryItem) => item.id), -// type: ctx.type, -// }, -// serverId, -// }, -// { -// onError: (err) => { -// toast.error({ -// message: err.message, -// title: t('error.genericError', { postProcess: 'sentenceCase' }), -// }); -// }, -// }, -// ); -// } -// } -// }, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type, t]); - -// const handleRemoveFromFavorites = useCallback(() => { -// if (!ctx.dataNodes && !ctx.data) return; - -// if (ctx.dataNodes) { -// const nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite); -// const nodesByServerId = nodesToUnfavorite.reduce( -// (acc, node) => { -// if (!acc[node.data.serverId]) { -// acc[node.data.serverId] = []; -// } -// acc[node.data.serverId].push(node); -// return acc; -// }, -// {} as Record[]>, -// ); - -// for (const serverId of Object.keys(nodesByServerId)) { -// const idsToUnfavorite = nodesByServerId[serverId].map((node) => node.data.id); -// deleteFavoriteMutation.mutate( -// { -// query: { -// id: idsToUnfavorite, -// type: ctx.type, -// }, -// serverId, -// }, -// { -// onSuccess: () => { -// for (const node of nodesToUnfavorite) { -// node.setData({ ...node.data, userFavorite: false }); -// } -// }, -// }, -// ); -// } -// } else { -// const itemsToUnfavorite = ctx.data.filter((item) => item.userFavorite); -// const itemsByServerId = (itemsToUnfavorite as any[]).reduce( -// (acc, item) => { -// if (!acc[item.serverId]) { -// acc[item.serverId] = []; -// } -// acc[item.serverId].push(item); -// return acc; -// }, -// {} as Record, -// ); - -// for (const serverId of Object.keys(itemsByServerId)) { -// const idsToUnfavorite = itemsByServerId[serverId].map( -// (item: AnyLibraryItem) => item.id, -// ); -// deleteFavoriteMutation.mutate({ -// query: { -// id: idsToUnfavorite, -// type: ctx.type, -// }, -// serverId, -// }); -// } -// } -// }, [ctx.data, ctx.dataNodes, ctx.type, deleteFavoriteMutation]); - -// const handleAddToPlaylist = useCallback(() => { -// if (!ctx.dataNodes && !ctx.data) return; - -// const albumId: string[] = []; -// const artistId: string[] = []; -// const songId: string[] = []; -// const genreId: string[] = []; - -// if (ctx.dataNodes) { -// for (const node of ctx.dataNodes) { -// switch (node.data.itemType) { -// case LibraryItem.ALBUM: -// albumId.push(node.data.id); -// break; -// case LibraryItem.ARTIST: -// artistId.push(node.data.id); -// break; -// case LibraryItem.GENRE: -// genreId.push(node.data.id); -// break; -// case LibraryItem.SONG: -// songId.push(node.data.id); -// break; -// } -// } -// } else { -// for (const item of ctx.data) { -// switch (item.itemType) { -// case LibraryItem.ALBUM: -// albumId.push(item.id); -// break; -// case LibraryItem.ALBUM_ARTIST: -// artistId.push(item.id); -// break; -// case LibraryItem.ARTIST: -// artistId.push(item.id); -// break; -// case LibraryItem.GENRE: -// genreId.push(item.id); -// break; -// case LibraryItem.SONG: -// songId.push(item.id); -// break; -// } -// } -// } - -// openContextModal({ -// innerProps: { -// albumId: albumId.length > 0 ? albumId : undefined, -// artistId: artistId.length > 0 ? artistId : undefined, -// genreId: genreId.length > 0 ? genreId : undefined, -// songId: songId.length > 0 ? songId : undefined, -// }, -// modal: 'addToPlaylist', -// size: 'lg', -// title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }), -// }); -// }, [ctx.data, ctx.dataNodes, t]); - -// const removeFromPlaylistMutation = useRemoveFromPlaylist(); - -// const handleRemoveFromPlaylist = useCallback(() => { -// let songId: string[] | undefined; - -// switch (serverType) { -// case ServerType.JELLYFIN: -// case ServerType.NAVIDROME: -// songId = ctx.dataNodes?.map((node) => node.data.playlistItemId); -// break; -// case ServerType.SUBSONIC: -// songId = ctx.dataNodes?.map((node) => node.rowIndex!.toString()); -// break; -// } - -// const confirm = () => { -// removeFromPlaylistMutation.mutate( -// { -// query: { -// id: ctx.context.playlistId, -// songId: songId || [], -// }, -// serverId: ctx.data?.[0]?.serverId, -// }, -// { -// onError: (err) => { -// toast.error({ -// message: err.message, -// title: t('error.genericError', { postProcess: 'sentenceCase' }), -// }); -// }, -// onSuccess: () => { -// closeAllModals(); -// }, -// }, -// ); -// }; - -// openModal({ -// children: ( -// -// {t('common.areYouSure', { postProcess: 'sentenceCase' })} -// -// ), -// title: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }), -// }); -// }, [ -// ctx.context?.playlistId, -// ctx.data, -// ctx.dataNodes, -// removeFromPlaylistMutation, -// serverType, -// t, -// ]); - -// const updateRatingMutation = useSetRating({}); - -// const handleUpdateRating = useCallback( -// (newRating: number) => { -// if (!ctx.dataNodes && !ctx.data) return; - -// let uniqueServerIds: string[] = []; -// let items: AnyLibraryItems = []; - -// if (ctx.dataNodes) { -// uniqueServerIds = ctx.dataNodes.reduce((acc, node) => { -// if (!acc.includes(node.data.serverId)) { -// acc.push(node.data.serverId); -// } -// return acc; -// }, [] as string[]); -// } else { -// uniqueServerIds = ctx.data.reduce((acc, item) => { -// if (!acc.includes(item.serverId)) { -// acc.push(item.serverId); -// } -// return acc; -// }, [] as string[]); -// } - -// const ratingToSet = newRating === rating ? 0 : newRating; - -// for (const serverId of uniqueServerIds) { -// if (ctx.dataNodes) { -// items = ctx.dataNodes -// .filter((node) => node.data.serverId === serverId) -// .map((node) => node.data); -// } else { -// items = ctx.data.filter((item) => item.serverId === serverId); -// } - -// updateRatingMutation.mutate( -// { -// query: { -// item: items, -// rating: ratingToSet, -// }, -// serverId, -// }, -// { -// onSuccess: () => { -// if (ctx.dataNodes) { -// for (const node of ctx.dataNodes) { -// node.setData({ ...node.data, userRating: ratingToSet }); -// } -// } -// }, -// }, -// ); -// } -// }, -// [ctx.data, ctx.dataNodes, updateRatingMutation, rating], -// ); - -// const playbackType = usePlaybackType(); -// // const { moveToBottomOfQueue, moveToNextOfQueue, moveToTopOfQueue, removeFromQueue } = -// // useQueueControls(); - -// const handleMoveToNext = useCallback(() => { -// const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId); -// if (!uniqueIds?.length) return; - -// const playerData = moveToNextOfQueue(uniqueIds); - -// if (playbackType === PlaybackType.LOCAL) { -// setQueueNext(playerData); -// } -// }, [ctx.dataNodes, moveToNextOfQueue, playbackType]); - -// const handleMoveToBottom = useCallback(() => { -// const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId); -// if (!uniqueIds?.length) return; - -// const playerData = moveToBottomOfQueue(uniqueIds); - -// if (playbackType === PlaybackType.LOCAL) { -// setQueueNext(playerData); -// } -// }, [ctx.dataNodes, moveToBottomOfQueue, playbackType]); - -// const handleMoveToTop = useCallback(() => { -// const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId); -// if (!uniqueIds?.length) return; - -// const playerData = moveToTopOfQueue(uniqueIds); - -// if (playbackType === PlaybackType.LOCAL) { -// setQueueNext(playerData); -// } -// }, [ctx.dataNodes, moveToTopOfQueue, playbackType]); - -// const handleShareItem = useCallback(() => { -// if (!ctx.dataNodes && !ctx.data) return; - -// const uniqueIds = ctx.data.map((node) => node.id); - -// openContextModal({ -// innerProps: { -// itemIds: uniqueIds, -// resourceType: ctx.data[0].itemType, -// }, -// modal: 'shareItem', -// size: 'md', -// title: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }), -// }); -// }, [ctx.data, ctx.dataNodes, t]); - -// const handleRemoveSelected = useCallback(() => { -// const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId); -// if (!uniqueIds?.length) return; - -// const currentSong = usePlayerStore.getState().current.song; -// const playerData = removeFromQueue(uniqueIds); -// const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong?.uniqueId); - -// if (playbackType === PlaybackType.LOCAL) { -// if (isCurrentSongRemoved) { -// setQueue(playerData); -// } else { -// setQueueNext(playerData); -// } -// } - -// ctx.tableApi?.redrawRows(); - -// if (isCurrentSongRemoved) { -// updateSong(playerData.current.song); -// } -// }, [ctx.dataNodes, ctx.tableApi, playbackType, removeFromQueue]); - -// const handleDeselectAll = useCallback(() => { -// ctx.tableApi?.deselectAll(); -// }, [ctx.tableApi]); - -// const handleOpenItemDetails = useCallback(() => { -// const item = ctx.data[0]; - -// openModal({ -// children: , -// size: 'xl', -// title: t('page.contextMenu.showDetails', { postProcess: 'titleCase' }), -// }); -// }, [ctx.data, t]); - -// const handleSimilar = useCallback(async () => { -// const item = ctx.data[0]; -// const songs = await controller.getSimilarSongs({ -// apiClientProps: { -// server: getServerById(item.serverId), -// signal: undefined, -// }, -// query: { albumArtistIds: item.albumArtistIds, songId: item.id }, -// }); -// if (songs) { -// handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW }); -// } -// }, [ctx, handlePlayQueueAdd]); - -// const handleDownload = useCallback(() => { -// const item = ctx.data[0]; -// const url = api.controller.getDownloadUrl({ -// apiClientProps: { server }, -// query: { id: item.id }, -// }); - -// if (utils) { -// utils.download(url!); -// } else { -// window.open(url, '_blank'); -// } -// }, [ctx.data, server]); - -// const handleGoToAlbum = useCallback(() => { -// const item = ctx.data[0]; -// if (item.albumId) { -// navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: item.albumId })); -// } -// }, [ctx.data, navigate]); - -// const handleGoToAlbumArtist = useCallback(() => { -// const item = ctx.data[0]; -// if (item.albumArtists && item.albumArtists.length > 0) { -// navigate( -// generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { -// albumArtistId: item.albumArtists[0].id, -// }), -// ); -// } -// }, [ctx.data, navigate]); - -// const contextMenuItems: Record = useMemo(() => { -// return { -// addToFavorites: { -// id: 'addToFavorites', -// label: t('page.contextMenu.addToFavorites', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleAddToFavorites, -// }, -// addToPlaylist: { -// id: 'addToPlaylist', -// label: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleAddToPlaylist, -// }, -// createPlaylist: { -// id: 'createPlaylist', -// label: t('page.contextMenu.createPlaylist', { postProcess: 'sentenceCase' }), -// onClick: () => {}, -// }, -// deletePlaylist: { -// id: 'deletePlaylist', -// label: t('page.contextMenu.deletePlaylist', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: openDeletePlaylistModal, -// }, -// deselectAll: { -// id: 'deselectAll', -// label: t('page.contextMenu.deselectAll', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleDeselectAll, -// }, -// download: { -// disabled: ctx.data?.length !== 1, -// id: 'download', -// label: t('page.contextMenu.download', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleDownload, -// }, -// goToAlbum: { -// disabled: ctx.data?.length !== 1 || !ctx.data[0]?.albumId, -// id: 'goToAlbum', -// label: t('page.contextMenu.goToAlbum', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleGoToAlbum, -// }, -// goToAlbumArtist: { -// disabled: -// ctx.data?.length !== 1 || -// !ctx.data[0]?.albumArtists || -// ctx.data[0]?.albumArtists?.length === 0, -// id: 'goToAlbumArtist', -// label: t('page.contextMenu.goToAlbumArtist', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleGoToAlbumArtist, -// }, -// moveToBottomOfQueue: { -// id: 'moveToBottomOfQueue', -// label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleMoveToBottom, -// }, -// moveToNextOfQueue: { -// id: 'moveToNext', -// label: t('page.contextMenu.moveToNext', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleMoveToNext, -// }, -// moveToTopOfQueue: { -// id: 'moveToTopOfQueue', -// label: t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleMoveToTop, -// }, -// play: { -// id: 'play', -// label: t('page.contextMenu.play', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: () => handlePlay(Play.NOW), -// }, -// playLast: { -// id: 'playLast', -// label: t('page.contextMenu.addLast', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: () => handlePlay(Play.LAST), -// }, -// playNext: { -// id: 'playNext', -// label: t('page.contextMenu.addNext', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: () => handlePlay(Play.NEXT), -// }, -// playShuffled: { -// id: 'playShuffled', -// label: t('page.contextMenu.playShuffled', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: () => handlePlay(Play.SHUFFLE), -// }, -// playSimilarSongs: { -// id: 'playSimilarSongs', -// label: t('page.contextMenu.playSimilarSongs', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleSimilar, -// }, -// removeFromFavorites: { -// id: 'removeFromFavorites', -// label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleRemoveFromFavorites, -// }, -// removeFromPlaylist: { -// id: 'removeFromPlaylist', -// label: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleRemoveFromPlaylist, -// }, -// removeFromQueue: { -// id: 'removeSongs', -// label: t('page.contextMenu.removeFromQueue', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleRemoveSelected, -// }, -// setRating: { -// id: 'setRating', -// label: t('action.setRating', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: () => {}, -// rightIcon: ( -// -// { -// handleUpdateRating(e); -// setRating(e); -// }} -// size="xs" -// value={rating} -// /> -// -// ), -// }, -// shareItem: { -// disabled: !hasFeature(server, ServerFeature.SHARING_ALBUM_SONG), -// id: 'shareItem', -// label: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleShareItem, -// }, -// showDetails: { -// disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType, -// id: 'showDetails', -// label: t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }), -// leftIcon: , -// onClick: handleOpenItemDetails, -// }, -// }; -// }, [ -// t, -// handleAddToFavorites, -// handleAddToPlaylist, -// openDeletePlaylistModal, -// handleDeselectAll, -// ctx.data, -// handleDownload, -// handleMoveToNext, -// handleMoveToBottom, -// handleMoveToTop, -// handleSimilar, -// handleRemoveFromFavorites, -// handleRemoveFromPlaylist, -// handleRemoveSelected, -// server, -// handleShareItem, -// handleGoToAlbum, -// handleGoToAlbumArtist, -// handleOpenItemDetails, -// handlePlay, -// handleUpdateRating, -// rating, -// ]); - -// const mergedRef = useMergedRef(ref, clickOutsideRef); - -// const providerValue = useMemo( -// () => ({ -// closeContextMenu, -// openContextMenu, -// }), -// [closeContextMenu, openContextMenu], -// ); - -// return ( -// -// -// -// {opened && ( -// -// -// -// {ctx.menuItems?.map((item) => { -// return ( -// !contextMenuItems[item.id].disabled && ( -// -// {item.children ? ( -// -// -// -// { -// contextMenuItems[item.id] -// .label -// } -// -// -// -// -// {contextMenuItems[ -// item.id -// ].children?.map((child) => ( -// -// {child.label} -// -// ))} -// -// -// -// ) : ( -// -// {contextMenuItems[item.id].label} -// -// )} - -// {item.divider && ( -// -// )} -// -// ) -// ); -// })} -// -// -// {t('page.contextMenu.numberSelected', { -// count: ctx.data?.length || 0, -// postProcess: 'lowerCase', -// })} -// -// -// -// )} -// -// {children} -// -// -// ); -// }; diff --git a/src/renderer/features/context-menu/events.ts b/src/renderer/features/context-menu/events.ts index 6165ea137..ee1db1dce 100644 --- a/src/renderer/features/context-menu/events.ts +++ b/src/renderer/features/context-menu/events.ts @@ -8,74 +8,18 @@ export type ContextMenuEvents = { openContextMenu: (args: OpenContextMenuProps) => void; }; -export type ContextMenuItemType = - | 'addToFavorites' - | 'addToPlaylist' - | 'createPlaylist' - | 'deletePlaylist' - | 'deselectAll' - | 'download' - | 'goToAlbum' - | 'goToAlbumArtist' - | 'moveToBottomOfQueue' - | 'moveToNextOfQueue' - | 'moveToTopOfQueue' - | 'play' - | 'playLast' - | 'playNext' - | 'playShuffled' - | 'playSimilarSongs' - | 'removeFromFavorites' - | 'removeFromPlaylist' - | 'removeFromQueue' - | 'setRating' - | 'shareItem' - | 'showDetails'; - -export type OpenContextMenuProps = { - context?: any; - data: any[]; - dataNodes?: RowNode[]; - menuItems: SetContextMenuItems; - resetGridCache?: () => void; - tableApi?: GridOptions['api']; - type: LibraryItem; - xPos: number; - yPos: number; -}; - -export const CONFIGURABLE_CONTEXT_MENU_ITEMS: ContextMenuItemType[] = [ - 'moveToBottomOfQueue', - 'moveToTopOfQueue', - 'play', - 'playLast', - 'playNext', - 'playShuffled', - 'playSimilarSongs', - 'addToPlaylist', - 'removeFromPlaylist', - 'addToFavorites', - 'removeFromFavorites', - 'setRating', - 'download', - 'shareItem', - 'goToAlbum', - 'goToAlbumArtist', - 'showDetails', -]; - -export const CONTEXT_MENU_ITEM_MAPPING: { [k in ContextMenuItemType]?: string } = { - moveToBottomOfQueue: 'moveToBottom', - moveToTopOfQueue: 'moveToTop', - playLast: 'addLast', - playNext: 'addNext', +export const CONTEXT_MENU_ITEM_MAPPING: { [k in ContextMenuItemKeys]?: string } = { + [ContextMenuItemKey.MOVE_TO_BOTTOM_OF_QUEUE]: 'moveToBottom', + [ContextMenuItemKey.MOVE_TO_TOP_OF_QUEUE]: 'moveToTop', + [ContextMenuItemKey.PLAY_LAST]: 'addLast', + [ContextMenuItemKey.PLAY_NEXT]: 'addNext', }; export type SetContextMenuItems = { children?: boolean; disabled?: boolean; divider?: boolean; - id: ContextMenuItemType; + id: ContextMenuItemKeys; onClick?: () => void; }[]; diff --git a/src/renderer/features/context-menu/hooks/use-handle-context-menu.ts b/src/renderer/features/context-menu/hooks/use-handle-context-menu.ts deleted file mode 100644 index 350421de5..000000000 --- a/src/renderer/features/context-menu/hooks/use-handle-context-menu.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { CellContextMenuEvent, GridApi } from '@ag-grid-community/core'; -import sortBy from 'lodash/sortBy'; - -import { openContextMenu, SetContextMenuItems } from '/@/renderer/features/context-menu/events'; -import { - Album, - AlbumArtist, - Artist, - LibraryItem, - QueueSong, - Song, -} from '/@/shared/types/domain-types'; - -export const useHandleTableContextMenu = ( - itemType: LibraryItem, - contextMenuItems: SetContextMenuItems, - context?: any, -) => { - const handleContextMenu = ( - e?: CellContextMenuEvent, - gridApi?: GridApi, - click?: MouseEvent, - ) => { - let clickEvent: MouseEvent | undefined = click; - if (e) { - if (!e?.event) return; - clickEvent = e?.event as MouseEvent; - clickEvent.preventDefault(); - } - - const api = gridApi || e?.api; - - if (!api) return; - - let selectedNodes = sortBy(api.getSelectedNodes(), ['rowIndex']); - let selectedRows = selectedNodes.map((node) => node.data); - - if (e) { - if (!e.data?.id) return; - - const shouldReplaceSelected = !selectedNodes - .map((node) => node.data.id) - .includes(e.data.id); - - if (shouldReplaceSelected) { - e.api.deselectAll(); - e.node.setSelected(true); - selectedRows = [e.data]; - selectedNodes = e.api.getSelectedNodes(); - } - } - - openContextMenu({ - context, - data: selectedRows, - dataNodes: selectedNodes, - menuItems: contextMenuItems, - tableApi: api, - type: itemType, - xPos: clickEvent?.clientX || 0, - yPos: clickEvent?.clientY || 0, - }); - }; - - return handleContextMenu; -}; - -export const useHandleGeneralContextMenu = ( - itemType: LibraryItem, - contextMenuItems: SetContextMenuItems, - context?: any, -) => { - const handleContextMenu = ( - e: any, - data: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[], - ) => { - if (!e) return; - const clickEvent = e as MouseEvent; - clickEvent.preventDefault(); - - openContextMenu({ - context, - data, - dataNodes: undefined, - menuItems: contextMenuItems, - type: itemType, - xPos: clickEvent.clientX + 15, - yPos: clickEvent.clientY + 5, - }); - }; - - return handleContextMenu; -}; - -export const useHandleGridContextMenu = ( - itemType: LibraryItem, - contextMenuItems: SetContextMenuItems, - resetGridCache?: () => void, - context?: any, -) => { - const handleContextMenu = ( - e: any, - data: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[], - ) => { - if (!e) return; - const clickEvent = e as MouseEvent; - clickEvent.preventDefault(); - - openContextMenu({ - context, - data, - dataNodes: undefined, - menuItems: contextMenuItems, - resetGridCache, - type: itemType, - xPos: clickEvent.clientX + 15, - yPos: clickEvent.clientY + 5, - }); - }; - - return handleContextMenu; -}; diff --git a/src/renderer/features/context-menu/menus/album-artist-context-menu.tsx b/src/renderer/features/context-menu/menus/album-artist-context-menu.tsx new file mode 100644 index 000000000..f1dbc636e --- /dev/null +++ b/src/renderer/features/context-menu/menus/album-artist-context-menu.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; + +import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action'; +import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action'; +import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action'; +import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action'; +import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action'; +import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action'; +import { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action'; +import { ShareAction } from '/@/renderer/features/context-menu/actions/share-action'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { AlbumArtist, LibraryItem } from '/@/shared/types/domain-types'; + +interface AlbumArtistContextMenuProps { + items: AlbumArtist[]; +} + +export const AlbumArtistContextMenu = ({ items }: AlbumArtistContextMenuProps) => { + const { ids } = useMemo(() => { + const ids = items.map((item) => item.id); + return { ids }; + }, [items]); + + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/context-menu/menus/album-context-menu.tsx b/src/renderer/features/context-menu/menus/album-context-menu.tsx new file mode 100644 index 000000000..3c1c3cfeb --- /dev/null +++ b/src/renderer/features/context-menu/menus/album-context-menu.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; + +import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action'; +import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action'; +import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action'; +import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action'; +import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action'; +import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action'; +import { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action'; +import { ShareAction } from '/@/renderer/features/context-menu/actions/share-action'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { Album, LibraryItem } from '/@/shared/types/domain-types'; + +interface AlbumContextMenuProps { + items: Album[]; +} + +export const AlbumContextMenu = ({ items }: AlbumContextMenuProps) => { + const { ids } = useMemo(() => { + const ids = items.map((item) => item.id); + return { ids }; + }, [items]); + + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/context-menu/menus/artist-context-menu.tsx b/src/renderer/features/context-menu/menus/artist-context-menu.tsx new file mode 100644 index 000000000..77a8780d6 --- /dev/null +++ b/src/renderer/features/context-menu/menus/artist-context-menu.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; + +import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action'; +import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action'; +import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action'; +import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action'; +import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action'; +import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action'; +import { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action'; +import { ShareAction } from '/@/renderer/features/context-menu/actions/share-action'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { Artist, LibraryItem } from '/@/shared/types/domain-types'; + +interface ArtistContextMenuProps { + items: Artist[]; +} + +export const ArtistContextMenu = ({ items }: ArtistContextMenuProps) => { + const { ids } = useMemo(() => { + const ids = items.map((item) => item.id); + return { ids }; + }, [items]); + + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/context-menu/menus/genre-context-menu.tsx b/src/renderer/features/context-menu/menus/genre-context-menu.tsx new file mode 100644 index 000000000..d2e636fee --- /dev/null +++ b/src/renderer/features/context-menu/menus/genre-context-menu.tsx @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; + +import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action'; +import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { Genre, LibraryItem } from '/@/shared/types/domain-types'; + +interface GenreContextMenuProps { + items: Genre[]; +} + +export const GenreContextMenu = ({ items }: GenreContextMenuProps) => { + const { ids } = useMemo(() => { + const ids = items.map((item) => item.id); + return { ids }; + }, [items]); + + return ( + + + + + + ); +}; diff --git a/src/renderer/features/context-menu/menus/playlist-context-menu.tsx b/src/renderer/features/context-menu/menus/playlist-context-menu.tsx new file mode 100644 index 000000000..af0c2f334 --- /dev/null +++ b/src/renderer/features/context-menu/menus/playlist-context-menu.tsx @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; + +import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action'; +import { DeletePlaylistAction } from '/@/renderer/features/context-menu/actions/delete-playlist-action'; +import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action'; +import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { LibraryItem, Playlist } from '/@/shared/types/domain-types'; + +interface PlaylistContextMenuProps { + items: Playlist[]; +} + +export const PlaylistContextMenu = ({ items }: PlaylistContextMenuProps) => { + const { ids } = useMemo(() => { + const ids = items.map((item) => item.id); + return { ids }; + }, [items]); + + return ( + + + + + + + + + + ); +}; diff --git a/src/renderer/features/context-menu/menus/playlist-song-context-menu.tsx b/src/renderer/features/context-menu/menus/playlist-song-context-menu.tsx new file mode 100644 index 000000000..38030ee0c --- /dev/null +++ b/src/renderer/features/context-menu/menus/playlist-song-context-menu.tsx @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; + +import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action'; +import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action'; +import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action'; +import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action'; +import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action'; +import { RemoveFromPlaylistAction } from '/@/renderer/features/context-menu/actions/remove-from-playlist-action'; +import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action'; +import { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action'; +import { ShareAction } from '/@/renderer/features/context-menu/actions/share-action'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { LibraryItem, Song } from '/@/shared/types/domain-types'; + +interface PlaylistSongContextMenuProps { + items: Song[]; +} + +export const PlaylistSongContextMenu = ({ items }: PlaylistSongContextMenuProps) => { + const { ids } = useMemo(() => { + const ids = items.map((item) => item.id); + return { ids }; + }, [items]); + + return ( + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/context-menu/menus/queue-context-menu.tsx b/src/renderer/features/context-menu/menus/queue-context-menu.tsx new file mode 100644 index 000000000..52d678784 --- /dev/null +++ b/src/renderer/features/context-menu/menus/queue-context-menu.tsx @@ -0,0 +1,46 @@ +import { useMemo } from 'react'; + +import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action'; +import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action'; +import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action'; +import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action'; +import { MoveQueueItemsAction } from '/@/renderer/features/context-menu/actions/move-queue-items-action'; +import { RemoveFromQueueAction } from '/@/renderer/features/context-menu/actions/remove-from-queue-action'; +import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action'; +import { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action'; +import { ShareAction } from '/@/renderer/features/context-menu/actions/share-action'; +import { ShuffleItemsAction } from '/@/renderer/features/context-menu/actions/shuffle-items-action'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { LibraryItem, QueueSong } from '/@/shared/types/domain-types'; + +interface QueueContextMenuProps { + items: QueueSong[]; +} + +export const QueueContextMenu = ({ items }: QueueContextMenuProps) => { + const { ids } = useMemo(() => { + const ids = items.map((item) => item.id); + return { ids }; + }, [items]); + + return ( + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/context-menu/menus/song-context-menu.tsx b/src/renderer/features/context-menu/menus/song-context-menu.tsx new file mode 100644 index 000000000..3c4fa59fe --- /dev/null +++ b/src/renderer/features/context-menu/menus/song-context-menu.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; + +import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action'; +import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action'; +import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action'; +import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action'; +import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action'; +import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action'; +import { SetRatingAction } from '/@/renderer/features/context-menu/actions/set-rating-action'; +import { ShareAction } from '/@/renderer/features/context-menu/actions/share-action'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; +import { LibraryItem, Song } from '/@/shared/types/domain-types'; + +interface SongContextMenuProps { + items: Song[]; +} + +export const SongContextMenu = ({ items }: SongContextMenuProps) => { + const { ids } = useMemo(() => { + const ids = items.map((item) => item.id); + return { ids }; + }, [items]); + + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/item-details/components/item-details-modal.tsx b/src/renderer/features/item-details/components/item-details-modal.tsx index 8a167d8f2..b18f805ab 100644 --- a/src/renderer/features/item-details/components/item-details-modal.tsx +++ b/src/renderer/features/item-details/components/item-details-modal.tsx @@ -17,7 +17,7 @@ import { Separator } from '/@/shared/components/separator/separator'; import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Table } from '/@/shared/components/table/table'; import { Text } from '/@/shared/components/text/text'; -import { ExplicitStatus } from '/@/shared/types/domain-types'; +import { Artist, ExplicitStatus } from '/@/shared/types/domain-types'; import { Album, AlbumArtist, @@ -29,7 +29,7 @@ import { } from '/@/shared/types/domain-types'; export type ItemDetailsModalProps = { - item: Album | AlbumArtist | Playlist | Song; + item: Album | AlbumArtist | Artist | Playlist | Song; }; type ItemDetailRow = { @@ -402,7 +402,7 @@ export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => { const { t } = useTranslation(); let body: ReactNode[] = []; - switch (item.itemType) { + switch (item._itemType) { case LibraryItem.ALBUM: body = AlbumPropertyMapping.map((rule) => handleRow(t, item, rule)); body.push(...handleParticipants(item, t)); diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx index 2eafc3aa6..6e024c005 100644 --- a/src/renderer/features/player/components/left-controls.tsx +++ b/src/renderer/features/player/components/left-controls.tsx @@ -7,8 +7,7 @@ import { generatePath, Link } from 'react-router'; import styles from './left-controls.module.css'; -import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; -import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; +import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { AppRoute } from '/@/renderer/router/routes'; import { useAppStoreActions, @@ -41,11 +40,6 @@ export const LeftControls = () => { const isSongDefined = Boolean(currentSong?.id); - const handleGeneralContextMenu = useHandleGeneralContextMenu( - LibraryItem.SONG, - SONG_CONTEXT_MENU_ITEMS, - ); - const handleToggleFullScreenPlayer = (e?: KeyboardEvent | MouseEvent) => { // don't toggle if right click if (e && 'button' in e && e.button === 2) { @@ -65,9 +59,14 @@ export const LeftControls = () => { e.preventDefault(); e.stopPropagation(); - if (isSongDefined && !isFullScreenPlayerExpanded) { - handleGeneralContextMenu(e, [currentSong!]); + if (!currentSong) { + return; } + + ContextMenuController.call({ + cmd: { items: [currentSong], type: LibraryItem.SONG }, + event: e, + }); }; const stopPropagation = (e?: MouseEvent) => e?.stopPropagation(); @@ -156,7 +155,7 @@ export const LeftControls = () => { {isSongDefined && ( handleGeneralContextMenu(e, [currentSong!])} + // onClick={(e) => handleGeneralContextMenu(e, [currentSong!])} size="xs" styles={{ root: { diff --git a/src/renderer/features/playlists/hooks/use-recent-playlists.ts b/src/renderer/features/playlists/hooks/use-recent-playlists.ts new file mode 100644 index 000000000..d04b02608 --- /dev/null +++ b/src/renderer/features/playlists/hooks/use-recent-playlists.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; + +import { useSessionStorage } from '/@/shared/hooks/use-session-storage'; + +interface RecentPlaylists { + [serverId: string]: string; +} + +const RECENT_PLAYLISTS_KEY = 'recent-playlists'; +const DEFAULT_VALUE: RecentPlaylists = {}; + +export const useRecentPlaylists = (serverId: null | string) => { + const [recentPlaylists, setRecentPlaylists] = useSessionStorage({ + defaultValue: DEFAULT_VALUE, + key: RECENT_PLAYLISTS_KEY, + }); + + const getRecentPlaylistId = useCallback((): null | string => { + if (!serverId) return null; + return recentPlaylists[serverId] || null; + }, [recentPlaylists, serverId]); + + const addRecentPlaylist = useCallback( + (playlistId: string) => { + if (!serverId || !playlistId) return; + + setRecentPlaylists({ + ...recentPlaylists, + [serverId]: playlistId, + }); + }, + [recentPlaylists, serverId, setRecentPlaylists], + ); + + return { + addRecentPlaylist, + recentPlaylistId: getRecentPlaylistId(), + }; +}; diff --git a/src/renderer/features/playlists/mutations/add-to-playlist-mutation.ts b/src/renderer/features/playlists/mutations/add-to-playlist-mutation.ts index f25ec2e6e..41f8c43d4 100644 --- a/src/renderer/features/playlists/mutations/add-to-playlist-mutation.ts +++ b/src/renderer/features/playlists/mutations/add-to-playlist-mutation.ts @@ -3,12 +3,17 @@ import { AxiosError } from 'axios'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; +import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; +import { useCurrentServerId } from '/@/renderer/store'; import { AddToPlaylistArgs, AddToPlaylistResponse } from '/@/shared/types/domain-types'; export const useAddToPlaylist = (args: MutationHookArgs) => { const { options } = args || {}; const queryClient = useQueryClient(); + const serverId = useCurrentServerId(); + + const { addRecentPlaylist } = useRecentPlaylists(serverId); return useMutation({ mutationFn: (args) => { @@ -17,7 +22,7 @@ export const useAddToPlaylist = (args: MutationHookArgs) => { apiClientProps: { serverId: args.apiClientProps.serverId }, }); }, - onSuccess: (_data, variables) => { + onSuccess: (_data, variables, context) => { const { apiClientProps } = variables; const serverId = apiClientProps.serverId; @@ -33,6 +38,10 @@ export const useAddToPlaylist = (args: MutationHookArgs) => { queryClient.invalidateQueries({ queryKey: queryKeys.playlists.songList(serverId, variables.query.id), }); + + addRecentPlaylist(variables.query.id); + + options?.onSuccess?.(_data, variables, context); }, ...options, }); diff --git a/src/renderer/features/settings/components/general/context-menu-settings.tsx b/src/renderer/features/settings/components/general/context-menu-settings.tsx deleted file mode 100644 index 8205bd0f7..000000000 --- a/src/renderer/features/settings/components/general/context-menu-settings.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { - CONFIGURABLE_CONTEXT_MENU_ITEMS, - CONTEXT_MENU_ITEM_MAPPING, -} from '/@/renderer/features/context-menu/events'; -import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option'; -import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store'; -import { Button } from '/@/shared/components/button/button'; -import { Checkbox } from '/@/shared/components/checkbox/checkbox'; -import { Divider } from '/@/shared/components/divider/divider'; -import { Stack } from '/@/shared/components/stack/stack'; - -export const ContextMenuSettings = () => { - const disabledItems = useSettingsStore((state) => state.general.disabledContextMenu); - const { toggleContextMenuItem } = useSettingsStoreActions(); - const [open, setOpen] = useState(false); - const { t } = useTranslation(); - - return ( - <> - setOpen(!open)} size="compact-md" variant="filled"> - {t(open ? 'common.close' : 'common.edit', { postProcess: 'titleCase' })} - - } - description={t('setting.contextMenu', { - context: 'description', - postProcess: 'sentenceCase', - })} - title={t('setting.contextMenu', { - postProcess: 'sentenceCase', - })} - /> - {open && ( - - {CONFIGURABLE_CONTEXT_MENU_ITEMS.map((item) => ( - toggleContextMenuItem(item)} - /> - ))} - - )} - - - ); -}; diff --git a/src/renderer/features/settings/components/general/general-tab.tsx b/src/renderer/features/settings/components/general/general-tab.tsx index dfe436ae4..469a146cd 100644 --- a/src/renderer/features/settings/components/general/general-tab.tsx +++ b/src/renderer/features/settings/components/general/general-tab.tsx @@ -2,7 +2,6 @@ import isElectron from 'is-electron'; import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings'; import { ArtistSettings } from '/@/renderer/features/settings/components/general/artist-settings'; -import { ContextMenuSettings } from '/@/renderer/features/settings/components/general/context-menu-settings'; import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings'; import { HomeSettings } from '/@/renderer/features/settings/components/general/home-settings'; import { RemoteSettings } from '/@/renderer/features/settings/components/general/remote-settings'; @@ -22,7 +21,6 @@ export const GeneralTab = () => { - {isElectron() && } diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 763722d13..3a8434859 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -6,8 +6,7 @@ import { useLocation } from 'react-router'; import styles from './sidebar.module.css'; -import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; -import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; +import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; @@ -17,8 +16,8 @@ import { } from '/@/renderer/features/sidebar/components/sidebar-playlist-list'; import { useAppStoreActions, - usePlayerSong, useFullScreenPlayerStore, + usePlayerSong, useSetFullScreenPlayerStore, useSidebarStore, } from '/@/renderer/store'; @@ -73,17 +72,19 @@ export const Sidebar = () => { setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded }); }; - const handleGeneralContextMenu = useHandleGeneralContextMenu( - LibraryItem.SONG, - SONG_CONTEXT_MENU_ITEMS, - ); - const handleToggleContextMenu = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); + if (!currentSong) { + return; + } + if (isSongDefined && !isFullScreenPlayerExpanded) { - handleGeneralContextMenu(e, [currentSong!]); + ContextMenuController.call({ + cmd: { items: [currentSong!], type: LibraryItem.SONG }, + event: e, + }); } }; diff --git a/src/renderer/features/similar-songs/components/similar-songs-list.tsx b/src/renderer/features/similar-songs/components/similar-songs-list.tsx index cc49e1476..cfc884e6d 100644 --- a/src/renderer/features/similar-songs/components/similar-songs-list.tsx +++ b/src/renderer/features/similar-songs/components/similar-songs-list.tsx @@ -7,7 +7,6 @@ import { ErrorBoundary } from 'react-error-boundary'; import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table'; import { ErrorFallback } from '/@/renderer/features/action-required/components/error-fallback'; import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; -import { useHandleTableContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { usePlayButtonBehavior, useTableSettings } from '/@/renderer/store'; import { Spinner } from '/@/shared/components/spinner/spinner'; @@ -21,7 +20,7 @@ export type SimilarSongsListProps = { export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListProps) => { const tableRef = useRef | null>(null); - const tableConfig = useTableSettings(fullScreen ? 'fullScreen' : 'songs'); + // const tableConfig = useTableSettings(fullScreen ? 'fullScreen' : 'songs'); const songQuery = useQuery( songsQueries.similar({ @@ -37,12 +36,12 @@ export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListPr }), ); - const columnDefs = useMemo( - () => getColumnDefs(tableConfig.columns, false, 'generic'), - [tableConfig.columns], - ); + // const columnDefs = useMemo( + // () => getColumnDefs(tableConfig.columns, false, 'generic'), + // [tableConfig.columns], + // ); - const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS); + // const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS); const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { if (!e.data || !songQuery.data) return; @@ -58,7 +57,7 @@ export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListPr ) : ( - + /> */} ); }; diff --git a/src/renderer/layouts/default-layout.tsx b/src/renderer/layouts/default-layout.tsx index f9513e2e0..f419e24e2 100644 --- a/src/renderer/layouts/default-layout.tsx +++ b/src/renderer/layouts/default-layout.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router'; import styles from './default-layout.module.css'; +import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { CommandPalette } from '/@/renderer/features/search/components/command-palette'; import { MainContent } from '/@/renderer/layouts/default-layout/main-content'; import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar'; @@ -88,6 +89,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
+ ); }; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 5ef5a7439..cdf6c1f59 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -17,7 +17,6 @@ import { PLAYLIST_TABLE_COLUMNS, SONG_TABLE_COLUMNS, } from '/@/renderer/components/item-list/item-table-list/default-columns'; -import { ContextMenuItemType } from '/@/renderer/features/context-menu/events'; import { AppRoute } from '/@/renderer/router/routes'; import { mergeOverridingColumns } from '/@/renderer/store/utils'; import { FontValueSchema } from '/@/renderer/types/fonts'; @@ -457,7 +456,6 @@ export interface SettingsSlice extends z.infer { setSidebarItems: (items: SidebarItemType[]) => void; setTable: (type: ItemListKey, data: DataTableProps) => void; setTranscodingConfig: (config: TranscodingConfig) => void; - toggleContextMenuItem: (item: ContextMenuItemType) => void; toggleMediaSession: () => void; toggleSidebarCollapseShare: () => void; }; @@ -1158,12 +1156,6 @@ export const useSettingsStore = createWithEqualityFn()( state.playback.transcode = config; }); }, - toggleContextMenuItem: (item: ContextMenuItemType) => { - set((state) => { - state.general.disabledContextMenu[item] = - !state.general.disabledContextMenu[item]; - }); - }, toggleMediaSession: () => { set((state) => { state.playback.mediaSession = !state.playback.mediaSession; diff --git a/src/shared/components/context-menu/context-menu.module.css b/src/shared/components/context-menu/context-menu.module.css new file mode 100644 index 000000000..c2b40198b --- /dev/null +++ b/src/shared/components/context-menu/context-menu.module.css @@ -0,0 +1,109 @@ +.content { + z-index: 1000; + width: 20rem; + min-width: 20rem; + max-width: 20rem; + padding: var(--theme-spacing-xs); + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); + border: 1px solid var(--theme-colors-border); + border-radius: var(--theme-radius-md); + filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%)); +} + +.inner-content { + display: flex; + flex-direction: column; + gap: var(--base-gap-xs); + overflow: hidden; +} + +.item { + position: relative; + display: flex; + align-items: center; + min-width: 8rem; + max-width: 100%; + padding: var(--theme-spacing-sm) var(--theme-spacing-md); + font-size: var(--theme-font-size-sm); + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + cursor: default; + user-select: none; + + &.has-left-icon { + padding-left: calc(var(--theme-spacing-md) + 1.5rem); + } + + &.has-right-icon { + padding-right: calc(var(--theme-spacing-md) + 1.5rem); + } + + & > *:not(.left-icon, .right-icon) { + flex: 1; + min-width: 0; + word-wrap: break-word; + overflow-wrap: break-word; + } + + &:hover { + background: var(--theme-colors-surface); + } +} + +.item[data-highlighted] { + background: var(--theme-colors-surface); +} + +.left-icon { + position: absolute; + top: 50%; + left: var(--theme-spacing-md); + z-index: 1; + display: flex; + align-items: center; + transform: translateY(-50%); +} + +.right-icon { + position: absolute; + top: 50%; + right: var(--theme-spacing-md); + z-index: 1; + display: flex; + align-items: center; + transform: translateY(-50%); +} + +.disabled { + pointer-events: none; + opacity: 0.6; +} + +.item.selected { + &::before { + position: absolute; + top: 50%; + left: 2px; + width: 4px; + height: 50%; + content: ''; + background-color: var(--theme-colors-primary-filled); + border-radius: var(--theme-border-radius-xl); + transform: translateY(-50%); + } +} + +.divider { + height: 1px; + padding: 0; + margin: var(--theme-spacing-xs) 0; + background: none; + border: none; + border-top: 1px solid var(--theme-colors-border); +} + +.max-height { + max-height: 36rem; +} diff --git a/src/shared/components/context-menu/context-menu.tsx b/src/shared/components/context-menu/context-menu.tsx new file mode 100644 index 000000000..0dd274858 --- /dev/null +++ b/src/shared/components/context-menu/context-menu.tsx @@ -0,0 +1,235 @@ +import type { Dispatch, SetStateAction } from 'react'; + +import * as RadixContextMenu from '@radix-ui/react-context-menu'; +import clsx from 'clsx'; +import { AnimatePresence, motion } from 'motion/react'; +import { createContext, Fragment, type ReactNode, useContext, useMemo, useState } from 'react'; + +import styles from './context-menu.module.css'; + +import { animationVariants } from '/@/shared/components/animations/animation-variants'; +import { AppIcon, Icon } from '/@/shared/components/icon/icon'; +import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; + +interface ContextMenuContext { + open: boolean; + setOpen: Dispatch>; +} + +export const ContextMenuContext = createContext(null); + +interface ContentProps { + children: ReactNode; + onCloseAutoFocus?: (event: FocusEvent) => void; + onEscapeKeyDown?: (event: KeyboardEvent) => void; + onFocusOutside?: (event: FocusEvent) => void; + onPointerDownOutside?: (event: PointerEvent) => void; + stickyContent?: ReactNode; +} + +interface ContextMenuProps { + children: ReactNode; +} + +interface DividerProps {} + +interface ItemProps { + children: ReactNode; + className?: string; + disabled?: boolean; + isSelected?: boolean; + leftIcon?: keyof typeof AppIcon; + onSelect?: (event: Event) => void; + rightIcon?: keyof typeof AppIcon; +} + +interface LabelProps extends React.ComponentPropsWithoutRef<'div'> { + children: ReactNode; +} + +interface SubmenuContext { + disabled?: boolean; + isCloseDisabled?: boolean; + open: boolean; + setOpen: Dispatch>; +} + +interface TargetProps { + children: ReactNode; +} + +export function ContextMenu(props: ContextMenuProps) { + const { children } = props; + + const [open, setOpen] = useState(false); + const context = useMemo(() => ({ open, setOpen }), [open]); + + return ( + + {children} + + ); +} + +function Content(props: ContentProps) { + const { children, stickyContent } = props; + const { open } = useContext(ContextMenuContext) as ContextMenuContext; + + return ( + + {open && ( + + + + {stickyContent} + {children} + + + + )} + + ); +} + +function Divider(props: DividerProps) { + return ; +} + +function Item(props: ItemProps) { + const { children, className, disabled, isSelected, leftIcon, onSelect, rightIcon } = props; + + return ( + + {leftIcon && } + {children} + {rightIcon && } + + ); +} + +function Label(props: LabelProps) { + const { children, className, ...htmlProps } = props; + + return ( + + {children} + + ); +} + +function Target(props: TargetProps) { + const { children } = props; + + return ( + + {children} + + ); +} + +const SubmenuContext = createContext(null); + +interface SubmenuContentProps { + children: ReactNode; + stickyContent?: ReactNode; +} + +interface SubmenuProps { + children: ReactNode; + disabled?: boolean; + isCloseDisabled?: boolean; + open?: boolean; +} + +interface SubmenuTargetProps { + children: ReactNode; +} + +function Submenu(props: SubmenuProps) { + const { children, disabled, isCloseDisabled, open: isManuallyOpen } = props; + const [open, setOpen] = useState(isManuallyOpen ?? false); + const context = useMemo( + () => ({ disabled, isCloseDisabled, open, setOpen }), + [disabled, isCloseDisabled, open], + ); + + return ( + + {children} + + ); +} + +function SubmenuContent(props: SubmenuContentProps) { + const { children, stickyContent } = props; + const { isCloseDisabled, open, setOpen } = useContext(SubmenuContext) as SubmenuContext; + + return ( + + {open && ( + + setOpen(true)} + onMouseLeave={() => { + if (!isCloseDisabled) { + setOpen(false); + } + }} + > + + {stickyContent} + {children} + + + + )} + + ); +} + +function SubmenuTarget(props: SubmenuTargetProps) { + const { children } = props; + const { disabled, setOpen } = useContext(SubmenuContext) as SubmenuContext; + + return ( + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + {children} + + ); +} + +ContextMenu.Target = Target; +ContextMenu.Content = Content; +ContextMenu.Item = Item; +ContextMenu.Label = Label; +ContextMenu.Group = RadixContextMenu.Group; +ContextMenu.Submenu = Submenu; +ContextMenu.SubmenuTarget = SubmenuTarget; +ContextMenu.SubmenuContent = SubmenuContent; +ContextMenu.Divider = Divider; +ContextMenu.Arrow = RadixContextMenu.Arrow; diff --git a/src/shared/hooks/use-session-storage.ts b/src/shared/hooks/use-session-storage.ts new file mode 100644 index 000000000..8d9c363ef --- /dev/null +++ b/src/shared/hooks/use-session-storage.ts @@ -0,0 +1,3 @@ +import { useSessionStorage as useMantineSessionStorage } from '@mantine/hooks'; + +export const useSessionStorage = useMantineSessionStorage;