mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-18 14:23:51 +02:00
replace element plus components
This commit is contained in:
254
package-lock.json
generated
254
package-lock.json
generated
@@ -14,19 +14,19 @@
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||
"@fontsource-variable/noto-sans-kr": "^5.2.9",
|
||||
"@fontsource-variable/noto-sans-sc": "^5.2.9",
|
||||
"@fontsource-variable/noto-sans-tc": "^5.2.9",
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.10",
|
||||
"@fontsource-variable/noto-sans-kr": "^5.2.10",
|
||||
"@fontsource-variable/noto-sans-sc": "^5.2.10",
|
||||
"@fontsource-variable/noto-sans-tc": "^5.2.10",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@kamiya4047/eslint-plugin-pretty-import": "^0.1.6",
|
||||
"@sentry/vite-plugin": "^4.6.1",
|
||||
"@sentry/vue": "^10.33.0",
|
||||
"@sentry/vite-plugin": "^4.6.2",
|
||||
"@sentry/vue": "^10.34.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tanstack/vue-virtual": "^3.13.18",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.0.7",
|
||||
"@types/node": "^25.0.8",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.3",
|
||||
@@ -46,7 +46,7 @@
|
||||
"esbuild-jest": "^0.5.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.2.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"noty": "^3.2.0-beta-deprecated",
|
||||
"pinia": "^3.0.4",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.8.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"remixicon": "^4.8.0",
|
||||
"sass-embedded": "^1.97.2",
|
||||
@@ -2097,9 +2097,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/noto-sans-jp": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.9.tgz",
|
||||
"integrity": "sha512-osPL5f7dvGDjuMuFwDTGPLG37030D8X5zk+3BWea6txAVDFeE/ZIrKW0DY0uSDfRn9+NiKbiFn/2QvZveKXTog==",
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.10.tgz",
|
||||
"integrity": "sha512-m0XfZ38rZtCaGAuAQL0cBPQ6fc/RguYEOqw66zvuLOV9vNRUBf9MFqyoazTu2JVCRIcnkrxbwF29UUaHHgTKdg==",
|
||||
"dev": true,
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
@@ -2107,9 +2107,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/noto-sans-kr": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-kr/-/noto-sans-kr-5.2.9.tgz",
|
||||
"integrity": "sha512-g1BnJdJbnAgRUP8YxyPIm8npZVUbtt6VgtLnkGR7poa/RVbVGd27i+9138DmwRvtbKhJG1fPLQ/V3BonvFykRQ==",
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-kr/-/noto-sans-kr-5.2.10.tgz",
|
||||
"integrity": "sha512-UZOO7HF44Rt5+7SCeFMHYVgbKu36Jet6IxrAd7jjEkQMVDmeefwd0H8V5pSZydvBOOxClk3V5cQsujJqGHhQMw==",
|
||||
"dev": true,
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
@@ -2117,9 +2117,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/noto-sans-sc": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.2.9.tgz",
|
||||
"integrity": "sha512-ZEEpZlxjYEIVdg85K38mqaoeBcorrN3Z6MaIkwK5w5Dqn/e9v5uVIYr0ukoLsFxaVyEXSi/c3caOeMHjbOMtfA==",
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.2.10.tgz",
|
||||
"integrity": "sha512-zdk10i5HrDQTXI7ldD61zToX1fsgig8vDTsu7zB48SXOitWfuX0e5viZAwnkHuhwh096PU6X6i1AyAsbBCISpA==",
|
||||
"dev": true,
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
@@ -2127,9 +2127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/noto-sans-tc": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-tc/-/noto-sans-tc-5.2.9.tgz",
|
||||
"integrity": "sha512-GhtbSE8IZTP3vZj7Fu1G/PERxguMe3jryAbHovSd22Rs7aYdbXQD8vBqkTT/tkHIUn6t2IFReTfgKUoQBPCe+w==",
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-tc/-/noto-sans-tc-5.2.10.tgz",
|
||||
"integrity": "sha512-COVssWiIp9VVfdGuHpbrcH8u9H5JK4Lm6U7oee2BD6aLwKzPshj49KGmmQe/r3Fcp4e4SSuCaqyN/BtDKxHOGQ==",
|
||||
"dev": true,
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
@@ -4372,63 +4372,63 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "10.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.33.0.tgz",
|
||||
"integrity": "sha512-nDJFHAfiFifBfJB0OF6DV6BIsIV5uah4lDsV4UBAgPBf+YAHclO10y1gi2U/JMh58c+s4lXi9p+PI1TFXZ0c6w==",
|
||||
"version": "10.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.34.0.tgz",
|
||||
"integrity": "sha512-0YNr60rGHyedmwkO0lbDBjNx2KAmT3kWamjaqu7Aw+jsESoPLgt+fzaTVvUBvkftBDui2PeTSzXm/nqzssctYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.33.0"
|
||||
"@sentry/core": "10.34.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/feedback": {
|
||||
"version": "10.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.33.0.tgz",
|
||||
"integrity": "sha512-sN/VLWtEf0BeV6w6wldIpTxUQxNVc9o9tjLRQa8je1ZV2FCgXA124Iff/zsowsz82dLqtg7qp6GA5zYXVq+JMA==",
|
||||
"version": "10.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.34.0.tgz",
|
||||
"integrity": "sha512-wgGnq+iNxsFSOe9WX/FOvtoItSTjgLJJ4dQkVYtcVM6WGBVIg4wgNYfECCnRNztUTPzpZHLjC9r+4Pym451DDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.33.0"
|
||||
"@sentry/core": "10.34.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay": {
|
||||
"version": "10.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.33.0.tgz",
|
||||
"integrity": "sha512-UOU9PYxuXnPop3HoQ3l4Q7SZUXJC3Vmfm0Adgad8U03UcrThWIHYc5CxECSrVzfDFNOT7w9o7HQgRAgWxBPMXg==",
|
||||
"version": "10.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.34.0.tgz",
|
||||
"integrity": "sha512-Vmea0GcOg57z/S1bVSj3saFcRvDqdLzdy4wd9fQMpMgy5OCbTlo7lxVUndKzbcZnanma6zF6VxwnWER1WuN9RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "10.33.0",
|
||||
"@sentry/core": "10.33.0"
|
||||
"@sentry-internal/browser-utils": "10.34.0",
|
||||
"@sentry/core": "10.34.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay-canvas": {
|
||||
"version": "10.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.33.0.tgz",
|
||||
"integrity": "sha512-MTmP6uoAVzw4CCPeqCgCLsRSiOfGLxgyMFjGTCW3E7t62MJ9S0H5sLsQ34sHxXUa1gFU9UNAjEvRRpZ0JvWrPw==",
|
||||
"version": "10.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.34.0.tgz",
|
||||
"integrity": "sha512-XWH/9njtgMD+LLWjc4KKgBpb+dTCkoUEIFDxcvzG/87d+jirmzf0+r8EfpLwKG+GrqNiiGRV39zIqu0SfPl+cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/replay": "10.33.0",
|
||||
"@sentry/core": "10.33.0"
|
||||
"@sentry-internal/replay": "10.34.0",
|
||||
"@sentry/core": "10.34.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/babel-plugin-component-annotate": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.1.tgz",
|
||||
"integrity": "sha512-aSIk0vgBqv7PhX6/Eov+vlI4puCE0bRXzUG5HdCsHBpAfeMkI8Hva6kSOusnzKqs8bf04hU7s3Sf0XxGTj/1AA==",
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.2.tgz",
|
||||
"integrity": "sha512-6VTjLJXtIHKwxMmThtZKwi1+hdklLNzlbYH98NhbH22/Vzb/c6BlSD2b5A0NGN9vFB807rD4x4tuP+Su7BxQXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4436,31 +4436,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "10.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.33.0.tgz",
|
||||
"integrity": "sha512-iWiPjik9zetM84jKfk01UveW1J0+X7w8XmJ8+IrhTyNDBVUWCRJWD8FrksiN1dRSg5mFWgfMRzKMz27hAScRwg==",
|
||||
"version": "10.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.34.0.tgz",
|
||||
"integrity": "sha512-8WCsAXli5Z+eIN8dMY8KGQjrS3XgUp1np/pjdeWNrVPVR8q8XpS34qc+f+y/LFrYQC9bs2Of5aIBwRtDCIvRsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "10.33.0",
|
||||
"@sentry-internal/feedback": "10.33.0",
|
||||
"@sentry-internal/replay": "10.33.0",
|
||||
"@sentry-internal/replay-canvas": "10.33.0",
|
||||
"@sentry/core": "10.33.0"
|
||||
"@sentry-internal/browser-utils": "10.34.0",
|
||||
"@sentry-internal/feedback": "10.34.0",
|
||||
"@sentry-internal/replay": "10.34.0",
|
||||
"@sentry-internal/replay-canvas": "10.34.0",
|
||||
"@sentry/core": "10.34.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugin-core": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.6.1.tgz",
|
||||
"integrity": "sha512-WPeRbnMXm927m4Kr69NTArPfI+p5/34FHftdCRI3LFPMyhZDzz6J3wLy4hzaVUgmMf10eLzmq2HGEMvpQmdynA==",
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.6.2.tgz",
|
||||
"integrity": "sha512-JkOc3JkVzi/fbXsFp8R9uxNKmBrPRaU4Yu4y1i3ihWfugqymsIYaN0ixLENZbGk2j4xGHIk20PAJzBJqBMTHew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.18.5",
|
||||
"@sentry/babel-plugin-component-annotate": "4.6.1",
|
||||
"@sentry/babel-plugin-component-annotate": "4.6.2",
|
||||
"@sentry/cli": "^2.57.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"find-up": "^5.0.0",
|
||||
@@ -4473,12 +4473,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli": {
|
||||
"version": "2.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.2.tgz",
|
||||
"integrity": "sha512-U4u62V4vaTWF+o40Mih8aOpQKqKUbZQt9A3LorIJwaE3tO3XFLRI70eWtW2se1Qmy0RZ74zB14nYcFNFl2t4Rw==",
|
||||
"version": "2.58.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.4.tgz",
|
||||
"integrity": "sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
@@ -4493,22 +4493,22 @@
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@sentry/cli-darwin": "2.58.2",
|
||||
"@sentry/cli-linux-arm": "2.58.2",
|
||||
"@sentry/cli-linux-arm64": "2.58.2",
|
||||
"@sentry/cli-linux-i686": "2.58.2",
|
||||
"@sentry/cli-linux-x64": "2.58.2",
|
||||
"@sentry/cli-win32-arm64": "2.58.2",
|
||||
"@sentry/cli-win32-i686": "2.58.2",
|
||||
"@sentry/cli-win32-x64": "2.58.2"
|
||||
"@sentry/cli-darwin": "2.58.4",
|
||||
"@sentry/cli-linux-arm": "2.58.4",
|
||||
"@sentry/cli-linux-arm64": "2.58.4",
|
||||
"@sentry/cli-linux-i686": "2.58.4",
|
||||
"@sentry/cli-linux-x64": "2.58.4",
|
||||
"@sentry/cli-win32-arm64": "2.58.4",
|
||||
"@sentry/cli-win32-i686": "2.58.4",
|
||||
"@sentry/cli-win32-x64": "2.58.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-darwin": {
|
||||
"version": "2.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.2.tgz",
|
||||
"integrity": "sha512-MArsb3zLhA2/cbd4rTm09SmTpnEuZCoZOpuZYkrpDw1qzBVJmRFA1W1hGAQ9puzBIk/ubY3EUhhzuU3zN2uD6w==",
|
||||
"version": "2.58.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.4.tgz",
|
||||
"integrity": "sha512-kbTD+P4X8O+nsNwPxCywtj3q22ecyRHWff98rdcmtRrvwz8CKi/T4Jxn/fnn2i4VEchy08OWBuZAqaA5Kh2hRQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -4518,14 +4518,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm": {
|
||||
"version": "2.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.2.tgz",
|
||||
"integrity": "sha512-HU9lTCzcHqCz/7Mt5n+cv+nFuJdc1hGD2h35Uo92GgxX3/IujNvOUfF+nMX9j6BXH6hUt73R5c0Ycq9+a3Parg==",
|
||||
"version": "2.58.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.4.tgz",
|
||||
"integrity": "sha512-rdQ8beTwnN48hv7iV7e7ZKucPec5NJkRdrrycMJMZlzGBPi56LqnclgsHySJ6Kfq506A2MNuQnKGaf/sBC9REA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
@@ -4537,14 +4537,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm64": {
|
||||
"version": "2.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.2.tgz",
|
||||
"integrity": "sha512-ay3OeObnbbPrt45cjeUyQjsx5ain1laj1tRszWj37NkKu55NZSp4QCg1gGBZ0gBGhckI9nInEsmKtix00alw2g==",
|
||||
"version": "2.58.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.4.tgz",
|
||||
"integrity": "sha512-0g0KwsOozkLtzN8/0+oMZoOuQ0o7W6O+hx+ydVU1bktaMGKEJLMAWxOQNjsh1TcBbNIXVOKM/I8l0ROhaAb8Ig==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
@@ -4556,15 +4556,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-i686": {
|
||||
"version": "2.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.2.tgz",
|
||||
"integrity": "sha512-CN9p0nfDFsAT1tTGBbzOUGkIllwS3hygOUyTK7LIm9z+UHw5uNgNVqdM/3Vg+02ymjkjISNB3/+mqEM5osGXdA==",
|
||||
"version": "2.58.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.4.tgz",
|
||||
"integrity": "sha512-NseoIQAFtkziHyjZNPTu1Gm1opeQHt7Wm1LbLrGWVIRvUOzlslO9/8i6wETUZ6TjlQxBVRgd3Q0lRBG2A8rFYA==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
@@ -4576,14 +4576,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-x64": {
|
||||
"version": "2.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.2.tgz",
|
||||
"integrity": "sha512-oX/LLfvWaJO50oBVOn4ZvG2SDWPq0MN8SV9eg5tt2nviq+Ryltfr7Rtoo+HfV+eyOlx1/ZXhq9Wm7OT3cQuz+A==",
|
||||
"version": "2.58.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.4.tgz",
|
||||
"integrity": "sha512-d3Arz+OO/wJYTqCYlSN3Ktm+W8rynQ/IMtSZLK8nu0ryh5mJOh+9XlXY6oDXw4YlsM8qCRrNquR8iEI1Y/IH+Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
@@ -4595,14 +4595,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-arm64": {
|
||||
"version": "2.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.2.tgz",
|
||||
"integrity": "sha512-+cl3x2HPVMpoSVGVM1IDWlAEREZrrVQj4xBb0TRKII7g3hUxRsAIcsrr7+tSkie++0FuH4go/b5fGAv51OEF3w==",
|
||||
"version": "2.58.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.4.tgz",
|
||||
"integrity": "sha512-bqYrF43+jXdDBh0f8HIJU3tbvlOFtGyRjHB8AoRuMQv9TEDUfENZyCelhdjA+KwDKYl48R1Yasb4EHNzsoO83w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -4612,15 +4612,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-i686": {
|
||||
"version": "2.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.2.tgz",
|
||||
"integrity": "sha512-omFVr0FhzJ8oTJSg1Kf+gjLgzpYklY0XPfLxZ5iiMiYUKwF5uo1RJRdkUOiEAv0IqpUKnmKcmVCLaDxsWclB7Q==",
|
||||
"version": "2.58.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.4.tgz",
|
||||
"integrity": "sha512-3triFD6jyvhVcXOmGyttf+deKZcC1tURdhnmDUIBkiDPJKGT/N5xa4qAtHJlAB/h8L9jgYih9bvJnvvFVM7yug==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -4630,14 +4630,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-x64": {
|
||||
"version": "2.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.2.tgz",
|
||||
"integrity": "sha512-2NAFs9UxVbRztQbgJSP5i8TB9eJQ7xraciwj/93djrSMHSEbJ0vC47TME0iifgvhlHMs5vqETOKJtfbbpQAQFA==",
|
||||
"version": "2.58.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.4.tgz",
|
||||
"integrity": "sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -4647,9 +4647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "10.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.33.0.tgz",
|
||||
"integrity": "sha512-ehH1VSUclIHZKEZVdv+klofsFIh8FFzqA6AAV23RtLepptzA8wqQzUGraEuSN25sYcNmYJ0jti5U0Ys+WZv5Dw==",
|
||||
"version": "10.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.34.0.tgz",
|
||||
"integrity": "sha512-4FFpYBMf0VFdPcsr4grDYDOR87mRu6oCfb51oQjU/Pndmty7UgYo0Bst3LEC/8v0SpytBtzXq+Wx/fkwulBesg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4657,13 +4657,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/vite-plugin": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-4.6.1.tgz",
|
||||
"integrity": "sha512-Qvys1y3o8/bfL3ikrHnJS9zxdjt0z3POshdBl3967UcflrTqBmnGNkcVk53SlmtJWIfh85fgmrLvGYwZ2YiqNg==",
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-4.6.2.tgz",
|
||||
"integrity": "sha512-hK9N50LlTaPlb2P1r87CFupU7MJjvtrp+Js96a2KDdiP8ViWnw4Gsa/OvA0pkj2wAFXFeBQMLS6g/SktTKG54w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/bundler-plugin-core": "4.6.1",
|
||||
"@sentry/bundler-plugin-core": "4.6.2",
|
||||
"unplugin": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4671,14 +4671,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/vue": {
|
||||
"version": "10.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.33.0.tgz",
|
||||
"integrity": "sha512-CUtoBl62DG8mkoYfgpkw2WdB187XA2CfPj7OJdIzt3lavhpSAPmsY4jUarK2RUJvcowr5zYbEfv50Y0tsQxuGA==",
|
||||
"version": "10.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.34.0.tgz",
|
||||
"integrity": "sha512-2c+s8pQKY/MTunwIgTsiMtq4c7cPYyhB1LFOZ/VJSQH8MLD7qzxG7ed8SOJ3NTnGn8a2TpW/vPtc3uh90zLH3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser": "10.33.0",
|
||||
"@sentry/core": "10.33.0"
|
||||
"@sentry/browser": "10.34.0",
|
||||
"@sentry/core": "10.34.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -5313,9 +5313,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.7.tgz",
|
||||
"integrity": "sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==",
|
||||
"version": "25.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
|
||||
"integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9004,14 +9004,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
|
||||
"integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
|
||||
"version": "5.5.5",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
|
||||
"integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.0",
|
||||
"synckit": "^0.11.7"
|
||||
"prettier-linter-helpers": "^1.0.1",
|
||||
"synckit": "^0.11.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -14900,9 +14900,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz",
|
||||
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -14917,9 +14917,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-linter-helpers": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
|
||||
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
|
||||
"integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17080,9 +17080,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.11",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
|
||||
"integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
|
||||
"version": "0.11.12",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
|
||||
"integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
18
package.json
18
package.json
@@ -35,19 +35,19 @@
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||
"@fontsource-variable/noto-sans-kr": "^5.2.9",
|
||||
"@fontsource-variable/noto-sans-sc": "^5.2.9",
|
||||
"@fontsource-variable/noto-sans-tc": "^5.2.9",
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.10",
|
||||
"@fontsource-variable/noto-sans-kr": "^5.2.10",
|
||||
"@fontsource-variable/noto-sans-sc": "^5.2.10",
|
||||
"@fontsource-variable/noto-sans-tc": "^5.2.10",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@kamiya4047/eslint-plugin-pretty-import": "^0.1.6",
|
||||
"@sentry/vite-plugin": "^4.6.1",
|
||||
"@sentry/vue": "^10.33.0",
|
||||
"@sentry/vite-plugin": "^4.6.2",
|
||||
"@sentry/vue": "^10.34.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tanstack/vue-virtual": "^3.13.18",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.0.7",
|
||||
"@types/node": "^25.0.8",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.3",
|
||||
@@ -67,7 +67,7 @@
|
||||
"esbuild-jest": "^0.5.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.2.0",
|
||||
@@ -75,7 +75,7 @@
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"noty": "^3.2.0-beta-deprecated",
|
||||
"pinia": "^3.0.4",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.8.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"remixicon": "^4.8.0",
|
||||
"sass-embedded": "^1.97.2",
|
||||
|
||||
36
src/App.vue
36
src/App.vue
@@ -1,32 +1,28 @@
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<el-config-provider
|
||||
:locale="/** @type {import('element-plus/es/locale').Language} */ (messages[locale].elementPlus)">
|
||||
<MacOSTitleBar></MacOSTitleBar>
|
||||
<MacOSTitleBar></MacOSTitleBar>
|
||||
|
||||
<div
|
||||
id="x-app"
|
||||
class="x-app"
|
||||
:class="{ 'with-macos-titlebar': isMacOS }"
|
||||
ondragenter="event.preventDefault()"
|
||||
ondragover="event.preventDefault()"
|
||||
ondrop="event.preventDefault()">
|
||||
<RouterView></RouterView>
|
||||
<Toaster position="top-center"></Toaster>
|
||||
<div
|
||||
id="x-app"
|
||||
class="x-app"
|
||||
:class="{ 'with-macos-titlebar': isMacOS }"
|
||||
ondragenter="event.preventDefault()"
|
||||
ondragover="event.preventDefault()"
|
||||
ondrop="event.preventDefault()">
|
||||
<RouterView></RouterView>
|
||||
<Toaster position="top-center"></Toaster>
|
||||
|
||||
<AlertDialogModal></AlertDialogModal>
|
||||
<PromptDialogModal></PromptDialogModal>
|
||||
<AlertDialogModal></AlertDialogModal>
|
||||
<PromptDialogModal></PromptDialogModal>
|
||||
|
||||
<VRCXUpdateDialog></VRCXUpdateDialog>
|
||||
</div>
|
||||
<div id="x-dialog-portal" class="x-dialog-portal"></div>
|
||||
</el-config-provider>
|
||||
<VRCXUpdateDialog></VRCXUpdateDialog>
|
||||
</div>
|
||||
<div id="x-dialog-portal" class="x-dialog-portal"></div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeMount, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
@@ -45,8 +41,6 @@
|
||||
|
||||
const isMacOS = computed(() => navigator.platform.includes('Mac'));
|
||||
|
||||
const { locale, messages } = useI18n();
|
||||
|
||||
initNoty();
|
||||
|
||||
const store = createGlobalStores();
|
||||
|
||||
177
src/components/BackToTop.vue
Normal file
177
src/components/BackToTop.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ArrowUp } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: [String, Object], default: null },
|
||||
|
||||
bottom: { type: Number, default: 20 },
|
||||
right: { type: Number, default: 20 },
|
||||
|
||||
visibilityHeight: { type: Number, default: 200 },
|
||||
|
||||
behavior: {
|
||||
type: String,
|
||||
default: 'smooth',
|
||||
validator: (value) => value === 'auto' || value === 'smooth'
|
||||
},
|
||||
|
||||
tooltip: { type: Boolean, default: true },
|
||||
tooltipText: { type: String, default: 'Back to top' },
|
||||
|
||||
teleport: { type: Boolean, default: true }
|
||||
});
|
||||
|
||||
const visible = ref(false);
|
||||
let containerEl = null;
|
||||
|
||||
function resolveTarget() {
|
||||
if (!props.target) return null;
|
||||
if (typeof props.target === 'string') {
|
||||
return document.querySelector(props.target);
|
||||
}
|
||||
|
||||
if (typeof props.target === 'object') {
|
||||
if ('value' in props.target) {
|
||||
return props.target.value;
|
||||
}
|
||||
if ('$el' in props.target) {
|
||||
return props.target.$el;
|
||||
}
|
||||
}
|
||||
|
||||
return props.target;
|
||||
}
|
||||
|
||||
function getScrollTop() {
|
||||
if (!containerEl || typeof containerEl.scrollTop !== 'number') {
|
||||
return window.scrollY || document.documentElement.scrollTop || 0;
|
||||
}
|
||||
return containerEl.scrollTop || 0;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
visible.value = getScrollTop() >= props.visibilityHeight;
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
const behavior = props.behavior === 'auto' ? 'auto' : 'smooth';
|
||||
if (!containerEl || typeof containerEl.scrollTo !== 'function') {
|
||||
window.scrollTo({ top: 0, behavior });
|
||||
return;
|
||||
}
|
||||
containerEl.scrollTo({ top: 0, behavior });
|
||||
}
|
||||
|
||||
function bind() {
|
||||
containerEl = resolveTarget();
|
||||
|
||||
const target = containerEl && typeof containerEl.addEventListener === 'function' ? containerEl : window;
|
||||
target.addEventListener('scroll', handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
}
|
||||
|
||||
function unbind() {
|
||||
const target = containerEl || window;
|
||||
target.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
bind();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.target,
|
||||
() => {
|
||||
unbind();
|
||||
bind();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unbind();
|
||||
});
|
||||
|
||||
const wrapperStyle = computed(
|
||||
() => `position:fixed; right:${props.right}px; bottom:${props.bottom}px; z-index:50;`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="teleport" to="body">
|
||||
<Transition name="back-to-top">
|
||||
<div v-if="visible" :style="wrapperStyle">
|
||||
<TooltipProvider v-if="tooltip">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
class="rounded-full shadow"
|
||||
aria-label="Back to top"
|
||||
@click="scrollToTop">
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{{ tooltipText }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
class="rounded-full shadow"
|
||||
aria-label="Back to top"
|
||||
@click="scrollToTop">
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<Transition v-else name="back-to-top">
|
||||
<div v-if="visible" :style="wrapperStyle">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
class="rounded-full shadow"
|
||||
aria-label="Back to top"
|
||||
@click="scrollToTop">
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.back-to-top-enter-active,
|
||||
.back-to-top-leave-active {
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.back-to-top-enter-from,
|
||||
.back-to-top-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.back-to-top-enter-to,
|
||||
.back-to-top-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.back-to-top-enter-active,
|
||||
.back-to-top-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,154 +1,332 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="fullscreenImageDialog.visible"
|
||||
class="fullscreen-image-overlay"
|
||||
:style="{ zIndex: overlayZIndex }"
|
||||
@click.self="closeDialog">
|
||||
<el-image
|
||||
v-if="fullscreenImageDialog.imageUrl"
|
||||
ref="imageRef"
|
||||
class="fullscreen-image"
|
||||
:src="fullscreenImageDialog.imageUrl"
|
||||
:preview-src-list="[fullscreenImageDialog.imageUrl]"
|
||||
:z-index="100000"
|
||||
fit="contain"
|
||||
preview-teleported
|
||||
hide-on-click-modal
|
||||
:initial-index="0"
|
||||
@load="handleImageLoad"
|
||||
@close="closeDialog">
|
||||
<template #toolbar="{ actions }">
|
||||
<Copy @click="copyImageToClipboard(fullscreenImageDialog.imageUrl)" class="toolbar-icon" />
|
||||
<Download
|
||||
@click="downloadAndSaveImage(fullscreenImageDialog.imageUrl, fullscreenImageDialog.fileName)"
|
||||
class="toolbar-icon" />
|
||||
<ZoomOut @click="actions('zoomOut')" class="toolbar-icon" />
|
||||
<ZoomIn @click="actions('zoomIn')" class="toolbar-icon" />
|
||||
<RotateCw @click="actions('clockwise')" class="toolbar-icon" />
|
||||
<RotateCcw @click="actions('anticlockwise')" class="toolbar-icon" />
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogPortal :to="portalTo">
|
||||
<RekaDialogOverlay class="fixed inset-0 bg-background/80 backdrop-blur-sm" @click="closeDialog" />
|
||||
|
||||
<RekaDialogContent
|
||||
class="fixed inset-0 p-6 sm:p-10 border-0 bg-transparent shadow-none outline-none"
|
||||
@open-auto-focus.prevent
|
||||
@close-auto-focus.prevent>
|
||||
<div ref="viewerEl" class="relative h-full w-full overflow-hidden select-none">
|
||||
<!-- toolbar -->
|
||||
<div
|
||||
class="absolute right-3 top-3 z-10 flex items-center gap-2 rounded-md bg-background/70 backdrop-blur px-2 py-1 border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="!imageUrl"
|
||||
@click="copyImageToClipboard(imageUrl)"
|
||||
aria-label="Copy">
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="!imageUrl"
|
||||
@click="downloadAndSaveImage(imageUrl, fullscreenImageDialog.fileName)"
|
||||
aria-label="Download">
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div class="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="zoomOutCenter"
|
||||
aria-label="Zoom out">
|
||||
<ZoomOut class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="zoomInCenter" aria-label="Zoom in">
|
||||
<ZoomIn class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="rotateCW"
|
||||
aria-label="Rotate clockwise">
|
||||
<RotateCw class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="rotateCCW"
|
||||
aria-label="Rotate counterclockwise">
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="resetTransform" aria-label="Reset">
|
||||
<RefreshCcw class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div class="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeDialog" aria-label="Close">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-full w-full flex items-center justify-center"
|
||||
@wheel="onWheel"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp">
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
class="max-h-full max-w-full x-viewer-img"
|
||||
:style="transformStyle"
|
||||
draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</RekaDialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Copy, Download, RotateCcw, RotateCw, ZoomIn, ZoomOut } from 'lucide-vue-next';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { Copy, Download, RefreshCcw, RotateCcw, RotateCw, X, ZoomIn, ZoomOut } from 'lucide-vue-next';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { DialogContent as RekaDialogContent, DialogOverlay as RekaDialogOverlay, DialogPortal } from 'reka-ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { acquireModalPortalLayer } from '@/lib/modalPortalLayers';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import Noty from 'noty';
|
||||
|
||||
import { escapeTag, extractFileId } from '../shared/utils';
|
||||
import { getNextDialogIndex } from '../shared/utils/base/ui';
|
||||
import { useGalleryStore } from '../stores';
|
||||
|
||||
const galleryStore = useGalleryStore();
|
||||
const { fullscreenImageDialog } = storeToRefs(galleryStore);
|
||||
|
||||
const imageRef = ref();
|
||||
const overlayZIndex = ref(4000);
|
||||
const viewerEl = ref(null);
|
||||
const portalLayer = acquireModalPortalLayer();
|
||||
const portalTo = portalLayer.element;
|
||||
|
||||
function showPreview() {
|
||||
nextTick(() => {
|
||||
imageRef.value?.showPreview?.();
|
||||
});
|
||||
const scale = ref(1);
|
||||
const rotate = ref(0); // deg
|
||||
const tx = ref(0);
|
||||
const ty = ref(0);
|
||||
|
||||
const isDragging = ref(false);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const startTx = ref(0);
|
||||
const startTy = ref(0);
|
||||
|
||||
const imageUrl = computed(() => fullscreenImageDialog.value.imageUrl || '');
|
||||
|
||||
const open = computed({
|
||||
get: () => fullscreenImageDialog.value.visible,
|
||||
set: (v) => {
|
||||
fullscreenImageDialog.value.visible = v;
|
||||
}
|
||||
});
|
||||
|
||||
function clamp(n, min, max) {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
function degToRad(deg) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function handleImageLoad() {
|
||||
showPreview();
|
||||
function resetTransform() {
|
||||
scale.value = 1;
|
||||
rotate.value = 0;
|
||||
tx.value = 0;
|
||||
ty.value = 0;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => fullscreenImageDialog.value.visible,
|
||||
(visible) => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
overlayZIndex.value = Math.max(getNextDialogIndex(), 4000);
|
||||
showPreview();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => fullscreenImageDialog.value.imageUrl,
|
||||
(url) => {
|
||||
if (!url || !fullscreenImageDialog.value.visible) {
|
||||
return;
|
||||
}
|
||||
showPreview();
|
||||
}
|
||||
);
|
||||
|
||||
function closeDialog() {
|
||||
fullscreenImageDialog.value.visible = false;
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(url) {
|
||||
if (!url) {
|
||||
function zoomAtCenter(factor) {
|
||||
const el = viewerEl.value;
|
||||
if (!el) {
|
||||
scale.value = clamp(scale.value * factor, 0.1, 10);
|
||||
return;
|
||||
}
|
||||
|
||||
scale.value = clamp(scale.value * factor, 0.1, 10);
|
||||
}
|
||||
|
||||
function zoomInCenter() {
|
||||
zoomAtCenter(1.2);
|
||||
}
|
||||
function zoomOutCenter() {
|
||||
zoomAtCenter(1 / 1.2);
|
||||
}
|
||||
|
||||
function rotateCW() {
|
||||
rotate.value = (rotate.value + 90) % 360;
|
||||
}
|
||||
function rotateCCW() {
|
||||
rotate.value = (rotate.value - 90 + 360) % 360;
|
||||
}
|
||||
|
||||
function zoomAtPointer(e, factor) {
|
||||
const el = viewerEl.value;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// mouse in container space
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
// container center
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
|
||||
const oldScale = scale.value;
|
||||
const newScale = clamp(oldScale * factor, 0.1, 10);
|
||||
|
||||
const r = degToRad(rotate.value);
|
||||
const cos = Math.cos(r);
|
||||
const sin = Math.sin(r);
|
||||
|
||||
// vector from transformed center (includes current translation)
|
||||
const vx = mx - cx - tx.value;
|
||||
const vy = my - cy - ty.value;
|
||||
|
||||
// inverse rotate + unscale => local point
|
||||
const ux = (vx * cos + vy * sin) / oldScale;
|
||||
const uy = (-vx * sin + vy * cos) / oldScale;
|
||||
|
||||
// forward rotate + scale => new vector
|
||||
const v2x = (ux * cos - uy * sin) * newScale;
|
||||
const v2y = (ux * sin + uy * cos) * newScale;
|
||||
|
||||
// keep pointer anchored
|
||||
tx.value = mx - cx - v2x;
|
||||
ty.value = my - cy - v2y;
|
||||
scale.value = newScale;
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
zoomAtPointer(e, factor);
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
if (e.button !== 0) return;
|
||||
isDragging.value = true;
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
dragStartX.value = e.clientX;
|
||||
dragStartY.value = e.clientY;
|
||||
startTx.value = tx.value;
|
||||
startTy.value = ty.value;
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!isDragging.value) return;
|
||||
const dx = e.clientX - dragStartX.value;
|
||||
const dy = e.clientY - dragStartY.value;
|
||||
tx.value = startTx.value + dx;
|
||||
ty.value = startTy.value + dy;
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
if (!isDragging.value) return;
|
||||
isDragging.value = false;
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
}
|
||||
|
||||
const transformStyle = computed(() => ({
|
||||
transform: `translate(${tx.value}px, ${ty.value}px) scale(${scale.value}) rotate(${rotate.value}deg)`,
|
||||
transformOrigin: 'center center'
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => open.value,
|
||||
(v) => {
|
||||
if (v) {
|
||||
portalLayer.bringToFront();
|
||||
resetTransform();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
portalLayer.release();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => imageUrl.value,
|
||||
(url) => {
|
||||
if (!url || !open.value) return;
|
||||
resetTransform();
|
||||
}
|
||||
);
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!open.value) return;
|
||||
if (e.key === '+' || e.key === '=') zoomInCenter();
|
||||
else if (e.key === '-' || e.key === '_') zoomOutCenter();
|
||||
else if (e.key.toLowerCase() === 'r') rotateCW();
|
||||
else if (e.key === '0') resetTransform();
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown));
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
|
||||
|
||||
async function copyImageToClipboard(url) {
|
||||
if (!url) return;
|
||||
const msg = toast.info('Downloading image...');
|
||||
try {
|
||||
const response = await webApiService.execute({
|
||||
url,
|
||||
method: 'GET'
|
||||
});
|
||||
if (response.status !== 200 || !response.data.startsWith('data:image/png')) {
|
||||
const response = await webApiService.execute({ url, method: 'GET' });
|
||||
if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) {
|
||||
throw new Error(`Error: ${response.data}`);
|
||||
}
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'image/png': await (await fetch(response.data)).blob()
|
||||
})
|
||||
]);
|
||||
const blob = await (await fetch(response.data)).blob();
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||
toast.success('Image copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error('Error downloading image:', error);
|
||||
new Noty({
|
||||
type: 'error',
|
||||
text: escapeTag(`Failed to download image. ${url}`)
|
||||
}).show();
|
||||
new Noty({ type: 'error', text: escapeTag(`Failed to download image. ${url}`) }).show();
|
||||
} finally {
|
||||
toast.dismiss(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndSaveImage(url, fileName) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
if (!url) return;
|
||||
const msg = toast.info('Downloading image...');
|
||||
try {
|
||||
const response = await webApiService.execute({
|
||||
url,
|
||||
method: 'GET'
|
||||
});
|
||||
if (response.status !== 200 || !response.data.startsWith('data:image/png')) {
|
||||
const response = await webApiService.execute({ url, method: 'GET' });
|
||||
if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) {
|
||||
throw new Error(`Error: ${response.data}`);
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = response.data;
|
||||
|
||||
const fileId = extractFileId(url);
|
||||
if (!fileName && fileId) {
|
||||
fileName = `${fileId}.png`;
|
||||
}
|
||||
if (!fileName) {
|
||||
fileName = `${url.split('/').pop()}.png`;
|
||||
}
|
||||
if (!fileName) {
|
||||
fileName = 'image.png';
|
||||
}
|
||||
link.setAttribute('download', fileName);
|
||||
let name = fileName;
|
||||
if (!name && fileId) name = `${fileId}.png`;
|
||||
if (!name) name = `${url.split('/').pop()}.png`;
|
||||
if (!name) name = 'image.png';
|
||||
|
||||
link.setAttribute('download', name);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error('Error downloading image:', error);
|
||||
new Noty({
|
||||
type: 'error',
|
||||
text: escapeTag(`Failed to download image. ${url}`)
|
||||
}).show();
|
||||
new Noty({ type: 'error', text: escapeTag(`Failed to download image. ${url}`) }).show();
|
||||
} finally {
|
||||
toast.dismiss(msg);
|
||||
}
|
||||
@@ -156,27 +334,12 @@
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-icon:hover {
|
||||
opacity: 1;
|
||||
.x-viewer-img {
|
||||
will-change: transform;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.toolbar-icon {
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.fullscreen-image-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.fullscreen-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
:deep(.el-image__preview) {
|
||||
display: none;
|
||||
.x-viewer-img:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
<template>
|
||||
<Popover v-model:open="visible">
|
||||
<PopoverTrigger asChild>
|
||||
<Button>
|
||||
{{ t('nav_menu.icon_picker.pick_icon') }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start" class="w-155">
|
||||
<div class="icon-picker">
|
||||
<InputGroupSearch
|
||||
v-model="search"
|
||||
class="icon-picker__search"
|
||||
:placeholder="t('nav_menu.icon_picker.search_placeholder')" />
|
||||
<el-scrollbar v-if="filteredCategories.length" height="600px" class="icon-picker__scroll">
|
||||
<div v-for="category in filteredCategories" :key="category.name" class="icon-picker__category">
|
||||
<div class="icon-picker__category-title">
|
||||
{{ category.name }}
|
||||
</div>
|
||||
<div class="icon-picker__grid">
|
||||
<div v-for="group in category.groups" :key="group.id" class="icon-picker__group">
|
||||
<div class="icon-picker__group-label">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<div class="icon-picker__variants">
|
||||
<button
|
||||
v-for="variant in group.variants"
|
||||
:key="variant.className"
|
||||
type="button"
|
||||
class="icon-picker__variant"
|
||||
:class="{ 'is-active': variant.className === modelValue }"
|
||||
:title="group.tooltip"
|
||||
@click="handleSelect(variant.className)">
|
||||
<i :class="[variant.className, 'ri-2x']"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<div v-else class="icon-picker__empty">{{ t('nav_menu.icon_picker.no_icon_found') }}</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InputGroupSearch } from '@/components/ui/input-group';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
|
||||
import remixIconTags from '../shared/constants/remixIconTags.json';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const visible = ref(false);
|
||||
const search = ref('');
|
||||
|
||||
const parseTags = (tagsText) =>
|
||||
typeof tagsText === 'string'
|
||||
? tagsText
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const formatLabel = (baseName) =>
|
||||
baseName
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(' ');
|
||||
|
||||
const createGroup = (categoryName, baseName, tagsText) => {
|
||||
const normalizedTags = parseTags(tagsText);
|
||||
const label = formatLabel(baseName);
|
||||
const variants = ['line', 'fill'].map((variant) => ({
|
||||
className: `ri-${baseName}-${variant}`,
|
||||
variant
|
||||
}));
|
||||
const searchText = [
|
||||
baseName,
|
||||
label,
|
||||
...baseName.split('-'),
|
||||
...normalizedTags,
|
||||
...variants.map((variant) => variant.className),
|
||||
'line',
|
||||
'fill'
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return {
|
||||
id: `${categoryName}-${baseName}`,
|
||||
label,
|
||||
tooltip: normalizedTags.length ? `${label} • ${normalizedTags.join(', ')}` : label,
|
||||
variants,
|
||||
searchable: searchText
|
||||
};
|
||||
};
|
||||
|
||||
const categories = computed(() =>
|
||||
Object.entries(remixIconTags)
|
||||
.filter(([key]) => key !== '_comment')
|
||||
.map(([name, icons]) => ({
|
||||
name,
|
||||
groups: Object.entries(icons || {}).map(([baseName, tags]) => createGroup(name, baseName, tags))
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return categories.value;
|
||||
}
|
||||
return categories.value
|
||||
.map((category) => ({
|
||||
name: category.name,
|
||||
groups: category.groups.filter((group) => group.searchable.includes(query))
|
||||
}))
|
||||
.filter((category) => category.groups.length > 0);
|
||||
});
|
||||
|
||||
const handleSelect = (className) => {
|
||||
emit('update:modelValue', className);
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
watch(visible, (nextVisible) => {
|
||||
if (!nextVisible) {
|
||||
search.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-picker__trigger i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.icon-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-picker__search {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-picker__scroll {
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.icon-picker__category {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.icon-picker__category-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.icon-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-picker__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-picker__group-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.icon-picker__variants {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-picker__variant {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 10px;
|
||||
color: var(--el-text-color-regular);
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-picker__variant i {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.icon-picker__variant:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.icon-picker__variant.is-active {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.icon-picker__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,11 +2,11 @@
|
||||
<div v-if="isVisible" class="inline-block">
|
||||
<TooltipWrapper side="top" :content="t('dialog.user.info.launch_invite_tooltip')"
|
||||
><Button
|
||||
class="rounded-full w-6 h-6 text-muted-foreground hover:text-foreground"
|
||||
class="rounded-full w-6 h-6 text-xs text-muted-foreground hover:text-foreground"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
@click="confirm"
|
||||
><Star />
|
||||
><LogIn />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Star } from 'lucide-vue-next';
|
||||
import { LogIn } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
<div v-if="!text" class="transparent">-</div>
|
||||
<div v-show="text" class="flex items-center">
|
||||
<div v-if="region" :class="['flags', 'mr-1.5', region]"></div>
|
||||
<TooltipWrapper
|
||||
:content="`${t('dialog.new_instance.instance_id')}: #${instanceName}`"
|
||||
:disabled="!instanceName || showInstanceIdInLocation"
|
||||
:delay-duration="300"
|
||||
side="top">
|
||||
<template v-if="disableTooltip">
|
||||
<div
|
||||
:class="['x-location', { 'x-link': link && location !== 'private' && location !== 'offline' }]"
|
||||
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden"
|
||||
@@ -21,10 +17,37 @@
|
||||
({{ groupName }})
|
||||
</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="isClosed" :content="t('dialog.user.info.instance_closed')">
|
||||
<AlertTriangle :class="['inline-block', 'ml-5']" style="color: lightcoral" />
|
||||
</TooltipWrapper>
|
||||
|
||||
<AlertTriangle v-if="isClosed" :class="['inline-block', 'ml-5']" style="color: lightcoral" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<TooltipWrapper
|
||||
:content="`${t('dialog.new_instance.instance_id')}: #${instanceName}`"
|
||||
:disabled="!instanceName || showInstanceIdInLocation"
|
||||
:delay-duration="300"
|
||||
side="top">
|
||||
<div
|
||||
:class="['x-location', { 'x-link': link && location !== 'private' && location !== 'offline' }]"
|
||||
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden"
|
||||
@click="handleShowWorldDialog">
|
||||
<Loader2 :class="['is-loading']" class="mr-1" v-if="isTraveling" />
|
||||
<span class="min-w-0 truncate">{{ text }}</span>
|
||||
<span v-if="showInstanceIdInLocation && instanceName" class="ml-1 whitespace-nowrap">{{
|
||||
` · #${instanceName}`
|
||||
}}</span>
|
||||
<span
|
||||
v-if="groupName"
|
||||
class="ml-0.5 whitespace-nowrap x-link"
|
||||
@click.stop="handleShowGroupDialog">
|
||||
({{ groupName }})
|
||||
</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="isClosed" :content="t('dialog.user.info.instance_closed')">
|
||||
<AlertTriangle :class="['inline-block', 'ml-5']" style="color: lightcoral" />
|
||||
</TooltipWrapper>
|
||||
</template>
|
||||
<Lock v-if="strict" :class="['inline-block', 'ml-5']" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,6 +94,10 @@
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
disableTooltip: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isOpenPreviousInstanceInfoDialog: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Lock, Unlock, AlertTriangle } from 'lucide-vue-next';
|
||||
import { AlertTriangle, Lock, Unlock } from 'lucide-vue-next';
|
||||
import { ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -239,13 +239,13 @@
|
||||
<CustomNavDialog
|
||||
v-model:visible="customNavDialogVisible"
|
||||
:layout="navLayout"
|
||||
:default-folder-icon="DEFAULT_FOLDER_ICON"
|
||||
@save="handleCustomNavSave"
|
||||
@reset="handleCustomNavReset" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||
import { ElMenu, ElMenuItem, ElPopover, ElSubMenu } from 'element-plus';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { dayjs } from 'element-plus';
|
||||
|
||||
100
src/components/PresetColorPicker.vue
Normal file
100
src/components/PresetColorPicker.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
|
||||
presets: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
|
||||
disabled: { type: Boolean, default: false },
|
||||
|
||||
clearable: { type: Boolean, default: false },
|
||||
emptyValue: { type: String, default: '' },
|
||||
|
||||
cols: { type: Number, default: 6 }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
function normalizeHex(v) {
|
||||
const s = String(v || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (/^#[0-9a-f]{6}$/.test(s)) return s;
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
const safeValue = computed(() => normalizeHex(props.modelValue));
|
||||
const displayText = computed(() => (props.modelValue ? String(props.modelValue) : props.emptyValue));
|
||||
|
||||
function setColor(color) {
|
||||
if (props.disabled) return;
|
||||
emit('update:modelValue', color);
|
||||
emit('change', color);
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
if (props.disabled) return;
|
||||
const v = e?.target?.value;
|
||||
setColor(String(v || ''));
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (props.disabled || !props.clearable) return;
|
||||
emit('update:modelValue', props.emptyValue);
|
||||
emit('change', props.emptyValue);
|
||||
}
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${Math.max(1, props.cols)}, minmax(0, 1fr))`
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="flex items-center gap-2 px-2" :disabled="disabled">
|
||||
<span class="h-4 w-4 rounded border" :style="{ backgroundColor: safeValue }" />
|
||||
<span class="font-mono text-xs opacity-80">
|
||||
{{ displayText }}
|
||||
</span>
|
||||
|
||||
<span v-if="clearable && modelValue" class="ml-1 opacity-60">✕</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-56 p-3">
|
||||
<div class="mb-3 grid gap-2" :style="gridStyle">
|
||||
<button
|
||||
v-for="color in presets"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="h-6 w-6 rounded border"
|
||||
:style="{ backgroundColor: color }"
|
||||
:disabled="disabled"
|
||||
:aria-disabled="disabled ? 'true' : 'false'"
|
||||
:class="[
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
safeValue === String(color).toLowerCase() ? 'ring-2 ring-offset-2' : ''
|
||||
]"
|
||||
@click="setColor(color)" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-full cursor-pointer border-none bg-transparent p-0"
|
||||
:value="safeValue"
|
||||
:disabled="disabled"
|
||||
@input="onInput" />
|
||||
|
||||
<div v-if="clearable" class="mt-3 flex justify-end">
|
||||
<Button variant="ghost" size="sm" :disabled="disabled" @click="clear"> Clear </Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -5,81 +5,83 @@
|
||||
<DialogTitle>{{ t('dialog.favorite.header') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div v-loading="loading">
|
||||
<span style="display: block; text-align: center">{{ t('dialog.favorite.vrchat_favorites') }}</span>
|
||||
<template v-if="favoriteDialog.currentGroup && favoriteDialog.currentGroup.key">
|
||||
<Button
|
||||
variant="outline"
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="deleteFavoriteNoConfirm(favoriteDialog.objectId)">
|
||||
<Check />{{ favoriteDialog.currentGroup.displayName }} ({{ favoriteDialog.currentGroup.count }} /
|
||||
{{ favoriteDialog.currentGroup.capacity }})
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="outline"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="addFavorite(group)">
|
||||
{{ group.displayName }} ({{ group.count }} / {{ group.capacity }})
|
||||
</Button>
|
||||
</template>
|
||||
<span style="display: block; text-align: center">{{ t('dialog.favorite.vrchat_favorites') }}</span>
|
||||
<template v-if="favoriteDialog.currentGroup && favoriteDialog.currentGroup.key">
|
||||
<Button
|
||||
variant="outline"
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="deleteFavoriteNoConfirm(favoriteDialog.objectId)">
|
||||
<Check />{{ favoriteDialog.currentGroup.displayName }} ({{
|
||||
favoriteDialog.currentGroup.count
|
||||
}}
|
||||
/ {{ favoriteDialog.currentGroup.capacity }})
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="outline"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="addFavorite(group)">
|
||||
{{ group.displayName }} ({{ group.count }} / {{ group.capacity }})
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="favoriteDialog.type === 'world'" style="margin-top: 20px">
|
||||
<span style="display: block; text-align: center">{{ t('dialog.favorite.local_favorites') }}</span>
|
||||
<template v-for="group in localWorldFavoriteGroups" :key="group">
|
||||
<Button
|
||||
variant="outline"
|
||||
v-if="hasLocalWorldFavorite(favoriteDialog.objectId, group)"
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="removeLocalWorldFavorite(favoriteDialog.objectId, group)">
|
||||
<Check />{{ group }} ({{ localWorldFavGroupLength(group) }})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
v-else
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="addLocalWorldFavorite(favoriteDialog.objectId, group)">
|
||||
{{ group }} ({{ localWorldFavGroupLength(group) }})
|
||||
</Button>
|
||||
</template>
|
||||
<span style="display: block; text-align: center">{{ t('dialog.favorite.local_favorites') }}</span>
|
||||
<template v-for="group in localWorldFavoriteGroups" :key="group">
|
||||
<Button
|
||||
variant="outline"
|
||||
v-if="hasLocalWorldFavorite(favoriteDialog.objectId, group)"
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="removeLocalWorldFavorite(favoriteDialog.objectId, group)">
|
||||
<Check />{{ group }} ({{ localWorldFavGroupLength(group) }})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
v-else
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="addLocalWorldFavorite(favoriteDialog.objectId, group)">
|
||||
{{ group }} ({{ localWorldFavGroupLength(group) }})
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="favoriteDialog.type === 'avatar'" style="margin-top: 20px">
|
||||
<span style="text-align: center">{{ t('dialog.favorite.local_avatar_favorites') }}</span>
|
||||
<template v-for="group in localAvatarFavoriteGroups" :key="group">
|
||||
<Button
|
||||
variant="outline"
|
||||
v-if="hasLocalAvatarFavorite(favoriteDialog.objectId, group)"
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="removeLocalAvatarFavorite(favoriteDialog.objectId, group)">
|
||||
<Check />{{ group }} ({{ localAvatarFavGroupLength(group) }})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
v-else
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
:disabled="!isLocalUserVrcPlusSupporter"
|
||||
@click="addLocalAvatarFavorite(favoriteDialog.objectId, group)">
|
||||
{{ group }} ({{ localAvatarFavGroupLength(group) }})
|
||||
</Button>
|
||||
</template>
|
||||
<span style="text-align: center">{{ t('dialog.favorite.local_avatar_favorites') }}</span>
|
||||
<template v-for="group in localAvatarFavoriteGroups" :key="group">
|
||||
<Button
|
||||
variant="outline"
|
||||
v-if="hasLocalAvatarFavorite(favoriteDialog.objectId, group)"
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
@click="removeLocalAvatarFavorite(favoriteDialog.objectId, group)">
|
||||
<Check />{{ group }} ({{ localAvatarFavGroupLength(group) }})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
v-else
|
||||
style="width: 100%; white-space: initial"
|
||||
class="my-1"
|
||||
:disabled="!isLocalUserVrcPlusSupporter"
|
||||
@click="addLocalAvatarFavorite(favoriteDialog.objectId, group)">
|
||||
{{ group }} ({{ localAvatarFavGroupLength(group) }})
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check } from 'lucide-vue-next';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
|
||||
@@ -4,51 +4,20 @@
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('nav_menu.custom_nav.dialog_title') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="custom-nav-dialog__list" v-if="localLayout.length">
|
||||
<div
|
||||
v-for="(entry, index) in localLayout"
|
||||
:key="entry.key || entry.id"
|
||||
:class="['custom-nav-entry', `custom-nav-entry--${entry.type}`]">
|
||||
<template v-if="entry.type === 'item'">
|
||||
<div class="custom-nav-entry__info">
|
||||
<i :class="definitionsMap.get(entry.key)?.icon"></i>
|
||||
<span>{{ t(definitionsMap.get(entry.key)?.labelKey || entry.key) }}</span>
|
||||
</div>
|
||||
<div class="custom-nav-entry__controls">
|
||||
<div class="custom-nav-entry__move">
|
||||
<Button
|
||||
class="rounded-full w-6 h-6 text-xs"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === 0"
|
||||
@click="handleMoveEntry(index, -1)">
|
||||
<i class="ri-arrow-up-line"></i>
|
||||
</Button>
|
||||
<Button
|
||||
class="rounded-full w-6 h-6 text-xs"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === localLayout.length - 1"
|
||||
@click="handleMoveEntry(index, 1)">
|
||||
<i class="ri-arrow-down-line"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="custom-nav-entry__folder-header">
|
||||
<div class="custom-nav-dialog__list" v-if="localLayout.length">
|
||||
<div
|
||||
v-for="(entry, index) in localLayout"
|
||||
:key="entry.key || entry.id"
|
||||
:class="['custom-nav-entry', `custom-nav-entry--${entry.type}`]">
|
||||
<template v-if="entry.type === 'item'">
|
||||
<div class="custom-nav-entry__info">
|
||||
<i :class="entry.icon || defaultFolderIcon"></i>
|
||||
<span>{{ entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder') }}</span>
|
||||
<i :class="definitionsMap.get(entry.key)?.icon"></i>
|
||||
<span>{{ t(definitionsMap.get(entry.key)?.labelKey || entry.key) }}</span>
|
||||
</div>
|
||||
<div class="custom-nav-entry__actions">
|
||||
<Button size="icon-sm w-6 h-6 text-xs" variant="outline" @click="openFolderEditor(index)">
|
||||
<i class="ri-edit-box-line"></i>
|
||||
{{ t('nav_menu.custom_nav.edit_folder') }}
|
||||
</Button>
|
||||
<div class="custom-nav-entry__controls">
|
||||
<div class="custom-nav-entry__move">
|
||||
<Button
|
||||
class="rounded-full text-xs w-6 h-6"
|
||||
class="rounded-full w-6 h-6 text-xs"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === 0"
|
||||
@@ -56,7 +25,7 @@
|
||||
<i class="ri-arrow-up-line"></i>
|
||||
</Button>
|
||||
<Button
|
||||
class="rounded-full text-xs w-6 h-6"
|
||||
class="rounded-full w-6 h-6 text-xs"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === localLayout.length - 1"
|
||||
@@ -65,54 +34,90 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-nav-entry__folder-items">
|
||||
<template v-if="entry.items?.length">
|
||||
<Badge
|
||||
v-for="key in entry.items"
|
||||
:key="`${entry.id}-${key}`"
|
||||
variant="outline"
|
||||
class="custom-nav-entry__folder-tag">
|
||||
{{ t(definitionsMap.get(key)?.labelKey || key) }}
|
||||
</Badge>
|
||||
</template>
|
||||
<span v-else class="custom-nav-entry__folder-empty">
|
||||
{{ t('nav_menu.custom_nav.folder_empty') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="custom-nav-entry__folder-header">
|
||||
<div class="custom-nav-entry__info">
|
||||
<i :class="entry.icon || defaultFolderIcon"></i>
|
||||
<span>{{
|
||||
entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="custom-nav-entry__actions">
|
||||
<Button
|
||||
size="icon-sm w-6 h-6 text-xs"
|
||||
variant="outline"
|
||||
@click="openFolderEditor(index)">
|
||||
<i class="ri-edit-box-line"></i>
|
||||
{{ t('nav_menu.custom_nav.edit_folder') }}
|
||||
</Button>
|
||||
<div class="custom-nav-entry__move">
|
||||
<Button
|
||||
class="rounded-full text-xs w-6 h-6"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === 0"
|
||||
@click="handleMoveEntry(index, -1)">
|
||||
<i class="ri-arrow-up-line"></i>
|
||||
</Button>
|
||||
<Button
|
||||
class="rounded-full text-xs w-6 h-6"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === localLayout.length - 1"
|
||||
@click="handleMoveEntry(index, 1)">
|
||||
<i class="ri-arrow-down-line"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-nav-entry__folder-items">
|
||||
<template v-if="entry.items?.length">
|
||||
<Badge
|
||||
v-for="key in entry.items"
|
||||
:key="`${entry.id}-${key}`"
|
||||
variant="outline"
|
||||
class="custom-nav-entry__folder-tag">
|
||||
{{ t(definitionsMap.get(key)?.labelKey || key) }}
|
||||
</Badge>
|
||||
</template>
|
||||
<span v-else class="custom-nav-entry__folder-empty">
|
||||
{{ t('nav_menu.custom_nav.folder_empty') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="invalidFolders.length"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
:title="t('nav_menu.custom_nav.invalid_folder')" />
|
||||
<!-- <el-alert
|
||||
v-if="invalidFolders.length"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
:title="t('nav_menu.custom_nav.invalid_folder')" /> -->
|
||||
<DialogFooter>
|
||||
<div class="custom-nav-dialog__footer">
|
||||
<div class="custom-nav-dialog__footer-left">
|
||||
<Button variant="outline" @click="openFolderEditor()">
|
||||
{{ t('nav_menu.custom_nav.add_folder') }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="handleReset">
|
||||
{{ t('nav_menu.custom_nav.restore_default') }}
|
||||
</Button>
|
||||
<div class="custom-nav-dialog__footer">
|
||||
<div class="custom-nav-dialog__footer-left">
|
||||
<Button variant="outline" @click="openFolderEditor()">
|
||||
{{ t('nav_menu.custom_nav.add_folder') }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="handleReset">
|
||||
{{ t('nav_menu.custom_nav.restore_default') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="custom-nav-dialog__footer-right">
|
||||
<Button variant="secondary" @click="handleClose">
|
||||
{{ t('nav_menu.custom_nav.cancel') }}
|
||||
</Button>
|
||||
<Button :disabled="isSaveDisabled" @click="handleSave">
|
||||
{{ t('nav_menu.custom_nav.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-nav-dialog__footer-right">
|
||||
<Button variant="secondary" @click="handleClose">
|
||||
{{ t('nav_menu.custom_nav.cancel') }}
|
||||
</Button>
|
||||
<Button :disabled="isSaveDisabled" @click="handleSave">
|
||||
{{ t('nav_menu.custom_nav.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="folderEditor.visible">
|
||||
<DialogContent class="folder-editor-dialog">
|
||||
<DialogContent class="folder-editor-dialog sm:max-w-[50vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{
|
||||
@@ -122,113 +127,151 @@
|
||||
}}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="folder-editor">
|
||||
<div class="folder-editor__form">
|
||||
<InputGroupField
|
||||
v-model="folderEditor.data.name"
|
||||
:placeholder="t('nav_menu.custom_nav.folder_name_placeholder')" />
|
||||
<IconPicker v-model="folderEditor.data.icon" class="folder-editor__icon-picker" />
|
||||
</div>
|
||||
<div class="folder-editor__lists">
|
||||
<div class="folder-editor__column">
|
||||
<div class="folder-editor__column-title">
|
||||
{{ t('nav_menu.custom_nav.folder_available') }}
|
||||
</div>
|
||||
<div v-if="!folderEditorAvailableItems.length" class="folder-editor__empty">
|
||||
{{ t('nav_menu.custom_nav.folder_empty') }}
|
||||
</div>
|
||||
<el-scrollbar v-else always class="folder-editor__scroll">
|
||||
<div v-for="item in folderEditorAvailableItems" :key="item.key" class="folder-editor__option">
|
||||
<label class="folder-editor__option-label">
|
||||
<Checkbox
|
||||
:model-value="folderEditor.data.items.includes(item.key)"
|
||||
@update:modelValue="(val) => toggleFolderItem(item.key, val)" />
|
||||
<span>
|
||||
<i :class="item.icon"></i>
|
||||
{{ t(item.labelKey) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<div class="folder-editor">
|
||||
<div class="folder-editor__form">
|
||||
<InputGroupField
|
||||
class="col-span-2"
|
||||
v-model="folderEditor.data.name"
|
||||
:placeholder="t('nav_menu.custom_nav.folder_name_placeholder')" />
|
||||
<InputGroupField
|
||||
class="col-span-2"
|
||||
v-model="folderEditor.data.icon"
|
||||
:placeholder="t('nav_menu.custom_nav.folder_icon_placeholder')">
|
||||
<template #trailing>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger as-child>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
:aria-label="t('nav_menu.custom_nav.folder_icon_placeholder')">
|
||||
<LinkIcon class="size-3.5" />
|
||||
</InputGroupButton>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="bottom" align="end" class="w-80">
|
||||
<div class="text-sm leading-snug">
|
||||
<div>
|
||||
Find the icon you want on this site and paste its class name here, e.g.
|
||||
<span class="font-mono">ri-arrow-left-up-line</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a
|
||||
class="x-link"
|
||||
@click.prevent="openExternalLink('https://remixicon.com/')">
|
||||
https://remixicon.com/
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</template>
|
||||
</InputGroupField>
|
||||
</div>
|
||||
<div class="folder-editor__column folder-editor__column--selected">
|
||||
<div class="folder-editor__column-title">
|
||||
{{ t('nav_menu.custom_nav.folder_selected') }}
|
||||
</div>
|
||||
<div v-if="!folderEditor.data.items.length" class="folder-editor__empty">
|
||||
{{ t('nav_menu.custom_nav.folder_selected_empty') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(key, index) in folderEditor.data.items"
|
||||
:key="`selected-${key}`"
|
||||
class="folder-editor__selected-item">
|
||||
<div class="folder-editor__selected-label">
|
||||
<i :class="definitionsMap.get(key)?.icon"></i>
|
||||
<span>{{ t(definitionsMap.get(key)?.labelKey || key) }}</span>
|
||||
<div class="folder-editor__lists">
|
||||
<div class="folder-editor__column">
|
||||
<div class="folder-editor__column-title">
|
||||
{{ t('nav_menu.custom_nav.folder_available') }}
|
||||
</div>
|
||||
<div class="folder-editor__selected-actions">
|
||||
<div class="custom-nav-entry__move">
|
||||
<Button
|
||||
class="rounded-full text-xs w-6 h-6"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === 0"
|
||||
@click="handleFolderItemMove(index, -1)">
|
||||
<i class="ri-arrow-up-line"></i>
|
||||
</Button>
|
||||
<Button
|
||||
class="rounded-full text-xs w-6 h-6"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === folderEditor.data.items.length - 1"
|
||||
@click="handleFolderItemMove(index, 1)">
|
||||
<i class="ri-arrow-down-line"></i>
|
||||
<div v-if="!folderEditorAvailableItems.length" class="folder-editor__empty">
|
||||
{{ t('nav_menu.custom_nav.folder_empty') }}
|
||||
</div>
|
||||
<ScrollArea v-else type="always" class="folder-editor__scroll">
|
||||
<div
|
||||
v-for="item in folderEditorAvailableItems"
|
||||
:key="item.key"
|
||||
class="folder-editor__option">
|
||||
<label class="folder-editor__option-label">
|
||||
<Checkbox
|
||||
:model-value="folderEditor.data.items.includes(item.key)"
|
||||
@update:modelValue="(val) => toggleFolderItem(item.key, val)" />
|
||||
<span>
|
||||
<i :class="item.icon"></i>
|
||||
{{ t(item.labelKey) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div class="folder-editor__column folder-editor__column--selected">
|
||||
<div class="folder-editor__column-title">
|
||||
{{ t('nav_menu.custom_nav.folder_selected') }}
|
||||
</div>
|
||||
<div v-if="!folderEditor.data.items.length" class="folder-editor__empty">
|
||||
{{ t('nav_menu.custom_nav.folder_selected_empty') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(key, index) in folderEditor.data.items"
|
||||
:key="`selected-${key}`"
|
||||
class="folder-editor__selected-item">
|
||||
<div class="folder-editor__selected-label">
|
||||
<i :class="definitionsMap.get(key)?.icon"></i>
|
||||
<span>{{ t(definitionsMap.get(key)?.labelKey || key) }}</span>
|
||||
</div>
|
||||
<div class="folder-editor__selected-actions">
|
||||
<div class="custom-nav-entry__move">
|
||||
<Button
|
||||
class="rounded-full text-xs w-6 h-6"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === 0"
|
||||
@click="handleFolderItemMove(index, -1)">
|
||||
<i class="ri-arrow-up-line"></i>
|
||||
</Button>
|
||||
<Button
|
||||
class="rounded-full text-xs w-6 h-6"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
:disabled="index === folderEditor.data.items.length - 1"
|
||||
@click="handleFolderItemMove(index, 1)">
|
||||
<i class="ri-arrow-down-line"></i>
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" @click="toggleFolderItem(key, false)">
|
||||
{{ t('nav_menu.custom_nav.remove_from_folder') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" @click="toggleFolderItem(key, false)">
|
||||
{{ t('nav_menu.custom_nav.remove_from_folder') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div class="folder-editor__footer">
|
||||
<Button
|
||||
variant="destructive"
|
||||
v-if="folderEditor.isEditing"
|
||||
:disabled="!canDeleteFolder"
|
||||
@click="handleFolderEditorDelete">
|
||||
{{ t('nav_menu.custom_nav.delete_folder') }}
|
||||
</Button>
|
||||
<div class="folder-editor__footer-spacer"></div>
|
||||
<Button variant="secondary" class="mr-2" @click="closeFolderEditor">
|
||||
{{ t('nav_menu.custom_nav.cancel') }}
|
||||
</Button>
|
||||
<Button :disabled="folderEditorSaveDisabled" @click="handleFolderEditorSave">
|
||||
{{ t('nav_menu.custom_nav.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="folder-editor__footer">
|
||||
<Button
|
||||
variant="destructive"
|
||||
v-if="folderEditor.isEditing"
|
||||
:disabled="!canDeleteFolder"
|
||||
@click="handleFolderEditorDelete">
|
||||
{{ t('nav_menu.custom_nav.delete_folder') }}
|
||||
</Button>
|
||||
<div class="folder-editor__footer-spacer"></div>
|
||||
<Button variant="secondary" class="mr-2" @click="closeFolderEditor">
|
||||
{{ t('nav_menu.custom_nav.cancel') }}
|
||||
</Button>
|
||||
<Button :disabled="folderEditorSaveDisabled" @click="handleFolderEditorSave">
|
||||
{{ t('nav_menu.custom_nav.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link as LinkIcon } from 'lucide-vue-next';
|
||||
import { openExternalLink } from '@/shared/utils/common';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { InputGroupButton, InputGroupField } from '../ui/input-group';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { InputGroupField } from '../ui/input-group';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { navDefinitions } from '../../shared/constants/ui.js';
|
||||
|
||||
import IconPicker from '../IconPicker.vue';
|
||||
// import IconPicker from '../IconPicker.vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@@ -240,8 +283,7 @@
|
||||
default: () => []
|
||||
},
|
||||
defaultFolderIcon: {
|
||||
type: String,
|
||||
default: 'ri-menu-fold-line'
|
||||
type: String
|
||||
}
|
||||
});
|
||||
|
||||
@@ -258,7 +300,7 @@
|
||||
type: 'folder',
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
icon: entry.icon || props.defaultFolderIcon,
|
||||
icon: entry.icon,
|
||||
items: Array.isArray(entry.items) ? [...entry.items] : []
|
||||
};
|
||||
}
|
||||
@@ -358,14 +400,14 @@
|
||||
folderEditor.data = {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
icon: entry.icon || props.defaultFolderIcon,
|
||||
icon: entry.icon,
|
||||
items: Array.isArray(entry.items) ? [...entry.items] : []
|
||||
};
|
||||
} else {
|
||||
folderEditor.data = {
|
||||
id: `custom-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 7)}`,
|
||||
name: '',
|
||||
icon: props.defaultFolderIcon,
|
||||
icon: '',
|
||||
items: []
|
||||
};
|
||||
}
|
||||
@@ -399,6 +441,7 @@
|
||||
|
||||
const applyFolderChanges = () => {
|
||||
const sanitizedItems = folderEditor.data.items.filter((key) => definitionsMap.value.has(key));
|
||||
const sanitizedIcon = folderEditor.data.icon?.trim() || '';
|
||||
const entries = [...localLayout.value];
|
||||
|
||||
if (folderEditor.isEditing) {
|
||||
@@ -421,7 +464,7 @@
|
||||
type: 'folder',
|
||||
id: folderEditor.data.id,
|
||||
name: folderEditor.data.name.trim(),
|
||||
icon: folderEditor.data.icon || props.defaultFolderIcon,
|
||||
icon: sanitizedIcon,
|
||||
items: sanitizedItems
|
||||
});
|
||||
|
||||
@@ -442,7 +485,7 @@
|
||||
type: 'folder',
|
||||
id: folderEditor.data.id,
|
||||
name: folderEditor.data.name.trim(),
|
||||
icon: folderEditor.data.icon || props.defaultFolderIcon,
|
||||
icon: sanitizedIcon,
|
||||
items: sanitizedItems
|
||||
});
|
||||
|
||||
|
||||
@@ -6,64 +6,64 @@
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="inviteGroupDialog.visible" v-loading="inviteGroupDialog.loading">
|
||||
<span>{{ t('dialog.invite_to_group.description') }}</span>
|
||||
<br />
|
||||
<span>{{ t('dialog.invite_to_group.description') }}</span>
|
||||
<br />
|
||||
|
||||
<div style="margin-top: 15px; width: 100%">
|
||||
<VirtualCombobox
|
||||
v-model="inviteGroupDialog.groupId"
|
||||
:groups="groupPickerGroups"
|
||||
:disabled="inviteGroupDialog.loading"
|
||||
:placeholder="t('dialog.invite_to_group.choose_group_placeholder')"
|
||||
:search-placeholder="t('dialog.invite_to_group.choose_group_placeholder')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<div class="avatar">
|
||||
<img :src="item.iconUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="name" v-text="item.label"></span>
|
||||
</div>
|
||||
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</div>
|
||||
|
||||
<div style="width: 100%; margin-top: 15px">
|
||||
<VirtualCombobox
|
||||
v-model="inviteGroupDialog.userIds"
|
||||
:groups="friendPickerGroups"
|
||||
multiple
|
||||
:disabled="inviteGroupDialog.loading"
|
||||
:placeholder="t('dialog.invite_to_group.choose_friends_placeholder')"
|
||||
:search-placeholder="t('dialog.invite_to_group.choose_friends_placeholder')"
|
||||
:clearable="true">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<template v-if="item.user">
|
||||
<div class="avatar" :class="userStatusClass(item.user)">
|
||||
<img :src="userImage(item.user)" loading="lazy" />
|
||||
<div style="margin-top: 15px; width: 100%">
|
||||
<VirtualCombobox
|
||||
v-model="inviteGroupDialog.groupId"
|
||||
:groups="groupPickerGroups"
|
||||
:disabled="inviteGroupDialog.loading"
|
||||
:placeholder="t('dialog.invite_to_group.choose_group_placeholder')"
|
||||
:search-placeholder="t('dialog.invite_to_group.choose_group_placeholder')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<div class="avatar">
|
||||
<img :src="item.iconUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span
|
||||
class="name"
|
||||
:style="{ color: item.user.$userColour }"
|
||||
v-text="item.user.displayName"></span>
|
||||
<span class="name" v-text="item.label"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-text="item.label"></span>
|
||||
</template>
|
||||
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</div>
|
||||
|
||||
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</div>
|
||||
<div style="width: 100%; margin-top: 15px">
|
||||
<VirtualCombobox
|
||||
v-model="inviteGroupDialog.userIds"
|
||||
:groups="friendPickerGroups"
|
||||
multiple
|
||||
:disabled="inviteGroupDialog.loading"
|
||||
:placeholder="t('dialog.invite_to_group.choose_friends_placeholder')"
|
||||
:search-placeholder="t('dialog.invite_to_group.choose_friends_placeholder')"
|
||||
:clearable="true">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<template v-if="item.user">
|
||||
<div class="avatar" :class="userStatusClass(item.user)">
|
||||
<img :src="userImage(item.user)" loading="lazy" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span
|
||||
class="name"
|
||||
:style="{ color: item.user.$userColour }"
|
||||
v-text="item.user.displayName"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-text="item.label"></span>
|
||||
</template>
|
||||
|
||||
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -80,9 +80,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { computed, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Check as CheckIcon } from 'lucide-vue-next';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
@@ -5,69 +5,69 @@
|
||||
<DialogTitle>{{ t('dialog.launch.header') }}</DialogTitle>
|
||||
<DialogDescription class="sr-only">{{ t('dialog.launch.header') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup class="gap-4">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.launch.url') }}</FieldLabel>
|
||||
<FieldContent class="flex-row items-center gap-2">
|
||||
<InputGroupField
|
||||
v-model="launchDialog.url"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
|
||||
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@click="copyInstanceMessage(launchDialog.url)"
|
||||
><Copy
|
||||
/></Button>
|
||||
</TooltipWrapper>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="launchDialog.shortUrl">
|
||||
<FieldLabel>
|
||||
<span class="flex items-center gap-1">
|
||||
<span>{{ t('dialog.launch.short_url') }}</span>
|
||||
<TooltipWrapper side="top" :content="t('dialog.launch.short_url_notice')">
|
||||
<AlertTriangle />
|
||||
<FieldGroup class="gap-4">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.launch.url') }}</FieldLabel>
|
||||
<FieldContent class="flex-row items-center gap-2">
|
||||
<InputGroupField
|
||||
v-model="launchDialog.url"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
|
||||
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@click="copyInstanceMessage(launchDialog.url)"
|
||||
><Copy
|
||||
/></Button>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<FieldContent class="flex-row items-center gap-2">
|
||||
<InputGroupField
|
||||
v-model="launchDialog.shortUrl"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
|
||||
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@click="copyInstanceMessage(launchDialog.shortUrl)"
|
||||
><Copy
|
||||
/></Button>
|
||||
</TooltipWrapper>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.launch.location') }}</FieldLabel>
|
||||
<FieldContent class="flex-row items-center gap-2">
|
||||
<InputGroupField
|
||||
v-model="launchDialog.location"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
|
||||
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@click="copyInstanceMessage(launchDialog.location)"
|
||||
><Copy
|
||||
/></Button>
|
||||
</TooltipWrapper>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="launchDialog.shortUrl">
|
||||
<FieldLabel>
|
||||
<span class="flex items-center gap-1">
|
||||
<span>{{ t('dialog.launch.short_url') }}</span>
|
||||
<TooltipWrapper side="top" :content="t('dialog.launch.short_url_notice')">
|
||||
<AlertTriangle />
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<FieldContent class="flex-row items-center gap-2">
|
||||
<InputGroupField
|
||||
v-model="launchDialog.shortUrl"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
|
||||
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@click="copyInstanceMessage(launchDialog.shortUrl)"
|
||||
><Copy
|
||||
/></Button>
|
||||
</TooltipWrapper>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.launch.location') }}</FieldLabel>
|
||||
<FieldContent class="flex-row items-center gap-2">
|
||||
<InputGroupField
|
||||
v-model="launchDialog.location"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
|
||||
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@click="copyInstanceMessage(launchDialog.location)"
|
||||
><Copy
|
||||
/></Button>
|
||||
</TooltipWrapper>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
class="mr-1.5"
|
||||
@@ -129,8 +129,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -138,6 +144,7 @@
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Field, FieldContent, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { AlertTriangle, Copy, MoreHorizontal } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonGroup } from '@/components/ui/button-group';
|
||||
|
||||
@@ -6,37 +6,37 @@
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="moderateGroupDialog.visible">
|
||||
<div class="x-friend-item" style="cursor: default">
|
||||
<div class="avatar">
|
||||
<img :src="userImage(moderateGroupDialog.userObject)" loading="lazy" />
|
||||
<div class="x-friend-item" style="cursor: default">
|
||||
<div class="avatar">
|
||||
<img :src="userImage(moderateGroupDialog.userObject)" loading="lazy" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span
|
||||
v-if="moderateGroupDialog.userObject.id"
|
||||
class="name"
|
||||
:style="{ color: moderateGroupDialog.userObject.$userColour }"
|
||||
v-text="moderateGroupDialog.userObject.displayName"></span>
|
||||
<span v-else v-text="moderateGroupDialog.userId"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span
|
||||
v-if="moderateGroupDialog.userObject.id"
|
||||
class="name"
|
||||
:style="{ color: moderateGroupDialog.userObject.$userColour }"
|
||||
v-text="moderateGroupDialog.userObject.displayName"></span>
|
||||
<span v-else v-text="moderateGroupDialog.userId"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px; width: 100%">
|
||||
<VirtualCombobox
|
||||
:model-value="moderateGroupDialog.groupId"
|
||||
@update:modelValue="setGroupId"
|
||||
:groups="groupPickerGroups"
|
||||
:placeholder="t('dialog.moderate_group.choose_group_placeholder')"
|
||||
:search-placeholder="t('dialog.moderate_group.choose_group_placeholder')"
|
||||
:close-on-select="true">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<img :src="item.iconUrl" loading="lazy" class="size-5 rounded-sm" />
|
||||
<span class="truncate text-sm" v-text="item.label"></span>
|
||||
<span v-if="selected" class="ml-auto opacity-70">✓</span>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</div>
|
||||
<div style="margin-top: 15px; width: 100%">
|
||||
<VirtualCombobox
|
||||
:model-value="moderateGroupDialog.groupId"
|
||||
@update:modelValue="setGroupId"
|
||||
:groups="groupPickerGroups"
|
||||
:placeholder="t('dialog.moderate_group.choose_group_placeholder')"
|
||||
:search-placeholder="t('dialog.moderate_group.choose_group_placeholder')"
|
||||
:close-on-select="true">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<img :src="item.iconUrl" loading="lazy" class="size-5 rounded-sm" />
|
||||
<span class="truncate text-sm" v-text="item.label"></span>
|
||||
<span v-if="selected" class="ml-auto opacity-70">✓</span>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -54,9 +54,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { computed, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
|
||||
@@ -5,208 +5,429 @@
|
||||
<DialogTitle>{{ t('dialog.new_instance.header') }}</DialogTitle>
|
||||
<DialogDescription class="sr-only">{{ t('dialog.new_instance.header') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TabsUnderline
|
||||
v-model="newInstanceDialog.selectedTab"
|
||||
:items="newInstanceTabs"
|
||||
:unmount-on-hide="false"
|
||||
@update:modelValue="newInstanceTabClick">
|
||||
<template #Normal>
|
||||
<FieldGroup class="gap-4">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.access_type') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.accessType"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.accessType = value;
|
||||
buildInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="public">{{
|
||||
t('dialog.new_instance.access_type_public')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="group">{{
|
||||
t('dialog.new_instance.access_type_group')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="friends+">{{
|
||||
t('dialog.new_instance.access_type_friend_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="friends">{{
|
||||
t('dialog.new_instance.access_type_friend')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="invite+">{{
|
||||
t('dialog.new_instance.access_type_invite_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="invite">{{
|
||||
t('dialog.new_instance.access_type_invite')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.group_access_type') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.groupAccessType"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.groupAccessType = value;
|
||||
buildInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem
|
||||
value="members"
|
||||
:disabled="
|
||||
!hasGroupPermission(newInstanceDialog.groupRef, 'group-instance-open-create')
|
||||
"
|
||||
>{{ t('dialog.new_instance.group_access_type_members') }}</ToggleGroupItem
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="plus"
|
||||
:disabled="
|
||||
!hasGroupPermission(newInstanceDialog.groupRef, 'group-instance-plus-create')
|
||||
"
|
||||
>{{ t('dialog.new_instance.group_access_type_plus') }}</ToggleGroupItem
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="public"
|
||||
<TabsUnderline
|
||||
v-model="newInstanceDialog.selectedTab"
|
||||
:items="newInstanceTabs"
|
||||
:unmount-on-hide="false"
|
||||
@update:modelValue="newInstanceTabClick">
|
||||
<template #Normal>
|
||||
<FieldGroup class="gap-4">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.access_type') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.accessType"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.accessType = value;
|
||||
buildInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="public">{{
|
||||
t('dialog.new_instance.access_type_public')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="group">{{
|
||||
t('dialog.new_instance.access_type_group')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="friends+">{{
|
||||
t('dialog.new_instance.access_type_friend_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="friends">{{
|
||||
t('dialog.new_instance.access_type_friend')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="invite+">{{
|
||||
t('dialog.new_instance.access_type_invite_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="invite">{{
|
||||
t('dialog.new_instance.access_type_invite')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.group_access_type') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.groupAccessType"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.groupAccessType = value;
|
||||
buildInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem
|
||||
value="members"
|
||||
:disabled="
|
||||
!hasGroupPermission(
|
||||
newInstanceDialog.groupRef,
|
||||
'group-instance-open-create'
|
||||
)
|
||||
"
|
||||
>{{ t('dialog.new_instance.group_access_type_members') }}</ToggleGroupItem
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="plus"
|
||||
:disabled="
|
||||
!hasGroupPermission(
|
||||
newInstanceDialog.groupRef,
|
||||
'group-instance-plus-create'
|
||||
)
|
||||
"
|
||||
>{{ t('dialog.new_instance.group_access_type_plus') }}</ToggleGroupItem
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="public"
|
||||
:disabled="
|
||||
!hasGroupPermission(
|
||||
newInstanceDialog.groupRef,
|
||||
'group-instance-public-create'
|
||||
) || newInstanceDialog.groupRef.privacy === 'private'
|
||||
"
|
||||
>{{ t('dialog.new_instance.group_access_type_public') }}</ToggleGroupItem
|
||||
>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.region') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.region"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.region = value;
|
||||
buildInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="US West">{{
|
||||
t('dialog.new_instance.region_usw')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="US East">{{
|
||||
t('dialog.new_instance.region_use')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Europe">{{
|
||||
t('dialog.new_instance.region_eu')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Japan">{{
|
||||
t('dialog.new_instance.region_jp')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.queueEnabled') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Checkbox v-model="newInstanceDialog.queueEnabled" @update:modelValue="buildInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.ageGate') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Checkbox
|
||||
v-model="newInstanceDialog.ageGate"
|
||||
:disabled="
|
||||
!hasGroupPermission(
|
||||
newInstanceDialog.groupRef,
|
||||
'group-instance-public-create'
|
||||
) || newInstanceDialog.groupRef.privacy === 'private'
|
||||
'group-instance-age-gated-create'
|
||||
)
|
||||
"
|
||||
>{{ t('dialog.new_instance.group_access_type_public') }}</ToggleGroupItem
|
||||
>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.region') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.region"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.region = value;
|
||||
buildInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="US West">{{
|
||||
t('dialog.new_instance.region_usw')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="US East">{{
|
||||
t('dialog.new_instance.region_use')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Europe">{{
|
||||
t('dialog.new_instance.region_eu')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Japan">{{
|
||||
t('dialog.new_instance.region_jp')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.queueEnabled') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Checkbox v-model="newInstanceDialog.queueEnabled" @update:modelValue="buildInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.ageGate') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Checkbox
|
||||
v-model="newInstanceDialog.ageGate"
|
||||
:disabled="
|
||||
!hasGroupPermission(newInstanceDialog.groupRef, 'group-instance-age-gated-create')
|
||||
"
|
||||
@update:modelValue="buildInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.display_name') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField
|
||||
:disabled="!isLocalUserVrcPlusSupporter"
|
||||
v-model="newInstanceDialog.displayName"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()"
|
||||
@change="buildInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.group_id') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<VirtualCombobox
|
||||
v-model="newInstanceDialog.groupId"
|
||||
:groups="normalGroupPickerGroups"
|
||||
:placeholder="t('dialog.new_instance.group_placeholder')"
|
||||
:search-placeholder="t('dialog.new_instance.group_placeholder')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true"
|
||||
@change="buildInstance">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<div class="avatar">
|
||||
<img :src="item.iconUrl" loading="lazy" />
|
||||
@update:modelValue="buildInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.display_name') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField
|
||||
:disabled="!isLocalUserVrcPlusSupporter"
|
||||
v-model="newInstanceDialog.displayName"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()"
|
||||
@change="buildInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.group_id') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<VirtualCombobox
|
||||
v-model="newInstanceDialog.groupId"
|
||||
:groups="normalGroupPickerGroups"
|
||||
:placeholder="t('dialog.new_instance.group_placeholder')"
|
||||
:search-placeholder="t('dialog.new_instance.group_placeholder')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true"
|
||||
@change="buildInstance">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<div class="avatar">
|
||||
<img :src="item.iconUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="name" v-text="item.label"></span>
|
||||
</div>
|
||||
<CheckIcon
|
||||
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="name" v-text="item.label"></span>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field
|
||||
v-if="
|
||||
newInstanceDialog.accessType === 'group' &&
|
||||
newInstanceDialog.groupAccessType === 'members'
|
||||
"
|
||||
class="items-start">
|
||||
<FieldLabel>{{ t('dialog.new_instance.roles') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
multiple
|
||||
:model-value="
|
||||
Array.isArray(newInstanceDialog.roleIds) ? newInstanceDialog.roleIds : []
|
||||
"
|
||||
@update:modelValue="handleRoleIdsChange">
|
||||
<SelectTrigger size="sm" class="w-full">
|
||||
<SelectValue>
|
||||
<span class="truncate">
|
||||
{{ selectedRoleSummary || t('dialog.new_instance.role_placeholder') }}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="role in newInstanceDialog.selectedGroupRoles"
|
||||
:key="role.id"
|
||||
:value="role.id">
|
||||
{{ role.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<template v-if="newInstanceDialog.instanceCreated">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.location') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField
|
||||
v-model="newInstanceDialog.location"
|
||||
size="sm"
|
||||
readonly
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.url') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField v-model="newInstanceDialog.url" size="sm" readonly />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</template>
|
||||
</FieldGroup>
|
||||
</template>
|
||||
<template #Legacy>
|
||||
<FieldGroup class="gap-4">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.access_type') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.accessType"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.accessType = value;
|
||||
buildLegacyInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="public">{{
|
||||
t('dialog.new_instance.access_type_public')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="group">{{
|
||||
t('dialog.new_instance.access_type_group')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="friends+">{{
|
||||
t('dialog.new_instance.access_type_friend_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="friends">{{
|
||||
t('dialog.new_instance.access_type_friend')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="invite+">{{
|
||||
t('dialog.new_instance.access_type_invite_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="invite">{{
|
||||
t('dialog.new_instance.access_type_invite')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.group_access_type') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.groupAccessType"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.groupAccessType = value;
|
||||
buildLegacyInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="members">{{
|
||||
t('dialog.new_instance.group_access_type_members')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="plus">{{
|
||||
t('dialog.new_instance.group_access_type_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="public">{{
|
||||
t('dialog.new_instance.group_access_type_public')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.region') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.region"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.region = value;
|
||||
buildLegacyInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="US West">{{
|
||||
t('dialog.new_instance.region_usw')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="US East">{{
|
||||
t('dialog.new_instance.region_use')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Europe">{{
|
||||
t('dialog.new_instance.region_eu')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Japan">{{
|
||||
t('dialog.new_instance.region_jp')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.ageGate') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Checkbox v-model="newInstanceDialog.ageGate" @update:modelValue="buildInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.world_id') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField
|
||||
v-model="newInstanceDialog.worldId"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()"
|
||||
@change="buildLegacyInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.instance_id') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField
|
||||
v-model="newInstanceDialog.instanceName"
|
||||
:placeholder="t('dialog.new_instance.instance_id_placeholder')"
|
||||
size="sm"
|
||||
@change="buildLegacyInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field
|
||||
v-if="
|
||||
newInstanceDialog.selectedTab === 'Legacy' &&
|
||||
newInstanceDialog.accessType !== 'public' &&
|
||||
newInstanceDialog.accessType !== 'group'
|
||||
"
|
||||
class="items-start">
|
||||
<FieldLabel>{{ t('dialog.new_instance.instance_creator') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<VirtualCombobox
|
||||
v-model="newInstanceDialog.userId"
|
||||
:groups="creatorPickerGroups"
|
||||
:placeholder="t('dialog.new_instance.instance_creator_placeholder')"
|
||||
:search-placeholder="t('dialog.new_instance.instance_creator_placeholder')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true"
|
||||
@change="buildLegacyInstance">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<template v-if="item.user">
|
||||
<div class="avatar" :class="userStatusClass(item.user)">
|
||||
<img :src="userImage(item.user)" loading="lazy" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span
|
||||
class="name"
|
||||
:style="{ color: item.user.$userColour }"
|
||||
v-text="item.user.displayName"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-text="item.label"></span>
|
||||
</template>
|
||||
|
||||
<CheckIcon
|
||||
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
<CheckIcon
|
||||
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field
|
||||
v-if="
|
||||
newInstanceDialog.accessType === 'group' && newInstanceDialog.groupAccessType === 'members'
|
||||
"
|
||||
class="items-start">
|
||||
<FieldLabel>{{ t('dialog.new_instance.roles') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
multiple
|
||||
:model-value="Array.isArray(newInstanceDialog.roleIds) ? newInstanceDialog.roleIds : []"
|
||||
@update:modelValue="handleRoleIdsChange">
|
||||
<SelectTrigger size="sm" class="w-full">
|
||||
<SelectValue>
|
||||
<span class="truncate">
|
||||
{{ selectedRoleSummary || t('dialog.new_instance.role_placeholder') }}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="role in newInstanceDialog.selectedGroupRoles"
|
||||
:key="role.id"
|
||||
:value="role.id">
|
||||
{{ role.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<template v-if="newInstanceDialog.instanceCreated">
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.group_id') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<VirtualCombobox
|
||||
v-model="newInstanceDialog.groupId"
|
||||
:groups="legacyGroupPickerGroups"
|
||||
:placeholder="t('dialog.new_instance.group_placeholder')"
|
||||
:search-placeholder="t('dialog.new_instance.group_placeholder')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true"
|
||||
@change="buildLegacyInstance">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<div class="avatar">
|
||||
<img :src="item.iconUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="name" v-text="item.label"></span>
|
||||
</div>
|
||||
<CheckIcon
|
||||
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.location') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
@@ -223,220 +444,49 @@
|
||||
<InputGroupField v-model="newInstanceDialog.url" size="sm" readonly />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</template>
|
||||
</FieldGroup>
|
||||
</template>
|
||||
<template #Legacy>
|
||||
<FieldGroup class="gap-4">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.access_type') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.accessType"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.accessType = value;
|
||||
buildLegacyInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="public">{{
|
||||
t('dialog.new_instance.access_type_public')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="group">{{
|
||||
t('dialog.new_instance.access_type_group')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="friends+">{{
|
||||
t('dialog.new_instance.access_type_friend_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="friends">{{
|
||||
t('dialog.new_instance.access_type_friend')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="invite+">{{
|
||||
t('dialog.new_instance.access_type_invite_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="invite">{{
|
||||
t('dialog.new_instance.access_type_invite')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.group_access_type') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.groupAccessType"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.groupAccessType = value;
|
||||
buildLegacyInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="members">{{
|
||||
t('dialog.new_instance.group_access_type_members')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="plus">{{
|
||||
t('dialog.new_instance.group_access_type_plus')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="public">{{
|
||||
t('dialog.new_instance.group_access_type_public')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.region') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
required
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="newInstanceDialog.region"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
newInstanceDialog.region = value;
|
||||
buildLegacyInstance();
|
||||
}
|
||||
">
|
||||
<ToggleGroupItem value="US West">{{
|
||||
t('dialog.new_instance.region_usw')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="US East">{{
|
||||
t('dialog.new_instance.region_use')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Europe">{{
|
||||
t('dialog.new_instance.region_eu')
|
||||
}}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Japan">{{
|
||||
t('dialog.new_instance.region_jp')
|
||||
}}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.ageGate') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Checkbox v-model="newInstanceDialog.ageGate" @update:modelValue="buildInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.world_id') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField
|
||||
v-model="newInstanceDialog.worldId"
|
||||
size="sm"
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()"
|
||||
@change="buildLegacyInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.instance_id') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField
|
||||
v-model="newInstanceDialog.instanceName"
|
||||
:placeholder="t('dialog.new_instance.instance_id_placeholder')"
|
||||
size="sm"
|
||||
@change="buildLegacyInstance" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field
|
||||
v-if="
|
||||
newInstanceDialog.selectedTab === 'Legacy' &&
|
||||
newInstanceDialog.accessType !== 'public' &&
|
||||
newInstanceDialog.accessType !== 'group'
|
||||
</FieldGroup>
|
||||
</template>
|
||||
</TabsUnderline>
|
||||
<DialogFooter v-if="newInstanceDialog.selectedTab === 'Normal'">
|
||||
<template v-if="newInstanceDialog.instanceCreated">
|
||||
<Button variant="outline" class="mr-2" @click="copyInstanceUrl(newInstanceDialog.location)">{{
|
||||
t('dialog.new_instance.copy_url')
|
||||
}}</Button>
|
||||
<Button variant="outline" class="mr-2" @click="selfInvite(newInstanceDialog.location)">{{
|
||||
t('dialog.new_instance.self_invite')
|
||||
}}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mr-2"
|
||||
:disabled="
|
||||
(newInstanceDialog.accessType === 'friends' || newInstanceDialog.accessType === 'invite') &&
|
||||
newInstanceDialog.userId !== currentUser.id
|
||||
"
|
||||
class="items-start">
|
||||
<FieldLabel>{{ t('dialog.new_instance.instance_creator') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<VirtualCombobox
|
||||
v-model="newInstanceDialog.userId"
|
||||
:groups="creatorPickerGroups"
|
||||
:placeholder="t('dialog.new_instance.instance_creator_placeholder')"
|
||||
:search-placeholder="t('dialog.new_instance.instance_creator_placeholder')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true"
|
||||
@change="buildLegacyInstance">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<template v-if="item.user">
|
||||
<div class="avatar" :class="userStatusClass(item.user)">
|
||||
<img :src="userImage(item.user)" loading="lazy" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span
|
||||
class="name"
|
||||
:style="{ color: item.user.$userColour }"
|
||||
v-text="item.user.displayName"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-text="item.label"></span>
|
||||
</template>
|
||||
|
||||
<CheckIcon
|
||||
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field v-if="newInstanceDialog.accessType === 'group'">
|
||||
<FieldLabel>{{ t('dialog.new_instance.group_id') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<VirtualCombobox
|
||||
v-model="newInstanceDialog.groupId"
|
||||
:groups="legacyGroupPickerGroups"
|
||||
:placeholder="t('dialog.new_instance.group_placeholder')"
|
||||
:search-placeholder="t('dialog.new_instance.group_placeholder')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true"
|
||||
@change="buildLegacyInstance">
|
||||
<template #item="{ item, selected }">
|
||||
<div class="x-friend-item flex w-full items-center">
|
||||
<div class="avatar">
|
||||
<img :src="item.iconUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="name" v-text="item.label"></span>
|
||||
</div>
|
||||
<CheckIcon
|
||||
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</div>
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.location') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField
|
||||
v-model="newInstanceDialog.location"
|
||||
size="sm"
|
||||
readonly
|
||||
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.new_instance.url') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<InputGroupField v-model="newInstanceDialog.url" size="sm" readonly />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</template>
|
||||
</TabsUnderline>
|
||||
<DialogFooter v-if="newInstanceDialog.selectedTab === 'Normal'">
|
||||
<template v-if="newInstanceDialog.instanceCreated">
|
||||
@click="showInviteDialog(newInstanceDialog.location)"
|
||||
>{{ t('dialog.new_instance.invite') }}</Button
|
||||
>
|
||||
<template v-if="canOpenInstanceInGame">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mr-2"
|
||||
@click="showLaunchDialog(newInstanceDialog.location, newInstanceDialog.shortName)"
|
||||
>{{ t('dialog.new_instance.launch') }}</Button
|
||||
>
|
||||
<Button @click="handleAttachGame(newInstanceDialog.location, newInstanceDialog.shortName)">
|
||||
{{ t('dialog.new_instance.open_ingame') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button @click="showLaunchDialog(newInstanceDialog.location, newInstanceDialog.shortName)">{{
|
||||
t('dialog.new_instance.launch')
|
||||
}}</Button>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button @click="handleCreateNewInstance">{{ t('dialog.new_instance.create_instance') }}</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
<DialogFooter v-else-if="newInstanceDialog.selectedTab === 'Legacy'">
|
||||
<Button variant="outline" class="mr-2" @click="copyInstanceUrl(newInstanceDialog.location)">{{
|
||||
t('dialog.new_instance.copy_url')
|
||||
}}</Button>
|
||||
@@ -445,7 +495,6 @@
|
||||
}}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mr-2"
|
||||
:disabled="
|
||||
(newInstanceDialog.accessType === 'friends' || newInstanceDialog.accessType === 'invite') &&
|
||||
newInstanceDialog.userId !== currentUser.id
|
||||
@@ -469,44 +518,7 @@
|
||||
t('dialog.new_instance.launch')
|
||||
}}</Button>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button @click="handleCreateNewInstance">{{ t('dialog.new_instance.create_instance') }}</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
<DialogFooter v-else-if="newInstanceDialog.selectedTab === 'Legacy'">
|
||||
<Button variant="outline" class="mr-2" @click="copyInstanceUrl(newInstanceDialog.location)">{{
|
||||
t('dialog.new_instance.copy_url')
|
||||
}}</Button>
|
||||
<Button variant="outline" class="mr-2" @click="selfInvite(newInstanceDialog.location)">{{
|
||||
t('dialog.new_instance.self_invite')
|
||||
}}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="
|
||||
(newInstanceDialog.accessType === 'friends' || newInstanceDialog.accessType === 'invite') &&
|
||||
newInstanceDialog.userId !== currentUser.id
|
||||
"
|
||||
@click="showInviteDialog(newInstanceDialog.location)"
|
||||
>{{ t('dialog.new_instance.invite') }}</Button
|
||||
>
|
||||
<template v-if="canOpenInstanceInGame">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mr-2"
|
||||
@click="showLaunchDialog(newInstanceDialog.location, newInstanceDialog.shortName)"
|
||||
>{{ t('dialog.new_instance.launch') }}</Button
|
||||
>
|
||||
<Button @click="handleAttachGame(newInstanceDialog.location, newInstanceDialog.shortName)">
|
||||
{{ t('dialog.new_instance.open_ingame') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button @click="showLaunchDialog(newInstanceDialog.location, newInstanceDialog.shortName)">{{
|
||||
t('dialog.new_instance.launch')
|
||||
}}</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<InviteDialog :invite-dialog="inviteDialog" @closeInviteDialog="closeInviteDialog" />
|
||||
@@ -514,8 +526,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Field, FieldContent, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check as CheckIcon } from 'lucide-vue-next';
|
||||
|
||||
@@ -4,79 +4,79 @@
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('dialog.boop_dialog.header') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<span>{{ displayName }}</span>
|
||||
<span>{{ displayName }}</span>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<div v-if="sendBoopDialog.visible" style="width: 100%">
|
||||
<VirtualCombobox
|
||||
v-model="emojiModel"
|
||||
:groups="emojiPickerGroups"
|
||||
:placeholder="t('dialog.boop_dialog.select_default_emoji')"
|
||||
:search-placeholder="t('dialog.boop_dialog.select_default_emoji')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true">
|
||||
<template #item="{ item, selected }">
|
||||
<span v-text="item.label"></span>
|
||||
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</div>
|
||||
<div v-if="sendBoopDialog.visible" style="width: 100%">
|
||||
<VirtualCombobox
|
||||
v-model="emojiModel"
|
||||
:groups="emojiPickerGroups"
|
||||
:placeholder="t('dialog.boop_dialog.select_default_emoji')"
|
||||
:search-placeholder="t('dialog.boop_dialog.select_default_emoji')"
|
||||
:clearable="true"
|
||||
:close-on-select="true"
|
||||
:deselect-on-reselect="true">
|
||||
<template #item="{ item, selected }">
|
||||
<span v-text="item.label"></span>
|
||||
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
|
||||
</template>
|
||||
</VirtualCombobox>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<div
|
||||
v-if="isLocalUserVrcPlusSupporter"
|
||||
style="
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
">
|
||||
<div
|
||||
v-for="image in emojiTable"
|
||||
:key="image.id"
|
||||
:class="image.id === fileId ? 'x-image-selected' : ''"
|
||||
style="cursor: pointer; border: 1px solid transparent; border-radius: 8px"
|
||||
@click="fileId = image.id">
|
||||
v-if="isLocalUserVrcPlusSupporter"
|
||||
style="
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
">
|
||||
<div
|
||||
v-if="
|
||||
image.versions &&
|
||||
image.versions.length > 0 &&
|
||||
image.versions[image.versions.length - 1].file.url
|
||||
"
|
||||
class="x-popover-image"
|
||||
style="padding: 8px">
|
||||
<Emoji :imageUrl="image.versions[image.versions.length - 1].file.url" :size="100"></Emoji>
|
||||
v-for="image in emojiTable"
|
||||
:key="image.id"
|
||||
:class="image.id === fileId ? 'x-image-selected' : ''"
|
||||
style="cursor: pointer; border: 1px solid transparent; border-radius: 8px"
|
||||
@click="fileId = image.id">
|
||||
<div
|
||||
v-if="
|
||||
image.versions &&
|
||||
image.versions.length > 0 &&
|
||||
image.versions[image.versions.length - 1].file.url
|
||||
"
|
||||
class="x-popover-image"
|
||||
style="padding: 8px">
|
||||
<Emoji :imageUrl="image.versions[image.versions.length - 1].file.url" :size="100"></Emoji>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" class="mr-2" @click="showGalleryPage">{{
|
||||
t('dialog.boop_dialog.emoji_manager')
|
||||
}}</Button>
|
||||
<Button size="sm" variant="secondary" class="mr-2" @click="closeDialog">{{
|
||||
t('dialog.boop_dialog.cancel')
|
||||
}}</Button>
|
||||
<Button size="sm" :disabled="!sendBoopDialog.userId" @click="sendBoop">{{
|
||||
t('dialog.boop_dialog.send')
|
||||
}}</Button>
|
||||
<Button size="sm" variant="outline" class="mr-2" @click="showGalleryPage">{{
|
||||
t('dialog.boop_dialog.emoji_manager')
|
||||
}}</Button>
|
||||
<Button size="sm" variant="secondary" class="mr-2" @click="closeDialog">{{
|
||||
t('dialog.boop_dialog.cancel')
|
||||
}}</Button>
|
||||
<Button size="sm" :disabled="!sendBoopDialog.userId" @click="sendBoop">{{
|
||||
t('dialog.boop_dialog.send')
|
||||
}}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check as CheckIcon } from 'lucide-vue-next';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
|
||||
@@ -64,8 +64,7 @@
|
||||
</template>
|
||||
<Location
|
||||
:location="userDialog.ref.location"
|
||||
:traveling="userDialog.ref.travelingToLocation"
|
||||
style="display: block; margin-top: 5px" />
|
||||
:traveling="userDialog.ref.travelingToLocation" />
|
||||
</div>
|
||||
<div class="x-friend-list" style="flex: 1; margin-top: 10px; max-height: 150px">
|
||||
<div
|
||||
|
||||
@@ -5,86 +5,86 @@
|
||||
<DialogTitle>{{ t('dialog.vrcx_updater.header') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div v-loading="checkingForVRCXUpdate" style="margin-top: 15px">
|
||||
<template v-if="updateInProgress">
|
||||
<Progress :model-value="updateProgress" class="w-full" />
|
||||
<div class="mt-2 text-xs" v-text="updateProgressText()"></div>
|
||||
<br />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="VRCXUpdateDialog.updatePending" style="margin-bottom: 15px">
|
||||
<span>{{ pendingVRCXInstall }}</span>
|
||||
<template v-if="updateInProgress">
|
||||
<Progress :model-value="updateProgress" class="w-full" />
|
||||
<div class="mt-2 text-xs" v-text="updateProgressText()"></div>
|
||||
<br />
|
||||
<span>{{ t('dialog.vrcx_updater.ready_for_update') }}</span>
|
||||
</div>
|
||||
<Tabs :model-value="branch" class="w-full" @update:modelValue="handleBranchChange">
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="Stable">{{ t('dialog.vrcx_updater.branch_stable') }}</TabsTrigger>
|
||||
<TabsTrigger value="Nightly">{{ t('dialog.vrcx_updater.branch_nightly') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="Nightly">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle class="text-muted-foreground" />
|
||||
<AlertTitle>{{ t('dialog.vrcx_updater.nightly_title') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ t('dialog.vrcx_updater.nightly_notice') }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<FieldGroup class="mt-3">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.vrcx_updater.release') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
:model-value="VRCXUpdateDialog.release"
|
||||
@update:modelValue="(v) => (VRCXUpdateDialog.release = v)">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in VRCXUpdateDialog.releases"
|
||||
:key="item.name"
|
||||
:value="item.name">
|
||||
{{ item.tag_name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div
|
||||
v-if="!VRCXUpdateDialog.updatePending && VRCXUpdateDialog.release === appVersion"
|
||||
class="mt-3 text-xs text-muted-foreground">
|
||||
<span>{{ t('dialog.vrcx_updater.latest_version') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="VRCXUpdateDialog.updatePending" style="margin-bottom: 15px">
|
||||
<span>{{ pendingVRCXInstall }}</span>
|
||||
<br />
|
||||
<span>{{ t('dialog.vrcx_updater.ready_for_update') }}</span>
|
||||
</div>
|
||||
<Tabs :model-value="branch" class="w-full" @update:modelValue="handleBranchChange">
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="Stable">{{ t('dialog.vrcx_updater.branch_stable') }}</TabsTrigger>
|
||||
<TabsTrigger value="Nightly">{{ t('dialog.vrcx_updater.branch_nightly') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="Nightly">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle class="text-muted-foreground" />
|
||||
<AlertTitle>{{ t('dialog.vrcx_updater.nightly_title') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ t('dialog.vrcx_updater.nightly_notice') }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<FieldGroup class="mt-3">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('dialog.vrcx_updater.release') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
:model-value="VRCXUpdateDialog.release"
|
||||
@update:modelValue="(v) => (VRCXUpdateDialog.release = v)">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in VRCXUpdateDialog.releases"
|
||||
:key="item.name"
|
||||
:value="item.name">
|
||||
{{ item.tag_name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div
|
||||
v-if="!VRCXUpdateDialog.updatePending && VRCXUpdateDialog.release === appVersion"
|
||||
class="mt-3 text-xs text-muted-foreground">
|
||||
<span>{{ t('dialog.vrcx_updater.latest_version') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" class="mr-2" v-if="updateInProgress" @click="cancelUpdate">
|
||||
{{ t('dialog.vrcx_updater.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
v-if="VRCXUpdateDialog.release !== pendingVRCXInstall"
|
||||
:disabled="updateInProgress"
|
||||
@click="installVRCXUpdate">
|
||||
{{ t('dialog.vrcx_updater.download') }}
|
||||
</Button>
|
||||
<Button variant="default" v-if="!updateInProgress && pendingVRCXInstall" @click="restartVRCX(true)">
|
||||
{{ t('dialog.vrcx_updater.install') }}
|
||||
</Button>
|
||||
<Button variant="secondary" class="mr-2" v-if="updateInProgress" @click="cancelUpdate">
|
||||
{{ t('dialog.vrcx_updater.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
v-if="VRCXUpdateDialog.release !== pendingVRCXInstall"
|
||||
:disabled="updateInProgress"
|
||||
@click="installVRCXUpdate">
|
||||
{{ t('dialog.vrcx_updater.download') }}
|
||||
</Button>
|
||||
<Button variant="default" v-if="!updateInProgress && pendingVRCXInstall" @click="restartVRCX(true)">
|
||||
{{ t('dialog.vrcx_updater.install') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Field, FieldContent, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { AlertCircle } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
@@ -56,13 +56,13 @@
|
||||
<AlertDialogPortal :to="portalTo">
|
||||
<AlertDialogOverlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80" />
|
||||
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-11000 bg-black/80" />
|
||||
<AlertDialogContent
|
||||
data-slot="alert-dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-11000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 min-w-32 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
v-bind="{ ...$attrs, ...forwardedProps }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 rounded-md border p-4 shadow-md outline-hidden',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 w-64 rounded-md border p-4 shadow-md outline-hidden',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
|
||||
44
src/components/ui/scroll-area/ScrollArea.vue
Normal file
44
src/components/ui/scroll-area/ScrollArea.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from 'reka-ui';
|
||||
import { ref, useAttrs } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
|
||||
import ScrollBar from './ScrollBar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: { type: null, required: false },
|
||||
dir: { type: null, required: false },
|
||||
scrollHideDelay: { type: Number, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false }
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const attrs = useAttrs();
|
||||
const viewportEl = ref(null);
|
||||
|
||||
function setViewportEl(el) {
|
||||
viewportEl.value = el?.$el ?? el ?? null;
|
||||
}
|
||||
|
||||
defineExpose({ viewportEl, update: () => {} });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot data-slot="scroll-area" v-bind="delegatedProps" :class="cn('relative', props.class)">
|
||||
<ScrollAreaViewport
|
||||
data-slot="scroll-area-viewport"
|
||||
:ref="setViewportEl"
|
||||
v-bind="attrs"
|
||||
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1">
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
37
src/components/ui/scroll-area/ScrollBar.vue
Normal file
37
src/components/ui/scroll-area/ScrollBar.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
orientation: { type: String, required: false, default: "vertical" },
|
||||
forceMount: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
class="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
</template>
|
||||
2
src/components/ui/scroll-area/index.js
Normal file
2
src/components/ui/scroll-area/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ScrollArea } from "./ScrollArea.vue";
|
||||
export { default as ScrollBar } from "./ScrollBar.vue";
|
||||
@@ -49,7 +49,7 @@
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-12000 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class
|
||||
|
||||
@@ -20,17 +20,20 @@
|
||||
ariaLabel: { type: String, default: '' },
|
||||
|
||||
variant: { type: String, default: 'fit' },
|
||||
unmountOnHide: { type: Boolean, default: false }
|
||||
unmountOnHide: { type: Boolean, default: false },
|
||||
fill: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const { modelValue, defaultValue, items, ariaLabel, variant, unmountOnHide } = toRefs(props);
|
||||
const { modelValue, defaultValue, items, ariaLabel, variant, unmountOnHide, fill } = toRefs(props);
|
||||
|
||||
const itemsList = computed(() => (Array.isArray(items.value) ? items.value : []));
|
||||
|
||||
const resolvedDefault = computed(() => {
|
||||
return defaultValue.value ?? items.value?.[0]?.value;
|
||||
return defaultValue.value ?? itemsList.value?.[0]?.value;
|
||||
});
|
||||
|
||||
const isValueValid = (value) => items.value?.some((item) => item?.value === value);
|
||||
const isValueValid = (value) => itemsList.value.some((item) => item?.value === value);
|
||||
|
||||
const innerValue = ref(isValueValid(modelValue.value) ? modelValue.value : resolvedDefault.value);
|
||||
|
||||
@@ -40,12 +43,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
watch([items, defaultValue], () => {
|
||||
watch([itemsList, defaultValue], () => {
|
||||
if (!isValueValid(innerValue.value)) {
|
||||
innerValue.value = resolvedDefault.value;
|
||||
return;
|
||||
}
|
||||
if (!isValueValid(modelValue.value)) {
|
||||
|
||||
if (modelValue.value !== undefined && modelValue.value !== null && !isValueValid(modelValue.value)) {
|
||||
innerValue.value = resolvedDefault.value;
|
||||
}
|
||||
});
|
||||
@@ -79,7 +83,7 @@
|
||||
<TabsRoot
|
||||
:model-value="innerValue"
|
||||
:default-value="resolvedDefault"
|
||||
class="w-full"
|
||||
:class="['w-full', fill ? 'flex min-h-0 flex-col' : '']"
|
||||
:unmount-on-hide="unmountOnHide"
|
||||
@update:modelValue="onValueChange">
|
||||
<TabsList :class="listClass" :aria-label="ariaLabel || undefined">
|
||||
@@ -89,7 +93,7 @@
|
||||
</TabsIndicator>
|
||||
|
||||
<TabsTrigger
|
||||
v-for="it in items"
|
||||
v-for="it in itemsList"
|
||||
:key="it.value"
|
||||
:value="it.value"
|
||||
:disabled="it.disabled"
|
||||
@@ -99,10 +103,13 @@
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
v-for="it in items"
|
||||
v-for="it in itemsList"
|
||||
:key="it.value"
|
||||
:value="it.value"
|
||||
class="pt-4 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background">
|
||||
:class="[
|
||||
'pt-4 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background',
|
||||
fill ? 'min-h-0 flex-1' : ''
|
||||
]">
|
||||
<slot :name="it.value" />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
<script setup>
|
||||
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
});
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
forceMount: { type: Boolean, required: false },
|
||||
ariaLabel: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
side: { type: null, required: false },
|
||||
sideOffset: { type: Number, required: false, default: 4 },
|
||||
align: { type: null, required: false },
|
||||
alignOffset: { type: Number, required: false },
|
||||
avoidCollisions: { type: Boolean, required: false },
|
||||
collisionBoundary: { type: null, required: false },
|
||||
collisionPadding: { type: [Number, Object], required: false },
|
||||
arrowPadding: { type: Number, required: false },
|
||||
sticky: { type: String, required: false },
|
||||
hideWhenDetached: { type: Boolean, required: false },
|
||||
positionStrategy: { type: String, required: false },
|
||||
updatePositionStrategy: { type: String, required: false },
|
||||
class: { type: null, required: false }
|
||||
});
|
||||
const props = defineProps({
|
||||
forceMount: { type: Boolean, required: false },
|
||||
ariaLabel: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
side: { type: null, required: false },
|
||||
sideOffset: { type: Number, required: false, default: 4 },
|
||||
align: { type: null, required: false },
|
||||
alignOffset: { type: Number, required: false },
|
||||
avoidCollisions: { type: Boolean, required: false },
|
||||
collisionBoundary: { type: null, required: false },
|
||||
collisionPadding: { type: [Number, Object], required: false },
|
||||
arrowPadding: { type: Number, required: false },
|
||||
sticky: { type: String, required: false },
|
||||
hideWhenDetached: { type: Boolean, required: false },
|
||||
positionStrategy: { type: String, required: false },
|
||||
updatePositionStrategy: { type: String, required: false },
|
||||
class: { type: null, required: false }
|
||||
});
|
||||
|
||||
const emits = defineEmits(['escapeKeyDown', 'pointerDownOutside']);
|
||||
const emits = defineEmits(['escapeKeyDown', 'pointerDownOutside']);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,14 +40,14 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
<slot />
|
||||
|
||||
<TooltipArrow
|
||||
class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
|
||||
class="bg-foreground fill-foreground z-12000 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const MODAL_PORTAL_ROOT_ID = 'vrcx-modal-portal-root';
|
||||
const APP_PORTAL_ROOT_ID = 'x-dialog-portal';
|
||||
|
||||
const BASE_Z_INDEX = 50;
|
||||
const BASE_Z_INDEX = 10000;
|
||||
const Z_STEP = 10;
|
||||
|
||||
let nextLayerIndex = 0;
|
||||
@@ -15,11 +15,15 @@ function ensureModalPortalRoot() {
|
||||
if (root) {
|
||||
root.style.position ||= 'relative';
|
||||
root.style.isolation ||= 'isolate';
|
||||
root.style.zIndex ||= String(BASE_Z_INDEX);
|
||||
return root;
|
||||
}
|
||||
|
||||
root = document.getElementById(MODAL_PORTAL_ROOT_ID);
|
||||
if (root) {
|
||||
root.style.position ||= 'relative';
|
||||
root.style.isolation ||= 'isolate';
|
||||
root.style.zIndex ||= String(BASE_Z_INDEX);
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -27,6 +31,7 @@ function ensureModalPortalRoot() {
|
||||
root.id = MODAL_PORTAL_ROOT_ID;
|
||||
root.style.position = 'relative';
|
||||
root.style.isolation = 'isolate';
|
||||
root.style.zIndex = String(BASE_Z_INDEX);
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
const elementPlusStrings = {
|
||||
// Vite does not support dynamic imports to `node_modules`.
|
||||
// https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations
|
||||
cs: () => import('element-plus/es/locale/lang/cs'),
|
||||
en: () => import('element-plus/es/locale/lang/en'),
|
||||
es: () => import('element-plus/es/locale/lang/es'),
|
||||
fr: () => import('element-plus/es/locale/lang/fr'),
|
||||
hu: () => import('element-plus/es/locale/lang/hu'),
|
||||
ja: () => import('element-plus/es/locale/lang/ja'),
|
||||
ko: () => import('element-plus/es/locale/lang/ko'),
|
||||
pl: () => import('element-plus/es/locale/lang/pl'),
|
||||
pt: () => import('element-plus/es/locale/lang/pt'),
|
||||
ru: () => import('element-plus/es/locale/lang/ru'),
|
||||
th: () => import('element-plus/es/locale/lang/th'),
|
||||
vi: () => import('element-plus/es/locale/lang/vi'),
|
||||
'zh-CN': () => import('element-plus/es/locale/lang/zh-cn'),
|
||||
'zh-TW': () => import('element-plus/es/locale/lang/zh-tw')
|
||||
};
|
||||
|
||||
async function getElementPlusStrings(code) {
|
||||
const loader = elementPlusStrings[code] || elementPlusStrings.en;
|
||||
return (await loader().catch(() => elementPlusStrings.en())).default;
|
||||
}
|
||||
|
||||
const localizedStringsUrls = import.meta.glob('./*.json', {
|
||||
eager: true,
|
||||
query: '?url',
|
||||
@@ -50,10 +26,7 @@ async function getLocalizedStrings(code) {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...localizedStrings,
|
||||
elementPlus: await getElementPlusStrings(code)
|
||||
};
|
||||
return localizedStrings;
|
||||
}
|
||||
|
||||
const languageNames = import.meta.glob('./*.json', {
|
||||
|
||||
@@ -403,13 +403,15 @@ function openExternalLink(link) {
|
||||
confirmText: 'Open',
|
||||
cancelText: 'Copy'
|
||||
})
|
||||
// TODO: beforeClose alert dialog
|
||||
.then(({ ok }) => {
|
||||
if (!ok) {
|
||||
.then(({ ok, reason }) => {
|
||||
if (reason === 'cancel') {
|
||||
copyToClipboard(link, 'Link copied to clipboard!');
|
||||
return;
|
||||
}
|
||||
AppApi.OpenLink(link);
|
||||
if (ok) {
|
||||
AppApi.OpenLink(link);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<MutualFriends />
|
||||
</template>
|
||||
</TabsUnderline>
|
||||
<el-backtop target="#chart" :right="30" :bottom="30"></el-backtop>
|
||||
<BackToTop target="#chart" :right="30" :bottom="30" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import BackToTop from '@/components/BackToTop.vue';
|
||||
|
||||
import { useChartsStore } from '../../stores';
|
||||
|
||||
const InstanceActivity = defineAsyncComponent(() => import('./components/InstanceActivity.vue'));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div ref="instanceActivityRef" class="pt-12">
|
||||
<BackToTop :target="instanceActivityRef" :right="30" :bottom="30" :teleport="false" />
|
||||
<div class="options-container instance-activity" style="margin-top: 0">
|
||||
<div>
|
||||
<span>{{ t('view.charts.instance_activity.header') }}</span>
|
||||
@@ -171,6 +172,7 @@
|
||||
import { toDate } from 'reka-ui/date';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import BackToTop from '@/components/BackToTop.vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../../../components/ui/popover';
|
||||
@@ -204,7 +206,7 @@
|
||||
|
||||
function setInstanceActivityHeight() {
|
||||
if (instanceActivityRef.value) {
|
||||
const availableHeight = window.innerHeight - 100;
|
||||
const availableHeight = window.innerHeight - 110;
|
||||
instanceActivityRef.value.style.height = `${availableHeight}px`;
|
||||
instanceActivityRef.value.style.overflowY = 'auto';
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<DropdownMenuSubContent
|
||||
side="right"
|
||||
align="start"
|
||||
class="w-[180px] p-1 rounded-lg">
|
||||
class="w-45 p-1 rounded-lg">
|
||||
<div class="group-visibility-menu">
|
||||
<button
|
||||
v-for="visibility in avatarGroupVisibilityOptions"
|
||||
@@ -476,7 +476,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeLocalGroupName">
|
||||
<el-scrollbar
|
||||
<ScrollArea
|
||||
ref="localAvatarScrollbarRef"
|
||||
class="favorites-content__scroll"
|
||||
@scroll="handleLocalAvatarScroll">
|
||||
@@ -495,7 +495,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="favorites-empty">No Data</div>
|
||||
</el-scrollbar>
|
||||
</ScrollArea>
|
||||
</template>
|
||||
<template v-else-if="isHistorySelected">
|
||||
<div class="favorites-content__scroll favorites-content__scroll--native">
|
||||
@@ -529,6 +529,7 @@
|
||||
import { ArrowUpDown, Check, Ellipsis, Loader, MoreHorizontal, Plus, RefreshCcw, RefreshCw } from 'lucide-vue-next';
|
||||
import { InputGroupField, InputGroupSearch } from '@/components/ui/input-group';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
@@ -1101,7 +1102,7 @@
|
||||
if (!isLocalGroupSelected.value || isSearchActive.value) {
|
||||
return;
|
||||
}
|
||||
const wrap = localAvatarScrollbarRef.value?.wrapRef;
|
||||
const wrap = localAvatarScrollbarRef.value?.viewportEl?.value;
|
||||
if (!wrap) {
|
||||
return;
|
||||
}
|
||||
@@ -1135,7 +1136,7 @@
|
||||
if (!isLocalGroupSelected.value || isSearchActive.value) {
|
||||
return;
|
||||
}
|
||||
const wrap = localAvatarScrollbarRef.value?.wrapRef;
|
||||
const wrap = localAvatarScrollbarRef.value?.viewportEl?.value;
|
||||
if (!wrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -404,7 +404,7 @@
|
||||
</template>
|
||||
<div v-else class="favorites-empty">No Data</div>
|
||||
</div>
|
||||
<el-scrollbar
|
||||
<ScrollArea
|
||||
v-else-if="activeLocalGroupName && isLocalGroupSelected"
|
||||
ref="localFavoritesScrollbarRef"
|
||||
class="favorites-content__scroll"
|
||||
@@ -424,7 +424,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="favorites-empty">No Data</div>
|
||||
</el-scrollbar>
|
||||
</ScrollArea>
|
||||
<div v-else class="favorites-empty">No Data</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -440,6 +440,7 @@
|
||||
import { ArrowUpDown, Ellipsis, MoreHorizontal, Plus, RefreshCcw, RefreshCw } from 'lucide-vue-next';
|
||||
import { InputGroupField, InputGroupSearch } from '@/components/ui/input-group';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
@@ -1004,7 +1005,7 @@
|
||||
if (!isLocalGroupSelected.value || isSearchActive.value) {
|
||||
return;
|
||||
}
|
||||
const wrap = localFavoritesScrollbarRef.value?.wrapRef;
|
||||
const wrap = localFavoritesScrollbarRef.value?.viewportEl?.value;
|
||||
if (!wrap) {
|
||||
return;
|
||||
}
|
||||
@@ -1097,7 +1098,7 @@
|
||||
if (!isLocalGroupSelected.value || isSearchActive.value) {
|
||||
return;
|
||||
}
|
||||
const wrap = localFavoritesScrollbarRef.value?.wrapRef;
|
||||
const wrap = localFavoritesScrollbarRef.value?.viewportEl?.value;
|
||||
if (!wrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
:rows="10"
|
||||
style="margin-top: 10px"
|
||||
input-class="resize-none" />
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 5px">
|
||||
<div>
|
||||
<div>
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
:model-value="worldImportFavoriteGroupSelection"
|
||||
|
||||
@@ -306,6 +306,7 @@ export const columns = [
|
||||
location={original.location}
|
||||
hint={original.worldName}
|
||||
grouphint={original.groupName}
|
||||
disableTooltip
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
@@ -318,6 +319,7 @@ export const columns = [
|
||||
location={original.location}
|
||||
hint={original.worldName}
|
||||
grouphint={original.groupName}
|
||||
disableTooltip
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
@@ -350,7 +352,7 @@ export const columns = [
|
||||
}
|
||||
|
||||
return (
|
||||
<span class="block w-full min-w-0 truncate">
|
||||
<div class="block w-full min-w-0 truncate">
|
||||
<i
|
||||
class={[
|
||||
'x-user-status',
|
||||
@@ -359,7 +361,7 @@ export const columns = [
|
||||
]}
|
||||
></i>
|
||||
<span>{original.statusDescription}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -379,13 +381,9 @@ export const columns = [
|
||||
|
||||
if (type === 'Bio') {
|
||||
return (
|
||||
<span
|
||||
class="block w-full min-w-0 truncate"
|
||||
innerHTML={formatDifference(
|
||||
original.previousBio,
|
||||
original.bio
|
||||
)}
|
||||
></span>
|
||||
<div class="block w-full min-w-0 truncate">
|
||||
{original.bio}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<div v-else class="friend-view__toolbar friend-view__toolbar--loading">
|
||||
<span class="friend-view__loading-text">{{ t('view.friends_locations.loading_more') }}</span>
|
||||
</div>
|
||||
<el-scrollbar v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll" @scroll="handleScroll">
|
||||
<ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll" @scroll="handleScroll">
|
||||
<template v-if="isSameInstanceView">
|
||||
<div v-if="visibleSameInstanceGroups.length" class="friend-view__instances">
|
||||
<section
|
||||
@@ -151,7 +151,7 @@
|
||||
<Loader2 class="friend-view__loading-icon" :size="18" />
|
||||
<span>{{ t('view.friends_locations.loading_more') }}</span>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</ScrollArea>
|
||||
<div v-else class="friend-view__initial-loading">
|
||||
<Loader2 class="friend-view__loading-icon" :size="22" />
|
||||
</div>
|
||||
@@ -164,6 +164,7 @@
|
||||
import { Loader2, Settings } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InputGroupSearch } from '@/components/ui/input-group';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -262,7 +263,7 @@
|
||||
let cleanupResize;
|
||||
|
||||
const updateGridWidth = () => {
|
||||
const wrap = scrollbarRef.value?.wrapRef;
|
||||
const wrap = scrollbarRef.value?.viewportEl?.value;
|
||||
if (!wrap) {
|
||||
return;
|
||||
}
|
||||
@@ -276,7 +277,7 @@
|
||||
cleanupResize = undefined;
|
||||
}
|
||||
|
||||
const wrap = scrollbarRef.value?.wrapRef;
|
||||
const wrap = scrollbarRef.value?.viewportEl?.value;
|
||||
if (!wrap) {
|
||||
return;
|
||||
}
|
||||
@@ -559,7 +560,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = scrollbarRef.value?.wrapRef;
|
||||
const wrap = scrollbarRef.value?.viewportEl?.value;
|
||||
|
||||
if (!wrap) {
|
||||
return;
|
||||
@@ -590,7 +591,7 @@
|
||||
|
||||
function maybeFillViewport() {
|
||||
nextTick(() => {
|
||||
const wrap = scrollbarRef.value?.wrapRef;
|
||||
const wrap = scrollbarRef.value?.viewportEl?.value;
|
||||
if (!wrap) {
|
||||
return;
|
||||
}
|
||||
@@ -634,7 +635,6 @@
|
||||
return;
|
||||
}
|
||||
nextTick(() => {
|
||||
scrollbarRef.value?.update?.();
|
||||
updateGridWidth();
|
||||
maybeFillViewport();
|
||||
});
|
||||
@@ -697,7 +697,6 @@
|
||||
settingsReady.value = true;
|
||||
nextTick(() => {
|
||||
setupResizeHandling();
|
||||
scrollbarRef.value?.update?.();
|
||||
updateGridWidth();
|
||||
maybeFillViewport();
|
||||
});
|
||||
|
||||
@@ -352,66 +352,52 @@
|
||||
@change="updateTrustColor('', '', true)"></simple-switch>
|
||||
<div>
|
||||
<div>
|
||||
<el-color-picker
|
||||
<PresetColorPicker
|
||||
:model-value="trustColor.untrusted"
|
||||
size="small"
|
||||
:predefine="['#CCCCCC']"
|
||||
@change="updateTrustColor('untrusted', $event)">
|
||||
</el-color-picker>
|
||||
:presets="['#CCCCCC']"
|
||||
@change="updateTrustColor('untrusted', $event)" />
|
||||
<span class="color-picker x-tag-untrusted">Visitor</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-color-picker
|
||||
<PresetColorPicker
|
||||
:model-value="trustColor.basic"
|
||||
size="small"
|
||||
:predefine="['#1778ff']"
|
||||
@change="updateTrustColor('basic', $event)">
|
||||
</el-color-picker>
|
||||
:presets="['#1778ff']"
|
||||
@change="updateTrustColor('basic', $event)" />
|
||||
<span class="color-picker x-tag-basic">New User</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-color-picker
|
||||
<PresetColorPicker
|
||||
:model-value="trustColor.known"
|
||||
size="small"
|
||||
:predefine="['#2bcf5c']"
|
||||
@change="updateTrustColor('known', $event)">
|
||||
</el-color-picker>
|
||||
:presets="['#2bcf5c']"
|
||||
@change="updateTrustColor('known', $event)" />
|
||||
<span class="color-picker x-tag-known">User</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-color-picker
|
||||
<PresetColorPicker
|
||||
:model-value="trustColor.trusted"
|
||||
size="small"
|
||||
:predefine="['#ff7b42']"
|
||||
@change="updateTrustColor('trusted', $event)">
|
||||
</el-color-picker>
|
||||
:presets="['#ff7b42']"
|
||||
@change="updateTrustColor('trusted', $event)" />
|
||||
<span class="color-picker x-tag-trusted">Known User</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-color-picker
|
||||
<PresetColorPicker
|
||||
:model-value="trustColor.veteran"
|
||||
size="small"
|
||||
:predefine="['#b18fff', '#8143e6', '#ff69b4', '#b52626', '#ffd000', '#abcdef']"
|
||||
@change="updateTrustColor('veteran', $event)">
|
||||
</el-color-picker>
|
||||
:presets="['#b18fff', '#8143e6', '#ff69b4', '#b52626', '#ffd000', '#abcdef']"
|
||||
@change="updateTrustColor('veteran', $event)" />
|
||||
<span class="color-picker x-tag-veteran">Trusted User</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-color-picker
|
||||
<PresetColorPicker
|
||||
:model-value="trustColor.vip"
|
||||
size="small"
|
||||
:predefine="['#ff2626']"
|
||||
@change="updateTrustColor('vip', $event)">
|
||||
</el-color-picker>
|
||||
:presets="['#ff2626']"
|
||||
@change="updateTrustColor('vip', $event)" />
|
||||
<span class="color-picker x-tag-vip">VRChat Team</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-color-picker
|
||||
<PresetColorPicker
|
||||
:model-value="trustColor.troll"
|
||||
size="small"
|
||||
:predefine="['#782f2f']"
|
||||
@change="updateTrustColor('troll', $event)">
|
||||
</el-color-picker>
|
||||
:presets="['#782f2f']"
|
||||
@change="updateTrustColor('troll', $event)" />
|
||||
<span class="color-picker x-tag-troll">Nuisance</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,6 +431,8 @@
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import PresetColorPicker from '@/components/PresetColorPicker.vue';
|
||||
|
||||
import { useAppearanceSettingsStore, useFavoriteStore, useVrStore } from '../../../../stores';
|
||||
import { getLanguageName, languageCodes } from '../../../../localization';
|
||||
import { THEME_CONFIG } from '../../../../shared/constants';
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
<div style="flex: 1; padding: 10px; padding-left: 0">
|
||||
<Popover v-model:open="isQuickSearchOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Input
|
||||
v-model="quickSearchQuery"
|
||||
:placeholder="t('side_panel.search_placeholder')"
|
||||
@focus="handleQuickSearchFocus" />
|
||||
<Input v-model="quickSearchQuery" :placeholder="t('side_panel.search_placeholder')" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
@@ -78,6 +75,7 @@
|
||||
:items="sidebarTabs"
|
||||
:unmount-on-hide="false"
|
||||
variant="equal"
|
||||
fill
|
||||
class="zero-margin-tabs"
|
||||
style="height: calc(100% - 70px); margin-top: 5px">
|
||||
<template #label-friends>
|
||||
@@ -89,14 +87,19 @@
|
||||
<span class="sidebar-tab-count"> ({{ groupInstances.length }}) </span>
|
||||
</template>
|
||||
<template #friends>
|
||||
<div class="el-tabs__content">
|
||||
<el-backtop target=".zero-margin-tabs .el-tabs__content" :bottom="20" :right="20"></el-backtop>
|
||||
<FriendsSidebar @confirm-delete-friend="confirmDeleteFriend" />
|
||||
<div class="h-full overflow-hidden">
|
||||
<ScrollArea ref="friendsScrollAreaRef" class="h-full">
|
||||
<FriendsSidebar @confirm-delete-friend="confirmDeleteFriend" />
|
||||
</ScrollArea>
|
||||
<BackToTop :target="friendsScrollTarget" :bottom="20" :right="20" :teleport="false" />
|
||||
</div>
|
||||
</template>
|
||||
<template #groups>
|
||||
<div class="el-tabs__content">
|
||||
<GroupsSidebar :group-instances="groupInstances" :group-order="inGameGroupOrder" />
|
||||
<div class="h-full overflow-hidden">
|
||||
<ScrollArea ref="groupsScrollAreaRef" class="h-full">
|
||||
<GroupsSidebar :group-instances="groupInstances" :group-order="inGameGroupOrder" />
|
||||
</ScrollArea>
|
||||
<BackToTop :target="groupsScrollTarget" :bottom="20" :right="20" :teleport="false" />
|
||||
</div>
|
||||
</template>
|
||||
</TabsUnderline>
|
||||
@@ -104,16 +107,19 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RefreshCw } from 'lucide-vue-next';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { TabsUnderline } from '@/components/ui/tabs';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import BackToTop from '@/components/BackToTop.vue';
|
||||
|
||||
import { useFriendStore, useGroupStore, useSearchStore } from '../../stores';
|
||||
import { userImage } from '../../shared/utils';
|
||||
|
||||
@@ -134,6 +140,25 @@
|
||||
const quickSearchQuery = ref('');
|
||||
const isQuickSearchOpen = ref(false);
|
||||
|
||||
const friendsScrollAreaRef = ref(null);
|
||||
const groupsScrollAreaRef = ref(null);
|
||||
const friendsScrollTarget = ref(null);
|
||||
const groupsScrollTarget = ref(null);
|
||||
|
||||
function resolveScrollViewport(scrollAreaComponentRef) {
|
||||
// Our ScrollArea renders a DOM element root; the viewport is marked by data-slot.
|
||||
const rootEl = scrollAreaComponentRef?.$el ?? null;
|
||||
if (!rootEl || typeof rootEl.querySelector !== 'function') return null;
|
||||
return rootEl.querySelector('[data-slot="scroll-area-viewport"]');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Ensure child components are mounted before querying their DOM.
|
||||
await nextTick();
|
||||
friendsScrollTarget.value = resolveScrollViewport(friendsScrollAreaRef.value);
|
||||
groupsScrollTarget.value = resolveScrollViewport(groupsScrollAreaRef.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
quickSearchQuery,
|
||||
(value) => {
|
||||
@@ -142,11 +167,6 @@
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function handleQuickSearchFocus() {
|
||||
isQuickSearchOpen.value = true;
|
||||
quickSearchRemoteMethod(String(quickSearchQuery.value ?? ''));
|
||||
}
|
||||
|
||||
function handleQuickSearchSelect(value) {
|
||||
if (!value) {
|
||||
return;
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div v-else class="skeleton" aria-busy="true" aria-label="Loading">
|
||||
<!-- <div v-else class="skeleton" aria-busy="true" aria-label="Loading">
|
||||
<div>
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<div>
|
||||
@@ -53,7 +53,7 @@
|
||||
<Skeleton class="mt-1.5 h-3 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -42,11 +42,7 @@
|
||||
|
||||
const timeZone = getLocalTimeZone();
|
||||
|
||||
// JSDoc casts: this project can end up with nominal-type mismatches for DateValue
|
||||
// due to duplicate @internationalized/date copies in tooling.
|
||||
/** @type {import('vue').Ref<any>} */
|
||||
const internalValue = ref(fromDate(props.modelValue ?? new Date(), timeZone));
|
||||
/** @type {import('vue').Ref<any>} */
|
||||
const placeholder = ref(fromDate(props.modelValue ?? new Date(), timeZone));
|
||||
|
||||
watch(
|
||||
@@ -147,6 +143,10 @@
|
||||
:class="hasFollowingFor(weekDate) ? 'has-following' : 'no-following'">
|
||||
{{ eventCountFor(weekDate) }}
|
||||
</div>
|
||||
<!-- <div
|
||||
v-if="eventCountFor(weekDate) > 0"
|
||||
class="calendar-event-dot"
|
||||
aria-hidden="true" /> -->
|
||||
</div>
|
||||
</div>
|
||||
</CalendarCellTrigger>
|
||||
@@ -165,7 +165,6 @@
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.date {
|
||||
@@ -193,21 +192,20 @@
|
||||
|
||||
.calendar-event-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9px;
|
||||
color: var(--el-color-white, #fff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
font-size: 10px;
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
z-index: 10;
|
||||
padding: 0 5px;
|
||||
line-height: 18px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.calendar-event-badge.has-following {
|
||||
@@ -217,4 +215,18 @@
|
||||
.calendar-event-badge.no-following {
|
||||
background-color: var(--group-calendar-badge-normal, var(--el-color-primary));
|
||||
}
|
||||
|
||||
.calendar-event-dot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 4px;
|
||||
transform: translateX(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--group-calendar-event-dot, #ef4444);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Dialog :open="visible" @update:open="(open) => (open ? null : closeDialog())">
|
||||
<DialogContent class="x-dialog w-[90vw] max-w-[90vw] sm:max-w-[70vw] h-[60vh] overflow-hidden">
|
||||
<DialogContent class="x-dialog sm:max-w-[50vw] h-[60vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div class="dialog-title-container">
|
||||
<DialogTitle>{{ t('dialog.group_calendar.header') }}</DialogTitle>
|
||||
@@ -18,82 +18,77 @@
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div class="top-content">
|
||||
<transition name="el-fade-in-linear" mode="out-in">
|
||||
<div v-if="viewMode === 'timeline'" key="timeline" class="timeline-view">
|
||||
<div class="timeline-container">
|
||||
<el-timeline v-if="groupedTimelineEvents.length">
|
||||
<el-timeline-item
|
||||
v-for="(timeGroup, key) of groupedTimelineEvents"
|
||||
:key="key"
|
||||
:timestamp="
|
||||
dayjs(timeGroup.startsAt).format('MM-DD ddd') + ' ' + timeGroup.startTime
|
||||
"
|
||||
placement="top">
|
||||
<div class="time-group-container">
|
||||
<GroupCalendarEventCard
|
||||
v-for="value in timeGroup.events"
|
||||
:key="value.id"
|
||||
:event="value"
|
||||
mode="timeline"
|
||||
:is-following="isEventFollowing(value.id)"
|
||||
:card-class="{ 'grouped-card': timeGroup.events.length > 1 }"
|
||||
@update-following-calendar-data="updateFollowingCalendarData"
|
||||
@click-action="showGroupDialog(value.ownerId)" />
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-else>{{ t('dialog.group_calendar.no_events') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-container">
|
||||
<GroupCalendarMonth
|
||||
v-model="selectedDay"
|
||||
:is-loading="isLoading"
|
||||
:events-by-date="filteredCalendar"
|
||||
:following-by-date="followingCalendarDate" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else key="grid" class="grid-view">
|
||||
<div class="search-container">
|
||||
<InputGroupSearch
|
||||
v-model="searchQuery"
|
||||
size="sm"
|
||||
:placeholder="t('dialog.group_calendar.search_placeholder')"
|
||||
class="search-input" />
|
||||
</div>
|
||||
|
||||
<div class="groups-grid" v-loading="isLoading">
|
||||
<div v-if="filteredGroupEvents.length" class="groups-container">
|
||||
<div v-for="group in filteredGroupEvents" :key="group.groupId" class="group-row">
|
||||
<div class="group-header" @click="toggleGroup(group.groupId)">
|
||||
<ArrowRight
|
||||
class="rotation-transition"
|
||||
:class="{ rotate: !groupCollapsed[group.groupId] }" />
|
||||
{{ group.groupName }}
|
||||
</div>
|
||||
<div class="events-row" v-show="!groupCollapsed[group.groupId]">
|
||||
<GroupCalendarEventCard
|
||||
v-for="event in group.events"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
mode="grid"
|
||||
:is-following="isEventFollowing(event.id)"
|
||||
@update-following-calendar-data="updateFollowingCalendarData"
|
||||
@click-action="showGroupDialog(event.ownerId)"
|
||||
card-class="grid-card" />
|
||||
</div>
|
||||
<div v-if="viewMode === 'timeline'" key="timeline" class="timeline-view">
|
||||
<div class="timeline-container">
|
||||
<div v-if="groupedTimelineEvents.length" class="timeline-list">
|
||||
<div v-for="(timeGroup, key) of groupedTimelineEvents" :key="key" class="timeline-group">
|
||||
<div class="timeline-timestamp">
|
||||
{{ dayjs(timeGroup.startsAt).format('MM-DD ddd') }} {{ timeGroup.startTime }}
|
||||
</div>
|
||||
<div class="time-group-container">
|
||||
<GroupCalendarEventCard
|
||||
v-for="value in timeGroup.events"
|
||||
:key="value.id"
|
||||
:event="value"
|
||||
mode="timeline"
|
||||
:is-following="isEventFollowing(value.id)"
|
||||
:card-class="{ 'grouped-card': timeGroup.events.length > 1 }"
|
||||
@update-following-calendar-data="updateFollowingCalendarData"
|
||||
@click-action="showGroupDialog(value.ownerId)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-events">
|
||||
{{
|
||||
searchQuery
|
||||
? t('dialog.group_calendar.search_no_matching')
|
||||
: t('dialog.group_calendar.search_no_this_month')
|
||||
}}
|
||||
</div>
|
||||
<div v-else class="timeline-empty">{{ t('dialog.group_calendar.no_events') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-container">
|
||||
<GroupCalendarMonth
|
||||
v-model="selectedDay"
|
||||
:is-loading="isLoading"
|
||||
:events-by-date="filteredCalendar"
|
||||
:following-by-date="followingCalendarDate" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else key="grid" class="grid-view">
|
||||
<div class="search-container">
|
||||
<InputGroupSearch
|
||||
v-model="searchQuery"
|
||||
size="sm"
|
||||
:placeholder="t('dialog.group_calendar.search_placeholder')"
|
||||
class="search-input" />
|
||||
</div>
|
||||
|
||||
<div class="groups-grid" v-loading="isLoading">
|
||||
<div v-if="filteredGroupEvents.length" class="groups-container">
|
||||
<div v-for="group in filteredGroupEvents" :key="group.groupId" class="group-row">
|
||||
<div class="group-header" @click="toggleGroup(group.groupId)">
|
||||
<ArrowRight
|
||||
class="rotation-transition"
|
||||
:class="{ rotate: !groupCollapsed[group.groupId] }" />
|
||||
{{ group.groupName }}
|
||||
</div>
|
||||
<div class="events-row" v-show="!groupCollapsed[group.groupId]">
|
||||
<GroupCalendarEventCard
|
||||
v-for="event in group.events"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
mode="grid"
|
||||
:is-following="isEventFollowing(event.id)"
|
||||
@update-following-calendar-data="updateFollowingCalendarData"
|
||||
@click-action="showGroupDialog(event.ownerId)"
|
||||
card-class="grid-card" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-events">
|
||||
{{
|
||||
searchQuery
|
||||
? t('dialog.group_calendar.search_no_matching')
|
||||
: t('dialog.group_calendar.search_no_this_month')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -334,7 +329,8 @@
|
||||
.sort((a, b) => dayjs(a.startsAt).diff(dayjs(b.startsAt)));
|
||||
});
|
||||
|
||||
const formatDateKey = (date) => formatDateFilter(date, 'date');
|
||||
// Use a stable key for calendar maps (independent of locale/appearance date formatting).
|
||||
const formatDateKey = (date) => dayjs(date).format('YYYY-MM-DD');
|
||||
|
||||
function getGroupNameFromCache(groupId) {
|
||||
if (!groupNamesCache.has(groupId)) {
|
||||
@@ -462,18 +458,36 @@
|
||||
overflow: hidden;
|
||||
.timeline-view {
|
||||
.timeline-container {
|
||||
:deep(.el-timeline) {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
padding-left: 4px;
|
||||
padding-right: 16px;
|
||||
margin-left: 10px;
|
||||
margin-right: 6px;
|
||||
overflow: auto;
|
||||
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.timeline-group {
|
||||
padding: 0 20px 6px 10px;
|
||||
}
|
||||
|
||||
.timeline-timestamp {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeline-empty {
|
||||
height: 100%;
|
||||
min-width: 200px;
|
||||
padding-left: 4px;
|
||||
padding-right: 16px;
|
||||
margin-left: 10px;
|
||||
margin-right: 6px;
|
||||
overflow: auto;
|
||||
.el-timeline-item {
|
||||
padding: 0 20px 20px 10px;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.time-group-container {
|
||||
display: flex;
|
||||
@@ -571,7 +585,6 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.calendar-container {
|
||||
width: 609px;
|
||||
|
||||
Reference in New Issue
Block a user