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;