Compare commits
7 Commits
e071be8def
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
7325fddd45
|
|||
|
2f91713c11
|
|||
|
24a99900b1
|
|||
|
99ade46247
|
|||
|
2edf97bf1c
|
|||
|
0cbb496fab
|
|||
|
8ca50b327a
|
@@ -15,7 +15,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -23,37 +23,37 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.3",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"zustand": "^5.0.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/bun": "^1.2.22",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/bun": "^1.2.23",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^30.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"electron": "^30.5.1",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.23",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.20",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -336,35 +336,35 @@
|
||||
|
||||
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.13", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.18", "source-map-js": "^1.2.1", "tailwindcss": "4.1.13" } }, "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw=="],
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.13", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.13", "@tailwindcss/oxide-darwin-arm64": "4.1.13", "@tailwindcss/oxide-darwin-x64": "4.1.13", "@tailwindcss/oxide-freebsd-x64": "4.1.13", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", "@tailwindcss/oxide-linux-x64-musl": "4.1.13", "@tailwindcss/oxide-wasm32-wasi": "4.1.13", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" } }, "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA=="],
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.13", "", { "os": "android", "cpu": "arm64" }, "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew=="],
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ=="],
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw=="],
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ=="],
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13", "", { "os": "linux", "cpu": "arm" }, "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw=="],
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ=="],
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg=="],
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ=="],
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ=="],
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.13", "", { "dependencies": { "@emnapi/core": "^1.4.5", "@emnapi/runtime": "^1.4.5", "@emnapi/wasi-threads": "^1.0.4", "@napi-rs/wasm-runtime": "^0.2.12", "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg=="],
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.13", "", { "os": "win32", "cpu": "x64" }, "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw=="],
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.13", "", { "dependencies": { "@tailwindcss/node": "4.1.13", "@tailwindcss/oxide": "4.1.13", "tailwindcss": "4.1.13" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ=="],
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
|
||||
|
||||
@@ -386,7 +386,7 @@
|
||||
|
||||
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
|
||||
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
|
||||
|
||||
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
|
||||
|
||||
@@ -414,7 +414,7 @@
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/react": ["@types/react@18.3.24", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A=="],
|
||||
"@types/react": ["@types/react@18.3.26", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
@@ -526,7 +526,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
|
||||
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
|
||||
|
||||
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
|
||||
|
||||
@@ -692,7 +692,7 @@
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@4.6.2", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ=="],
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.22", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-atkAG6QaJMGoTLc4MDAP+rqZcfwQuTIh2IqHWFLy2TEjxr0MOK+5BSG4RzL2564AAPpZkDRsZXAUz68kjnU6Ug=="],
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.23", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="],
|
||||
|
||||
@@ -1238,7 +1238,7 @@
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.13", "", {}, "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w=="],
|
||||
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
|
||||
|
||||
"tapable": ["tapable@2.2.3", "", {}, "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg=="],
|
||||
|
||||
@@ -1272,7 +1272,7 @@
|
||||
|
||||
"type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
@@ -1386,12 +1386,14 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@types/react-syntax-highlighter/@types/react": ["@types/react@18.3.24", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"app-builder-lib/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||
@@ -1402,6 +1404,8 @@
|
||||
|
||||
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
||||
|
||||
"config-file-ts/typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||
|
||||
"decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite --open",
|
||||
"dev:web": "VITE_APP_MODE=web bunx --bun vite",
|
||||
"dev:electron": "bunx --bun vite --open",
|
||||
"build": "tsc && bunx --bun vite build && electron-builder",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "bunx --bun vite preview"
|
||||
@@ -21,7 +22,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -29,37 +30,37 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.3",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/bun": "^1.2.22",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/bun": "^1.2.23",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^30.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"electron": "^30.5.1",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.23",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5"
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.20",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ function App(props: { socket: Socket }) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="discord-theme">
|
||||
<ThemeProvider defaultTheme="system" storageKey="concord-theme">
|
||||
<Router>
|
||||
<div className="h-screen w-screen overflow-hidden bg-background text-foreground">
|
||||
<Routes>
|
||||
|
||||
@@ -38,11 +38,6 @@ const ChannelItem: React.FC<ChannelItemProps> = ({ channel }) => {
|
||||
if (isConnectedToThisChannel) {
|
||||
leaveChannel();
|
||||
} else if (currentUser && token) {
|
||||
console.log({
|
||||
channelId: channel.id,
|
||||
currentUser: currentUser.id,
|
||||
token: token,
|
||||
});
|
||||
joinChannel(channel.id, currentUser.id, token);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +47,7 @@ const ChannelItem: React.FC<ChannelItemProps> = ({ channel }) => {
|
||||
const connectedUserIds = Array.from(remoteStreams.keys());
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${isActive ?? "visible"}`}>
|
||||
<button
|
||||
onClick={handleChannelClick}
|
||||
className={`w-full flex items-center p-1.5 rounded-md text-left transition-colors ${
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Plus, Edit } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { CategoryWithChannels } from "@/types/api";
|
||||
import ChannelItem from "@/components/channel/ChannelItem";
|
||||
|
||||
@@ -23,48 +16,22 @@ const CategoryHeader: React.FC<CategoryHeaderProps> = ({
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-1 py-1 h-6 text-xs font-semibold text-gray-400 uppercase tracking-wide hover:text-gray-300 group"
|
||||
onClick={(e) => {
|
||||
// Only toggle if not right-clicking (which opens dropdown)
|
||||
if (e.button === 0) {
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} className="mr-1" />
|
||||
) : (
|
||||
<ChevronRight size={12} className="mr-1" />
|
||||
)}
|
||||
<span className="truncate">{category.name}</span>
|
||||
</div>
|
||||
<Plus
|
||||
size={12}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem>
|
||||
<Plus size={14} className="mr-2" />
|
||||
Create Channel
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Edit size={14} className="mr-2" />
|
||||
Edit Category
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-400 focus:text-red-400">
|
||||
Delete Category
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between p-4 h-6 text-md text-concord-primary font-semibold interactive-hover uppercase tracking-wide group"
|
||||
onClick={() => {
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} className="mr-1" />
|
||||
) : (
|
||||
<ChevronRight size={12} className="mr-1" />
|
||||
)}
|
||||
<span className="truncate">{category.name}</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -114,16 +81,17 @@ const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
|
||||
onToggle={() => toggleCategory(category.id)}
|
||||
/>
|
||||
|
||||
{/* Channels */}
|
||||
{isExpanded && (
|
||||
<div className="ml-2 space-y-0.5">
|
||||
{category.channels
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((channel) => (
|
||||
<ChannelItem key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`ml-2 space-y-0.5 transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
isExpanded ? "max-h-screen opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{category.channels
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((channel) => (
|
||||
<ChannelItem key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, ErrorInfo, ReactNode } from "react";
|
||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -48,8 +49,8 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full space-y-4">
|
||||
<div className="min-h-screen bg-concord-primary flex items-center justify-center p-4">
|
||||
<Card className="max-w-md bg-concord-secondary border-concord w-full space-y-4">
|
||||
<Alert className="border-red-500 bg-red-950/50">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
<AlertTitle className="text-red-400">
|
||||
@@ -82,7 +83,7 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
{/* Error details in development */}
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
<details className="mt-4 p-3 bg-gray-800 rounded-lg text-sm">
|
||||
<details className="mt-4 p-3 bg-concord-secondary rounded-lg text-sm">
|
||||
<summary className="cursor-pointer text-red-400 font-medium mb-2">
|
||||
Error Details (Development)
|
||||
</summary>
|
||||
@@ -107,7 +108,7 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,12 +34,12 @@ const AppLayout: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-concord-primary text-concord-primary">
|
||||
<div className="flex h-screen overflow-y-auto bg-concord-primary text-concord-primary">
|
||||
{/* This component handles playing audio from remote users */}
|
||||
<VoiceConnectionManager />
|
||||
|
||||
{/* Server List Sidebar - Always visible on desktop, overlay on mobile */}
|
||||
<div className="relative w-[72px] sidebar-primary flex-shrink-0">
|
||||
<div className="relative min-w-1/16 sidebar-primary flex-shrink-0">
|
||||
<ServerSidebar />
|
||||
</div>
|
||||
{/* Channel Sidebar - Only shown when in a server context and not collapsed */}
|
||||
@@ -68,7 +68,7 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
{/* Member List - Only shown when in a channel and member list is enabled */}
|
||||
{showMemberList && shouldShowChannelSidebar && (
|
||||
<div className="flex-0 sidebar-secondary order-l border-sidebar">
|
||||
<div className="flex-0 min-w-1/7 sidebar-secondary order-l border-sidebar">
|
||||
<MemberList />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { ChevronDown, Plus, Users } from "lucide-react";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useInstanceDetails } from "@/hooks/useServers";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import ChannelList from "@/components/channel/ChannelList";
|
||||
import { CreateCategoryModal, CreateChannelModal } from "../server/ServerIcon";
|
||||
import { CreateChannelModal } from "@/components/modals/CreateChannelModal";
|
||||
|
||||
const ChannelSidebar: React.FC = () => {
|
||||
const { instanceId } = useParams();
|
||||
@@ -14,8 +14,8 @@ const ChannelSidebar: React.FC = () => {
|
||||
useInstanceDetails(instanceId);
|
||||
const categories = instance?.categories;
|
||||
const {
|
||||
toggleMemberList,
|
||||
showMemberList,
|
||||
showCreateChannel,
|
||||
closeCreateChannel,
|
||||
openCreateChannel,
|
||||
openServerSettings,
|
||||
} = useUiStore();
|
||||
@@ -45,11 +45,11 @@ const ChannelSidebar: React.FC = () => {
|
||||
<div className="sidebar-secondary flex-1">
|
||||
<ScrollArea className="">
|
||||
{/* Server Header */}
|
||||
<div className="flex items-center justify-between border-b border-concord-primary shadow-sm px-4 py-3">
|
||||
<div className="flex items-center justify-between border-b border-concord-primary shadow-sm px-6 py-4">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center justify-between w-full h-8 font-semibold text-concord-primary hover:bg-concord-tertiary"
|
||||
className="flex items-center justify-between w-full h-8 font-semibold text-concord-primary text-xl hover:bg-concord-tertiary"
|
||||
onClick={openServerSettings}
|
||||
>
|
||||
<span className="truncate">{instance.name}</span>
|
||||
@@ -73,30 +73,25 @@ const ChannelSidebar: React.FC = () => {
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="border-t border-sidebar px-2 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="interactive-hover"
|
||||
className="justify-start interactive-hover flex-grow-1"
|
||||
onClick={openCreateChannel}
|
||||
>
|
||||
<Plus size={16} className="mr-1" />
|
||||
Add Channel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 ${showMemberList ? "text-interactive-active" : "interactive-hover"}`}
|
||||
onClick={toggleMemberList}
|
||||
>
|
||||
<Users size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<CreateChannelModal />
|
||||
<CreateCategoryModal />
|
||||
<CreateChannelModal
|
||||
isOpen={showCreateChannel}
|
||||
onClose={closeCreateChannel}
|
||||
categories={categories}
|
||||
instanceId={instance.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,12 +4,10 @@ import { Crown, Shield, UserIcon } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Role } from "@/types/database";
|
||||
import { Role, User } from "@/types/database";
|
||||
import { useInstanceMembers } from "@/hooks/useServers";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { User } from "@/types/database";
|
||||
|
||||
// Status color utility
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "online":
|
||||
@@ -27,23 +25,30 @@ interface MemberItemProps {
|
||||
member: User;
|
||||
instanceId: string;
|
||||
isOwner?: boolean;
|
||||
currentUserRole: "member" | "mod" | "admin";
|
||||
currentUserRolePriority: number;
|
||||
}
|
||||
|
||||
// Get the user's role for this specific instance
|
||||
const getUserRoleForInstance = (roles: Role[], instanceId: string) => {
|
||||
return roles.find((r) => r.instanceId === instanceId)?.role || "member";
|
||||
const getUserRoleForInstance = (roles: Role[], instanceId: string): string => {
|
||||
if (!instanceId) return "member";
|
||||
const roleEntry = roles.find((r) => r.instanceId === instanceId);
|
||||
return roleEntry?.role || "member";
|
||||
};
|
||||
|
||||
// Define role colors and priorities
|
||||
const getRoleInfo = (role: string) => {
|
||||
switch (role) {
|
||||
const lowerRole = role.toLowerCase();
|
||||
switch (lowerRole) {
|
||||
case "admin":
|
||||
return { color: "#ff6b6b", priority: 3, name: "Admin" };
|
||||
case "mod":
|
||||
return { color: "#4ecdc4", priority: 2, name: "Moderator" };
|
||||
default:
|
||||
case "member":
|
||||
return { color: null, priority: 1, name: "Member" };
|
||||
default:
|
||||
return {
|
||||
color: null,
|
||||
priority: 0,
|
||||
name: role.charAt(0).toUpperCase() + role.slice(1),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,78 +56,99 @@ const MemberItem: React.FC<MemberItemProps> = ({
|
||||
member,
|
||||
instanceId,
|
||||
isOwner = false,
|
||||
currentUserRolePriority,
|
||||
}) => {
|
||||
const userRole = getUserRoleForInstance(member.roles, instanceId || "");
|
||||
// Determine the role for this specific instance
|
||||
const userRole = getUserRoleForInstance(member.roles, instanceId);
|
||||
const roleInfo = getRoleInfo(userRole);
|
||||
const memberRolePriority = roleInfo.priority;
|
||||
|
||||
// Consider if this member is a global admin as well
|
||||
const isGlobalAdmin = member.admin || false;
|
||||
let effectiveRoleInfo = roleInfo;
|
||||
let effectiveMemberRolePriority = memberRolePriority;
|
||||
|
||||
if (isGlobalAdmin && roleInfo.priority < 3) {
|
||||
effectiveRoleInfo = getRoleInfo("admin");
|
||||
effectiveMemberRolePriority = 3;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
|
||||
disabled={member.admin}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="relative">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage
|
||||
src={member.picture || undefined}
|
||||
alt={member.username}
|
||||
/>
|
||||
<AvatarFallback className="text-xs bg-primary text-primary-foreground">
|
||||
{member.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-sidebar ${getStatusColor(member.status)}`}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
|
||||
// disable if the current member is an admin
|
||||
disabled={currentUserRolePriority < 3 || effectiveMemberRolePriority >= 3}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="relative">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage
|
||||
src={member.picture || undefined}
|
||||
alt={member.username}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="flex items-center gap-1">
|
||||
{isOwner && (
|
||||
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
{!isOwner && userRole !== "member" && (
|
||||
<Shield
|
||||
size={12}
|
||||
className="flex-shrink-0"
|
||||
style={{ color: roleInfo.color || "var(--background)" }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: roleInfo.color || "var(--color-text-primary)" }}
|
||||
>
|
||||
{member.nickname || member.username}
|
||||
</span>
|
||||
</div>
|
||||
{member.bio && (
|
||||
<div className="text-xs text-concord-secondary truncate">
|
||||
{member.bio}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AvatarFallback className="text-xs bg-primary text-primary-foreground">
|
||||
{member.username?.slice(0, 2).toUpperCase() || "???"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-sidebar ${getStatusColor(member.status)}`}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</>
|
||||
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="flex items-center gap-1">
|
||||
{isOwner && (
|
||||
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
{/* Display Shield for Admins and Mods, not for Members */}
|
||||
{!isOwner && effectiveMemberRolePriority > 1 && (
|
||||
<Shield
|
||||
size={12}
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
color: effectiveRoleInfo.color || "var(--background)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{
|
||||
color: effectiveRoleInfo.color || "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{member.nickname || member.username}
|
||||
</span>
|
||||
</div>
|
||||
{member.bio && (
|
||||
<div className="text-xs text-concord-secondary truncate">
|
||||
{member.bio}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberList: React.FC = () => {
|
||||
const { instanceId } = useParams();
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const { data: members, isLoading } = useInstanceMembers(instanceId);
|
||||
const { user: currentUser } = useAuthStore();
|
||||
|
||||
const currentUserRole = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return "member";
|
||||
if (currentUser.admin) return "admin";
|
||||
const currentUserRoleInfo = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) {
|
||||
return { role: "member", priority: 1, name: "Member", color: null };
|
||||
}
|
||||
|
||||
const userRole = currentUser.roles.find(
|
||||
(role) => role.instanceId === instanceId,
|
||||
);
|
||||
return userRole?.role || "member";
|
||||
// If the current user is a global admin, they are effectively an admin of any instance.
|
||||
if (currentUser.admin) {
|
||||
return { role: "admin", priority: 3, name: "Admin", color: "#ff6b6b" };
|
||||
}
|
||||
|
||||
const role = getUserRoleForInstance(currentUser.roles, instanceId);
|
||||
return { ...getRoleInfo(role), role: role };
|
||||
}, [currentUser, instanceId]);
|
||||
|
||||
if (!instanceId) {
|
||||
@@ -140,17 +166,26 @@ const MemberList: React.FC = () => {
|
||||
if (!members || members.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-concord-secondary text-sm">No members</div>
|
||||
<div className="text-concord-secondary text-sm">No members found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group members by role
|
||||
// Group members by their role for the current instance.
|
||||
const groupedMembers = members.reduce(
|
||||
(acc, member) => {
|
||||
const userRole =
|
||||
member.roles.find((r) => r.instanceId === instanceId)?.role || "member";
|
||||
const roleInfo = getRoleInfo(userRole);
|
||||
// Determine the effective role for this instance.
|
||||
let effectiveRoleName = getUserRoleForInstance(
|
||||
member.roles as Role[],
|
||||
instanceId,
|
||||
);
|
||||
|
||||
// Global admin is instance admin
|
||||
if (member.admin && effectiveRoleName !== "admin") {
|
||||
effectiveRoleName = "admin";
|
||||
}
|
||||
|
||||
const roleInfo = getRoleInfo(effectiveRoleName);
|
||||
|
||||
if (!acc[roleInfo.name]) {
|
||||
acc[roleInfo.name] = [];
|
||||
@@ -161,53 +196,66 @@ const MemberList: React.FC = () => {
|
||||
{} as Record<string, User[]>,
|
||||
);
|
||||
|
||||
// Sort role groups by priority (admin > mod > member)
|
||||
const sortedRoleGroups = Object.entries(groupedMembers).sort(
|
||||
([roleNameA], [roleNameB]) => {
|
||||
const priorityA = getRoleInfo(roleNameA.toLowerCase())?.priority || 1;
|
||||
const priorityB = getRoleInfo(roleNameB.toLowerCase())?.priority || 1;
|
||||
// Get all unique role names present and sort them by priority.
|
||||
const sortedRoleNames = Object.keys(groupedMembers).sort(
|
||||
(roleNameA, roleNameB) => {
|
||||
const priorityA = getRoleInfo(roleNameA).priority;
|
||||
const priorityB = getRoleInfo(roleNameB).priority;
|
||||
return priorityB - priorityA;
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 border-l border-concord-primary h-full bg-concord-secondary">
|
||||
<div className="flex flex-col flex-grow-1 w-full border-l border-concord h-full sidebar-secondary">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-concord-primary flex items-center justify-between">
|
||||
<UserIcon size={20} className="text-concord-primary h-8" />
|
||||
<p className="text-sm font-semibold text-concord-secondary tracking-wide">
|
||||
{members.length} Members
|
||||
<div className="px-6 py-4 pb-5 border-b border-concord flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserIcon size={20} className="h-5 w-5 text-concord-secondary" />
|
||||
<p className="font-semibold text-xl text-concord-primary tracking-wide">
|
||||
Members
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-concord-secondary tracking-wide">
|
||||
{members.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Member List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-2">
|
||||
{sortedRoleGroups.map(([roleName, roleMembers]) => (
|
||||
<div key={roleName} className="mb-4">
|
||||
{/* Role Header */}
|
||||
<div className="px-4 py-1">
|
||||
<h4 className="text-xs font-semibold text-concord-secondary uppercase tracking-wide">
|
||||
{roleName} — {roleMembers.length}
|
||||
</h4>
|
||||
</div>
|
||||
{sortedRoleNames.map((roleName) => {
|
||||
const roleMembers = groupedMembers[roleName];
|
||||
// Sort members within each role group alphabetically by username.
|
||||
const sortedMembers = roleMembers.sort((a, b) =>
|
||||
(a.nickname || a.username).localeCompare(
|
||||
b.nickname || b.username,
|
||||
),
|
||||
);
|
||||
|
||||
{/* Role Members */}
|
||||
<div className="space-y-1">
|
||||
{roleMembers
|
||||
.sort((a, b) => a.username.localeCompare(b.username))
|
||||
.map((member) => (
|
||||
return (
|
||||
<div key={roleName} className="mb-4">
|
||||
{/* Role Header */}
|
||||
<div className="px-4 py-1">
|
||||
<h4 className="text-xs font-semibold text-concord-secondary uppercase tracking-wide">
|
||||
{roleName} — {roleMembers.length}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Role Members */}
|
||||
<div className="space-y-1">
|
||||
{sortedMembers.map((member) => (
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
instanceId={instanceId}
|
||||
currentUserRole={currentUserRole}
|
||||
currentUserRolePriority={currentUserRoleInfo.priority}
|
||||
isOwner={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -51,31 +51,29 @@ const ServerSidebar: React.FC = () => {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="sidebar-primary flex flex-col items-center h-full py-2 space-y-2">
|
||||
<div className="sidebar-primary flex flex-col items-center h-full space-y-2 w-full">
|
||||
{/* Home/DM Button */}
|
||||
<Tooltip>
|
||||
<Tooltip key={"home-server"}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-12 h-12 ml-0 rounded-2xl hover:rounded-xl transition-all duration-200 ${
|
||||
className={`w-12 h-12 mt-2 transition-all duration-200 ${
|
||||
!instanceId || instanceId === "@me"
|
||||
? "bg-primary text-primary-foreground rounded-xl"
|
||||
: "hover:bg-primary/10"
|
||||
? "rounded-xl border-primary bg-primary/10 border-2"
|
||||
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
|
||||
}`}
|
||||
onClick={handleHomeClick}
|
||||
>
|
||||
<Home size={24} />
|
||||
<Home size={4} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-8 h-0.5 bg-border rounded-full" />
|
||||
|
||||
<div className="w-full h-0.5 bg-border rounded-full" />{" "}
|
||||
{/* Server List */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin scrollbar-thumb-border space-y-2">
|
||||
{isLoading ? (
|
||||
@@ -116,26 +114,25 @@ const ServerSidebar: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Add Server Button - Only show if user can create servers */}
|
||||
{canCreateServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 mb-4 h-12 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
|
||||
onClick={handleCreateServer}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Add a Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Server Button - Only show if user can create servers */}
|
||||
{canCreateServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 ml-3 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
|
||||
onClick={handleCreateServer}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Add a Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -8,18 +8,10 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useLogout } from "@/hooks/useAuth";
|
||||
import { useVoiceStore } from "@/stores/voiceStore";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
// Status color utility
|
||||
const getStatusColor = (status: string) => {
|
||||
@@ -35,70 +27,12 @@ const getStatusColor = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// User Status Dropdown Component
|
||||
interface UserStatusDropdownProps {
|
||||
currentStatus: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
|
||||
// currentStatus,
|
||||
onStatusChange,
|
||||
children,
|
||||
}) => {
|
||||
const { mutate: logout } = useLogout();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "online", label: "Online", color: "bg-status-online" },
|
||||
{ value: "away", label: "Away", color: "bg-status-away" },
|
||||
{ value: "busy", label: "Do Not Disturb", color: "bg-status-busy" },
|
||||
{ value: "offline", label: "Invisible", color: "bg-status-offline" },
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="checkchekchek" asChild>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-48">
|
||||
{statusOptions.map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status.value}
|
||||
onClick={() => onStatusChange(status.value)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-3 h-3 rounded-full ${status.color}`} />
|
||||
<span>{status.label}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => useUiStore.getState().openUserSettings()}
|
||||
>
|
||||
<Settings size={16} className="mr-2" />
|
||||
User Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => logout()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Log Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
// Voice Controls Component
|
||||
interface VoiceControlsProps {
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
onMuteToggle: () => void;
|
||||
onDeafenToggle: () => void;
|
||||
onSettingsClick: () => void;
|
||||
}
|
||||
|
||||
const VoiceControls: React.FC<VoiceControlsProps> = ({
|
||||
@@ -106,7 +40,6 @@ const VoiceControls: React.FC<VoiceControlsProps> = ({
|
||||
isDeafened,
|
||||
onMuteToggle,
|
||||
onDeafenToggle,
|
||||
onSettingsClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
@@ -146,21 +79,6 @@ const VoiceControls: React.FC<VoiceControlsProps> = ({
|
||||
</Tooltip>
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 interactive-hover"
|
||||
onClick={onSettingsClick}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>User Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
@@ -207,40 +125,25 @@ const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Main UserPanel Component
|
||||
const UserPanel: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const { openUserSettings } = useUiStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { isConnected, isMuted, isDeafened, toggleMute, toggleDeafen } =
|
||||
useVoiceStore();
|
||||
|
||||
const handleStatusChange = (newStatus: string) => {
|
||||
console.log("Status change to:", newStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
|
||||
{/* User Info with Dropdown */}
|
||||
<UserStatusDropdown
|
||||
currentStatus={user?.status as string}
|
||||
onStatusChange={handleStatusChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-1 flex items-center h-auto p-1 rounded-md hover:bg-concord-secondary"
|
||||
>
|
||||
<UserAvatar user={user} size="md" />
|
||||
<div className="ml-2 flex-1 min-w-0 text-left">
|
||||
<div className="text-sm font-medium text-concord-primary truncate">
|
||||
{user?.nickname || user?.username}
|
||||
</div>
|
||||
<div className="text-xs text-concord-secondary truncate capitalize">
|
||||
{user?.status}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</UserStatusDropdown>
|
||||
<div className="user-panel flex items-center p-3 bg-concord-tertiary border-t border-sidebar min-h-16 rounded-xl m-2">
|
||||
{/* User Info */}
|
||||
<UserAvatar user={user} size="md" />
|
||||
<div className="ml-2 flex-1 min-w-0 text-left">
|
||||
<div className="text-sm font-medium text-concord-primary truncate">
|
||||
{user?.nickname || user?.username}
|
||||
</div>
|
||||
<div className="text-xs text-concord-secondary truncate capitalize">
|
||||
{user?.status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice Controls */}
|
||||
{isConnected && (
|
||||
@@ -249,9 +152,26 @@ const UserPanel: React.FC = () => {
|
||||
isDeafened={isDeafened}
|
||||
onMuteToggle={toggleMute}
|
||||
onDeafenToggle={toggleDeafen}
|
||||
onSettingsClick={openUserSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 interactive-hover"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>User Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import Avatar from "@/components/common/Avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Edit, Trash2, Reply, MoreHorizontal } from "lucide-react";
|
||||
import { Message, User } from "@/types/database";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface MessageProps {
|
||||
message: Message;
|
||||
user: any;
|
||||
currentUser?: any;
|
||||
isGrouped?: boolean | null;
|
||||
onEdit?: (messageId: string) => void;
|
||||
onDelete?: (messageId: string) => void;
|
||||
onReply?: (messageId: string) => void;
|
||||
replyTo?: Message | null;
|
||||
replyToUser?: User | null;
|
||||
}
|
||||
|
||||
const MessageComponent: React.FC<MessageProps> = ({
|
||||
message,
|
||||
user,
|
||||
currentUser,
|
||||
isGrouped = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
|
||||
};
|
||||
|
||||
const renderContent = (content: string) => {
|
||||
// Simple code block detection
|
||||
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||
// Add text before code block
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(
|
||||
<span key={lastIndex}>{content.slice(lastIndex, match.index)}</span>,
|
||||
);
|
||||
}
|
||||
|
||||
// Add code block
|
||||
const language = match[1] || "text";
|
||||
const code = match[2];
|
||||
parts.push(
|
||||
<div key={match.index} className="my-2">
|
||||
<div className="bg-concord-tertiary rounded-md p-3 border border-border">
|
||||
<div className="text-xs text-concord-secondary mb-2 font-mono">
|
||||
{language}
|
||||
</div>
|
||||
<pre className="text-sm font-mono text-concord-primary overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
lastIndex = codeBlockRegex.lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(<span key={lastIndex}>{content.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : content;
|
||||
};
|
||||
|
||||
const isOwnMessage = currentUser?.id === message.userId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative px-4 py-2 hover:bg-concord-secondary/50 transition-colors ${
|
||||
isGrouped ? "mt-0.5" : "mt-4"
|
||||
}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Avatar - only show if not grouped */}
|
||||
<div className="w-10 flex-shrink-0">
|
||||
{!isGrouped && <Avatar user={user} size="md" showStatus={true} />}
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header - only show if not grouped */}
|
||||
{!isGrouped && (
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-semibold text-concord-primary">
|
||||
{user.nickname || user.username}
|
||||
</span>
|
||||
<span className="text-xs text-concord-secondary">
|
||||
{formatTimestamp(message.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content */}
|
||||
<div className="text-concord-primary leading-relaxed">
|
||||
{renderContent(message.content)}
|
||||
</div>
|
||||
|
||||
{/* Reactions */}
|
||||
</div>
|
||||
|
||||
{/* Message actions */}
|
||||
{isHovered && (
|
||||
<div className="absolute top-0 right-4 bg-concord-secondary border border-border rounded-md shadow-md flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 interactive-hover"
|
||||
onClick={() => onReply?.(message.id)}
|
||||
>
|
||||
<Reply className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 interactive-hover"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(message.content)}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Text
|
||||
</DropdownMenuItem>
|
||||
{isOwnMessage && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => onEdit?.(message.id)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit Message
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete?.(message.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Message
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Message Input Component
|
||||
interface MessageInputProps {
|
||||
channelName?: string;
|
||||
onSendMessage: (content: string) => void;
|
||||
replyingTo?: Message;
|
||||
onCancelReply?: () => void;
|
||||
}
|
||||
|
||||
const MessageInput: React.FC<MessageInputProps> = ({
|
||||
channelName,
|
||||
onSendMessage,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
}) => {
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim()) {
|
||||
onSendMessage(content.trim());
|
||||
setContent("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
{replyingTo && (
|
||||
<div className="mb-2 p-2 bg-concord-tertiary rounded-t-lg border border-b-0 border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-concord-secondary">
|
||||
Replying to {replyingTo.userId}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-1"
|
||||
onClick={onCancelReply}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-concord-primary truncate">
|
||||
{replyingTo.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Message #${channelName || "channel"}`}
|
||||
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
rows={1}
|
||||
style={{
|
||||
minHeight: "44px",
|
||||
maxHeight: "200px",
|
||||
resize: "none",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
|
||||
Press Enter to send
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { type MessageProps, MessageComponent, MessageInput };
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Reply, MoreHorizontal } from "lucide-react";
|
||||
import { Message } from "@/lib/api-client";
|
||||
|
||||
interface MessageActionsModalProps {
|
||||
message: Message;
|
||||
// isOwnMessage?: boolean;
|
||||
onReply?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export const MessageActionsDropdown: React.FC<MessageActionsModalProps> = ({
|
||||
message,
|
||||
onReply,
|
||||
// isOwnMessage,
|
||||
}) => {
|
||||
const handleReply = () => {
|
||||
onReply?.(message.id);
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
navigator.clipboard.writeText(message.text);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center">
|
||||
<DropdownMenuItem onSelect={handleReply}>
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
<span>Reply</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleCopyText}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
<span>Copy Text</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
244
concord-client/src/components/message/MessageComponent.tsx
Normal file
244
concord-client/src/components/message/MessageComponent.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Copy, Reply, Pin } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { formatDistanceToNow, isValid, parseISO } from "date-fns";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||
import {
|
||||
dark,
|
||||
solarizedLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { Message } from "@/lib/api-client";
|
||||
import { useState } from "react";
|
||||
|
||||
// User type for message component
|
||||
interface MessageUser {
|
||||
id: string;
|
||||
username?: string;
|
||||
nickname?: string | null;
|
||||
picture?: string | null;
|
||||
}
|
||||
|
||||
// Message Props interface
|
||||
interface MessageProps {
|
||||
message: Message;
|
||||
user: MessageUser;
|
||||
replyTo?: Message;
|
||||
replyToUser?: MessageUser;
|
||||
onReply?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export const MessageComponent: React.FC<MessageProps> = ({
|
||||
message,
|
||||
user,
|
||||
replyTo,
|
||||
replyToUser,
|
||||
onReply,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
try {
|
||||
// First try parsing as ISO string
|
||||
let date = parseISO(timestamp);
|
||||
|
||||
// If that fails, try regular Date constructor
|
||||
if (!isValid(date)) {
|
||||
date = new Date(timestamp);
|
||||
}
|
||||
|
||||
// Final check if date is valid
|
||||
if (!isValid(date)) {
|
||||
console.error("Invalid timestamp:", timestamp);
|
||||
return "Invalid date";
|
||||
}
|
||||
|
||||
return formatDistanceToNow(date, { addSuffix: true });
|
||||
} catch (error) {
|
||||
console.error("Error formatting timestamp:", timestamp, error);
|
||||
return "Invalid date";
|
||||
}
|
||||
};
|
||||
// const isOwnMessage = currentUser?.id === message.userId;
|
||||
const { mode } = useTheme();
|
||||
|
||||
// Get username with fallback
|
||||
const username = user.username || user.username || "Unknown User";
|
||||
const displayName = user.nickname || user.nickname || username;
|
||||
|
||||
const isDeleted = message.deleted;
|
||||
|
||||
if (isDeleted) {
|
||||
return (
|
||||
<div className="px-4 py-2 opacity-50">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-concord-secondary italic border border-border rounded px-3 py-2 bg-concord-tertiary/50">
|
||||
This message has been deleted
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group relative px-4 py-2 hover:bg-concord-secondary/50 transition-colors"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Avatar - always show */}
|
||||
<div className="w-10 flex-shrink-0">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user.picture || undefined} alt={username} />
|
||||
<AvatarFallback className="text-sm bg-primary text-primary-foreground">
|
||||
{username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Reply line and reference */}
|
||||
{replyTo && replyToUser && (
|
||||
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
|
||||
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
|
||||
<span className="font-medium text-concord-primary">
|
||||
{replyToUser.nickname || replyToUser.username}
|
||||
</span>
|
||||
<span className="truncate max-w-xs opacity-75">
|
||||
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header - always show */}
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-semibold text-concord-primary">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-xs text-concord-secondary">
|
||||
{formatTimestamp(message.createdAt)}
|
||||
</span>
|
||||
{message.edited && (
|
||||
<span className="text-xs text-concord-secondary opacity-60">
|
||||
(edited)
|
||||
</span>
|
||||
)}
|
||||
{(message as any).pinned && (
|
||||
<Pin className="h-3 w-3 text-concord-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message content with markdown */}
|
||||
<div className="text-concord-primary leading-relaxed prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code: ({ className, children }) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return match ? (
|
||||
<div className="flex flex-row flex-1 max-w-2/3 flex-wrap !bg-transparent">
|
||||
<SyntaxHighlighter
|
||||
PreTag="div"
|
||||
children={String(children).replace(/\n$/, "")}
|
||||
language={match[1]}
|
||||
style={mode === "light" ? solarizedLight : dark}
|
||||
className="!bg-concord-secondary p-2 border-2 concord-border rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<code className={className}>{children}</code>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary pl-4 my-2 italic text-concord-secondary bg-concord-secondary/30 py-2 rounded-r">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="my-1 text-concord-primary">{children}</p>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-concord-primary">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-concord-primary">{children}</em>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside my-2 text-concord-primary">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside my-2 text-concord-primary">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-xl font-bold my-2 text-concord-primary">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-lg font-bold my-2 text-concord-primary">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-bold my-2 text-concord-primary">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message actions */}
|
||||
{isHovered && (
|
||||
<div className="absolute top-0 right-4 bg-concord-secondary border border-border rounded-md shadow-md flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 interactive-hover"
|
||||
onClick={() => onReply?.(message.id)}
|
||||
>
|
||||
<Reply className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 interactive-hover"
|
||||
onClick={() => navigator.clipboard.writeText(message.text)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{/*<MessageActionsDropdown
|
||||
message={message}
|
||||
onReply={() => onReply?.(message.id)}
|
||||
/>*/}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
108
concord-client/src/components/message/MessageInput.tsx
Normal file
108
concord-client/src/components/message/MessageInput.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Message } from "@/lib/api-client";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useSendMessage } from "@/hooks/useMessages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface MessageUser {
|
||||
id: string;
|
||||
username?: string;
|
||||
nickname?: string | null;
|
||||
picture?: string | null;
|
||||
}
|
||||
interface MessageInputProps {
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
replyingTo?: Message | null;
|
||||
onCancelReply?: () => void;
|
||||
replyingToUser: MessageUser | null;
|
||||
}
|
||||
|
||||
export const MessageInput: React.FC<MessageInputProps> = ({
|
||||
channelId,
|
||||
channelName,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
replyingToUser,
|
||||
}) => {
|
||||
const [content, setContent] = useState("");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
// Use the API hook for sending messages
|
||||
const sendMessageMutation = useSendMessage();
|
||||
|
||||
// Auto-resize textarea (using direct DOM access as a fallback, no ref needed)
|
||||
useEffect(() => {
|
||||
const textarea = document.getElementById(
|
||||
"message-input-textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim() && !sendMessageMutation.isPending) {
|
||||
try {
|
||||
await sendMessageMutation.mutateAsync({
|
||||
channelId,
|
||||
content: content.trim(),
|
||||
repliedMessageId: replyingTo?.id || null,
|
||||
});
|
||||
setContent("");
|
||||
onCancelReply?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
{replyingTo && replyingToUser && (
|
||||
<div className="mb-2 p-3 bg-concord-secondary rounded-lg border border-b-0 border-border">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
|
||||
<span className="font-medium text-concord-primary">
|
||||
{replyingToUser.nickname || replyingToUser.username}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-1 text-concord-secondary hover:text-concord-primary"
|
||||
onClick={onCancelReply}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-concord-primary truncate pl-2">
|
||||
{replyingTo.text.replace(/```[\s\S]*?```/g, "[code]")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<Textarea
|
||||
id="message-input-textarea" // Unique ID for DOM targeting
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Message #${channelName || "channel"}`}
|
||||
disabled={sendMessageMutation.isPending}
|
||||
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50 min-h-8 max-h-56"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,39 +11,58 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Hash, Volume2, Loader2 } from "lucide-react";
|
||||
import { useCreateChannel } from "@/hooks/useServers";
|
||||
import { CategoryWithChannels } from "@/types";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CreateChannelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
instanceId: string; // Changed to use instanceId instead of categories prop
|
||||
defaultCategoryId?: string;
|
||||
instanceId: string;
|
||||
categories: CategoryWithChannels[] | undefined;
|
||||
}
|
||||
|
||||
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
defaultCategoryId,
|
||||
categories,
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
|
||||
const createChannelMutation = useCreateChannel();
|
||||
|
||||
// Reset form when modal opens/closes
|
||||
// Reset form when modal opens or closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId(defaultCategoryId || "");
|
||||
setCategoryId("");
|
||||
} else {
|
||||
setCategoryId("");
|
||||
}
|
||||
}, [isOpen, defaultCategoryId]);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !categoryId) return;
|
||||
// Basic validation: ensure name is not empty and a category is selected
|
||||
if (!name.trim() || !categoryId || categoryId === "no-categories") {
|
||||
console.warn("Channel name and a valid category are required.");
|
||||
toast("Error", {
|
||||
description: "Channel name and a valid category are required.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createChannelMutation.mutateAsync({
|
||||
@@ -53,17 +72,25 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
categoryId,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
// Reset form after successful creation
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId(defaultCategoryId || "");
|
||||
setCategoryId(""); // Reset to default or empty
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create channel:", error);
|
||||
toast("Error", { description: <p>{`${error}`}</p> });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to determine if the form is in a valid state for submission
|
||||
const isFormInvalid =
|
||||
!name.trim() || // Name is required and cannot be just whitespace
|
||||
!categoryId || // Category must be selected
|
||||
categoryId === "no-categories" || // Handle the "no categories available" placeholder
|
||||
createChannelMutation.isPending; // Disable while mutation is in progress
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
@@ -72,12 +99,13 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Channel Type Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Channel Type</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "text" ? "default" : "outline"}
|
||||
variant={type === "text" ? "secondary" : "ghost"}
|
||||
onClick={() => setType("text")}
|
||||
className="flex-1"
|
||||
>
|
||||
@@ -86,7 +114,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "voice" ? "default" : "outline"}
|
||||
variant={type === "voice" ? "secondary" : "ghost"}
|
||||
onClick={() => setType("voice")}
|
||||
className="flex-1"
|
||||
>
|
||||
@@ -96,6 +124,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Name Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
@@ -107,6 +136,35 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-category">Category</Label>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onValueChange={(value) => setCategoryId(value)}
|
||||
required
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
// Display this option if there are no categories
|
||||
<SelectItem value="no-categories" disabled>
|
||||
No categories available
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Channel Description Textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-description">Description</Label>
|
||||
<Textarea
|
||||
@@ -118,20 +176,12 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
!categoryId ||
|
||||
createChannelMutation.isPending ||
|
||||
categoryId === "loading" ||
|
||||
categoryId === "no-categories"
|
||||
}
|
||||
>
|
||||
<Button type="submit" disabled={isFormInvalid}>
|
||||
{createChannelMutation.isPending ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Reply } from "lucide-react";
|
||||
import { Message } from "@/lib/api-client";
|
||||
|
||||
interface MessageActionsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: Message;
|
||||
isOwnMessage: boolean;
|
||||
onReply?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
onReply,
|
||||
}) => {
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[300px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message Actions</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onReply?.(message.id))}
|
||||
>
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
Reply
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() =>
|
||||
handleAction(() => navigator.clipboard.writeText(message.text))
|
||||
}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Text
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Instance } from "@/types/database";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "../ui/dialog";
|
||||
import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Category } from "@/types";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import {
|
||||
useCreateCategory,
|
||||
useCreateChannel,
|
||||
useCreateInstance,
|
||||
} from "@/hooks/useServers";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
interface ServerIconProps {
|
||||
server: Instance;
|
||||
@@ -47,17 +23,17 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
{/* Active indicator */}
|
||||
<div className="relative group w-12">
|
||||
{/* Active indicator - Positioned outside to the left */}
|
||||
<div
|
||||
className={`absolute left-0 top-1/2 transform -translate-y-1/2 w-1 bg-accent-foreground rounded transition-all duration-200 ${
|
||||
className={`absolute -left-2 top-1/2 transform -translate-y-1/2 w-1 bg-accent-foreground rounded transition-all duration-200 ${
|
||||
isActive ? "h-10 rounded-xl" : "rounded-r h-2 group-hover:h-5"
|
||||
}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-12 h-12 ml-3 transition-all duration-200 ${
|
||||
className={`w-12 h-12 transition-all duration-200 ${
|
||||
isActive
|
||||
? "rounded-xl border-primary bg-primary/10 border-2"
|
||||
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
|
||||
@@ -80,308 +56,4 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Create Server Modal
|
||||
export const CreateServerModal: React.FC = () => {
|
||||
const { showCreateServer, closeCreateServer } = useUiStore();
|
||||
const { mutate: createInstance, isPending } = useCreateInstance();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
createInstance(
|
||||
{
|
||||
name: name.trim(),
|
||||
icon: icon.trim() || undefined,
|
||||
// description: description.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create server:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateServer} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new server to chat with friends and communities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-name">Server Name</Label>
|
||||
<Input
|
||||
id="server-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter server name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="server-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this server about?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-icon">Server Icon URL (Optional)</Label>
|
||||
<Input
|
||||
id="server-icon"
|
||||
type="url"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="https://example.com/icon.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Server"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Channel Modal
|
||||
export const CreateChannelModal: React.FC = () => {
|
||||
const { showCreateChannel, closeCreateChannel } = useUiStore();
|
||||
const { mutate: createChannel, isPending } = useCreateChannel();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
|
||||
// You'd need to get categories for the current instance
|
||||
// This is a simplified version
|
||||
const categories: Category[] = []; // Get from context or props
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && categoryId) {
|
||||
createChannel(
|
||||
{
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
categoryId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create channel:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateChannel} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new text or voice channel in this server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-type">Channel Type</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value: "text" | "voice") => setType(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channel type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text Channel</SelectItem>
|
||||
<SelectItem value="voice">Voice Channel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-category">Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter channel name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="channel-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this channel for?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Category Modal
|
||||
export const CreateCategoryModal: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutate: createCategory, isPending } = useCreateCategory();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [instanceId, setInstanceId] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && instanceId) {
|
||||
createCategory(
|
||||
{
|
||||
name: name.trim(),
|
||||
instanceId,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create category:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new category to organize your channels.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category-name">Category Name</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter category name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Category"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminModals: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<CreateServerModal />
|
||||
<CreateChannelModal />
|
||||
<CreateCategoryModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ServerIcon;
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
Palette,
|
||||
Plus,
|
||||
Trash2,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
useTheme,
|
||||
ThemeDefinition,
|
||||
ThemeColors,
|
||||
} from "@/components/theme-provider";
|
||||
|
||||
// Theme color input component for OKLCH values
|
||||
const ColorInput: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ label, value, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor={label} className="text-right text-sm">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="col-span-3 font-mono text-sm"
|
||||
placeholder="oklch(0.5 0.1 180)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom theme creation modal
|
||||
const CreateThemeModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (theme: Omit<ThemeDefinition, "id" | "isCustom">) => void;
|
||||
}> = ({ isOpen, onClose, onSave }) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [mode, setMode] = useState<"light" | "dark">("dark");
|
||||
const [colors, setColors] = useState<ThemeColors>({
|
||||
background: "oklch(0.145 0 0)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.205 0 0)",
|
||||
cardForeground: "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.205 0 0)",
|
||||
popoverForeground: "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.922 0 0)",
|
||||
primaryForeground: "oklch(0.205 0 0)",
|
||||
secondary: "oklch(0.269 0 0)",
|
||||
secondaryForeground: "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.269 0 0)",
|
||||
mutedForeground: "oklch(0.708 0 0)",
|
||||
accent: "oklch(0.269 0 0)",
|
||||
accentForeground: "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.556 0 0)",
|
||||
chart1: "oklch(0.488 0.243 264.376)",
|
||||
chart2: "oklch(0.696 0.17 162.48)",
|
||||
chart3: "oklch(0.769 0.188 70.08)",
|
||||
chart4: "oklch(0.627 0.265 303.9)",
|
||||
chart5: "oklch(0.645 0.246 16.439)",
|
||||
sidebar: "oklch(0.205 0 0)",
|
||||
sidebarForeground: "oklch(0.985 0 0)",
|
||||
sidebarPrimary: "oklch(0.488 0.243 264.376)",
|
||||
sidebarPrimaryForeground: "oklch(0.985 0 0)",
|
||||
sidebarAccent: "oklch(0.269 0 0)",
|
||||
sidebarAccentForeground: "oklch(0.985 0 0)",
|
||||
sidebarBorder: "oklch(1 0 0 / 10%)",
|
||||
sidebarRing: "oklch(0.556 0 0)",
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
mode,
|
||||
colors,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setName("");
|
||||
setDescription("");
|
||||
setMode("dark");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const updateColor = (key: keyof ThemeColors, value: string) => {
|
||||
setColors((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Custom Theme</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a custom theme by defining colors in OKLCH format (e.g.,
|
||||
"oklch(0.5 0.1 180)")
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-96">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="theme-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="My Custom Theme"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-description" className="text-right">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="theme-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-mode" className="text-right">
|
||||
Mode
|
||||
</Label>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(v: "light" | "dark") => setMode(v)}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Color sections */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Basic Colors</h4>
|
||||
<div className="space-y-3">
|
||||
<ColorInput
|
||||
label="Background"
|
||||
value={colors.background}
|
||||
onChange={(v) => updateColor("background", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Foreground"
|
||||
value={colors.foreground}
|
||||
onChange={(v) => updateColor("foreground", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Primary"
|
||||
value={colors.primary}
|
||||
onChange={(v) => updateColor("primary", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Secondary"
|
||||
value={colors.secondary}
|
||||
onChange={(v) => updateColor("secondary", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<h4 className="font-medium">Sidebar Colors</h4>
|
||||
<div className="space-y-3">
|
||||
<ColorInput
|
||||
label="Sidebar"
|
||||
value={colors.sidebar}
|
||||
onChange={(v) => updateColor("sidebar", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Sidebar Primary"
|
||||
value={colors.sidebarPrimary}
|
||||
onChange={(v) => updateColor("sidebarPrimary", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Sidebar Accent"
|
||||
value={colors.sidebarAccent}
|
||||
onChange={(v) => updateColor("sidebarAccent", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||
Create Theme
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Main theme selector component as modal
|
||||
export function ThemeSelector() {
|
||||
const {
|
||||
mode,
|
||||
currentTheme,
|
||||
setMode,
|
||||
setTheme,
|
||||
addCustomTheme,
|
||||
removeCustomTheme,
|
||||
getThemesForMode,
|
||||
} = useTheme();
|
||||
|
||||
const [isMainModalOpen, setIsMainModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const lightThemes = getThemesForMode("light");
|
||||
const darkThemes = getThemesForMode("dark");
|
||||
|
||||
const getCurrentModeIcon = () => {
|
||||
switch (mode) {
|
||||
case "light":
|
||||
return <Sun className="h-4 w-4" />;
|
||||
case "dark":
|
||||
return <Moon className="h-4 w-4" />;
|
||||
default:
|
||||
return <Monitor className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isMainModalOpen} onOpenChange={setIsMainModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Theme
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{getCurrentModeIcon()}
|
||||
Appearance Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose your preferred theme and color scheme
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Current Theme Display */}
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{currentTheme.name}</p>
|
||||
{currentTheme.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentTheme.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
</div>
|
||||
|
||||
{/* Mode Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Display Mode</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={mode === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMode("light")}
|
||||
className="flex flex-col gap-1 h-auto py-3"
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="text-xs">Light</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMode("dark")}
|
||||
className="flex flex-col gap-1 h-auto py-3"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
<span className="text-xs">Dark</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "system" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMode("system")}
|
||||
className="flex flex-col gap-1 h-auto py-3"
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span className="text-xs">System</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Selection */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Themes</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="space-y-3">
|
||||
{/* Light Themes */}
|
||||
{lightThemes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Light Themes
|
||||
</p>
|
||||
{lightThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => setTheme(theme.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{theme.name}
|
||||
</p>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{theme.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTheme.id === theme.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{theme.isCustom && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomTheme(theme.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dark Themes */}
|
||||
{darkThemes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{lightThemes.length > 0 && <Separator />}
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Dark Themes
|
||||
</p>
|
||||
{darkThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => setTheme(theme.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{theme.name}
|
||||
</p>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{theme.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTheme.id === theme.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{theme.isCustom && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomTheme(theme.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsMainModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreateThemeModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSave={addCustomTheme}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@@ -15,7 +15,7 @@ function DropdownMenuPortal({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@@ -26,7 +26,7 @@ function DropdownMenuTrigger({
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@@ -41,12 +41,12 @@ function DropdownMenuContent({
|
||||
sideOffset={sideOffset}
|
||||
className={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-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
@@ -54,7 +54,7 @@ function DropdownMenuGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
@@ -63,8 +63,8 @@ function DropdownMenuItem({
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -73,11 +73,11 @@ function DropdownMenuItem({
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -146,7 +146,7 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
@@ -154,11 +154,11 @@ function DropdownMenuLabel({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@@ -171,7 +171,7 @@ function DropdownMenuSeparator({
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
@@ -183,17 +183,17 @@ function DropdownMenuShortcut({
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -202,22 +202,22 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -229,11 +229,11 @@ function DropdownMenuSubContent({
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={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-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -252,4 +252,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,12 +156,6 @@ export const useEditMessage = () => {
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call when available
|
||||
console.log(
|
||||
"Editing message:",
|
||||
data.messageId,
|
||||
"New content:",
|
||||
data.content,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -207,10 +201,6 @@ export const usePinMessage = () => {
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call when available
|
||||
console.log(
|
||||
`${data.pinned ? "Pinning" : "Unpinning"} message:`,
|
||||
data.messageId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
108
concord-client/src/hooks/usePermissions.ts
Normal file
108
concord-client/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { Role } from "@/types/database";
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
type PermissionsRole = "admin" | "member" | "mod";
|
||||
|
||||
const getUserRoleForInstance = (
|
||||
roles: Role[],
|
||||
instanceId: string,
|
||||
): PermissionsRole => {
|
||||
if (!instanceId) return "member";
|
||||
const roleEntry = roles.find((r) => r.instanceId === instanceId);
|
||||
return roleEntry?.role || "member";
|
||||
};
|
||||
|
||||
const getRoleInfo = (role: PermissionsRole) => {
|
||||
const lowerRole = role.toLowerCase();
|
||||
switch (lowerRole) {
|
||||
case "admin":
|
||||
return { color: "#ff6b6b", priority: 3, name: "Admin" };
|
||||
case "mod":
|
||||
return { color: "#4ecdc4", priority: 2, name: "Moderator" };
|
||||
case "member":
|
||||
return { color: null, priority: 1, name: "Member" };
|
||||
default:
|
||||
return {
|
||||
color: null,
|
||||
priority: 0,
|
||||
name: role.charAt(0).toUpperCase() + role.slice(1),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
interface InstancePermissions {
|
||||
currentUserRole: PermissionsRole;
|
||||
currentUserRolePriority: number;
|
||||
canManageMembers: boolean; // Can kick/ban/promote/demote members
|
||||
canViewAdminPanel: boolean;
|
||||
}
|
||||
|
||||
export const useInstancePermissions = (): InstancePermissions => {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const { user: currentUser } = useAuthStore();
|
||||
|
||||
const permissions = useMemo(() => {
|
||||
let currentUserRole: PermissionsRole = "member";
|
||||
let currentUserRolePriority = 1;
|
||||
let canManageMembers = false;
|
||||
let canViewAdminPanel = false;
|
||||
|
||||
if (!currentUser || !instanceId) {
|
||||
// If no user or instance, user has no permissions within an instance
|
||||
return {
|
||||
currentUserRole,
|
||||
currentUserRolePriority,
|
||||
canManageMembers,
|
||||
canViewAdminPanel,
|
||||
};
|
||||
}
|
||||
|
||||
// If they are a global admin
|
||||
if (currentUser.admin) {
|
||||
currentUserRole = "admin";
|
||||
currentUserRolePriority = 3;
|
||||
canManageMembers = true;
|
||||
canViewAdminPanel = true;
|
||||
return {
|
||||
currentUserRole: "admin",
|
||||
currentUserRolePriority: 3,
|
||||
canManageMembers: true,
|
||||
canManageRoles: true,
|
||||
canViewAdminPanel: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Instance-Specific Role Check
|
||||
const instanceRole = getUserRoleForInstance(currentUser.roles, instanceId);
|
||||
const roleInfo = getRoleInfo(instanceRole as PermissionsRole);
|
||||
|
||||
currentUserRole = instanceRole;
|
||||
currentUserRolePriority = roleInfo.priority;
|
||||
|
||||
// Define permissions based on role priority
|
||||
if (roleInfo.priority >= 3) {
|
||||
// Admin
|
||||
canManageMembers = true;
|
||||
canViewAdminPanel = true;
|
||||
} else if (roleInfo.priority === 2) {
|
||||
// Moderator
|
||||
canManageMembers = true;
|
||||
canViewAdminPanel = false;
|
||||
} else {
|
||||
// Member (priority 1 or 0)
|
||||
canManageMembers = false;
|
||||
canViewAdminPanel = false;
|
||||
}
|
||||
|
||||
return {
|
||||
currentUserRole,
|
||||
currentUserRolePriority,
|
||||
canManageMembers,
|
||||
canViewAdminPanel,
|
||||
};
|
||||
}, [currentUser, instanceId]);
|
||||
|
||||
return permissions as InstancePermissions;
|
||||
};
|
||||
@@ -256,6 +256,8 @@ export class ApiClient {
|
||||
requestingUserId: string;
|
||||
requestingUserToken: string;
|
||||
}): Promise<Channel> {
|
||||
console.log(data);
|
||||
|
||||
return this.request<Channel>("/api/channel", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -4,30 +4,10 @@ import App from "./App.tsx";
|
||||
import { io } from "socket.io-client";
|
||||
import "./index.css";
|
||||
|
||||
function printPayload(data: unknown) {
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
const socket = io("http://localhost:3000");
|
||||
socket.on("connect", () => {
|
||||
console.log("connected!");
|
||||
socket.emit("ping", "world");
|
||||
});
|
||||
socket.on("pong", () => {
|
||||
console.log("pong");
|
||||
});
|
||||
|
||||
socket.on("joined-voicechannel", printPayload);
|
||||
socket.on("user-joined-voicechannel", printPayload);
|
||||
socket.on("error-voicechannel", printPayload);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App socket={socket} />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
// Use contextBridge
|
||||
window.ipcRenderer.on("main-process-message", (_event, message) => {
|
||||
console.log(message);
|
||||
});
|
||||
|
||||
@@ -1,419 +1,21 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import {
|
||||
Hash,
|
||||
Volume2,
|
||||
Users,
|
||||
Pin,
|
||||
MoreHorizontal,
|
||||
Reply,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow, isValid, parseISO } from "date-fns";
|
||||
import { Hash, Volume2, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||
import {
|
||||
dark,
|
||||
solarizedLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
// Updated imports for API integration
|
||||
import { MessageComponent } from "@/components/message/MessageComponent";
|
||||
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
|
||||
import {
|
||||
useChannelMessages,
|
||||
useLoadMoreMessages,
|
||||
useSendMessage,
|
||||
} from "@/hooks/useMessages";
|
||||
import { useChannelMessages, useLoadMoreMessages } from "@/hooks/useMessages";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { Message } from "@/lib/api-client";
|
||||
|
||||
// Modal imports
|
||||
import { MessageActionsModal } from "@/components/modals/MessageActionsModal";
|
||||
|
||||
// User type for message component
|
||||
interface MessageUser {
|
||||
id: string;
|
||||
username?: string;
|
||||
userName?: string;
|
||||
nickname?: string | null;
|
||||
nickName?: string | null;
|
||||
picture?: string | null;
|
||||
}
|
||||
|
||||
// Message Props interface
|
||||
interface MessageProps {
|
||||
message: Message;
|
||||
user: MessageUser;
|
||||
currentUser: any;
|
||||
replyTo?: Message;
|
||||
replyToUser?: MessageUser;
|
||||
onReply?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
const MessageComponent: React.FC<MessageProps> = ({
|
||||
message,
|
||||
user,
|
||||
currentUser,
|
||||
replyTo,
|
||||
replyToUser,
|
||||
onReply,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [showActionsModal, setShowActionsModal] = useState(false);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
try {
|
||||
// First try parsing as ISO string
|
||||
let date = parseISO(timestamp);
|
||||
|
||||
// If that fails, try regular Date constructor
|
||||
if (!isValid(date)) {
|
||||
date = new Date(timestamp);
|
||||
}
|
||||
|
||||
// Final check if date is valid
|
||||
if (!isValid(date)) {
|
||||
console.error("Invalid timestamp:", timestamp);
|
||||
return "Invalid date";
|
||||
}
|
||||
|
||||
return formatDistanceToNow(date, { addSuffix: true });
|
||||
} catch (error) {
|
||||
console.error("Error formatting timestamp:", timestamp, error);
|
||||
return "Invalid date";
|
||||
}
|
||||
};
|
||||
const isOwnMessage = currentUser?.id === message.userId;
|
||||
const { mode } = useTheme();
|
||||
|
||||
// Get username with fallback
|
||||
const username = user.username || user.userName || "Unknown User";
|
||||
const displayName = user.nickname || user.nickName || username;
|
||||
|
||||
const isDeleted = message.deleted;
|
||||
|
||||
if (isDeleted) {
|
||||
return (
|
||||
<div className="px-4 py-2 opacity-50">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-concord-secondary italic border border-border rounded px-3 py-2 bg-concord-tertiary/50">
|
||||
This message has been deleted
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group relative px-4 py-2 hover:bg-concord-secondary/50 transition-colors"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Avatar - always show */}
|
||||
<div className="w-10 flex-shrink-0">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user.picture || undefined} alt={username} />
|
||||
<AvatarFallback className="text-sm bg-primary text-primary-foreground">
|
||||
{username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Reply line and reference */}
|
||||
{replyTo && replyToUser && (
|
||||
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
|
||||
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
|
||||
<span className="font-medium text-concord-primary">
|
||||
{replyToUser.nickname ||
|
||||
replyToUser.nickName ||
|
||||
replyToUser.username ||
|
||||
replyToUser.userName}
|
||||
</span>
|
||||
<span className="truncate max-w-xs opacity-75">
|
||||
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply line and reference */}
|
||||
{replyTo && replyToUser && (
|
||||
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
|
||||
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
|
||||
<span className="font-medium text-concord-primary">
|
||||
{replyToUser.nickname ||
|
||||
replyToUser.nickName ||
|
||||
replyToUser.username ||
|
||||
replyToUser.userName}
|
||||
</span>
|
||||
<span className="truncate max-w-xs opacity-75">
|
||||
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header - always show */}
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-semibold text-concord-primary">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-xs text-concord-secondary">
|
||||
{formatTimestamp(message.createdAt)}
|
||||
</span>
|
||||
{message.edited && (
|
||||
<span className="text-xs text-concord-secondary opacity-60">
|
||||
(edited)
|
||||
</span>
|
||||
)}
|
||||
{(message as any).pinned && (
|
||||
<Pin className="h-3 w-3 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message content with markdown */}
|
||||
<div className="text-concord-primary leading-relaxed prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code: ({ className, children }) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return match ? (
|
||||
<div className="flex flex-row flex-1 max-w-2/3 flex-wrap !bg-transparent">
|
||||
<SyntaxHighlighter
|
||||
PreTag="div"
|
||||
children={String(children).replace(/\n$/, "")}
|
||||
language={match[1]}
|
||||
style={mode === "light" ? solarizedLight : dark}
|
||||
className="!bg-concord-secondary p-2 border-2 concord-border rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<code className={className}>{children}</code>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary pl-4 my-2 italic text-concord-secondary bg-concord-secondary/30 py-2 rounded-r">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="my-1 text-concord-primary">{children}</p>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-concord-primary">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-concord-primary">{children}</em>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside my-2 text-concord-primary">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside my-2 text-concord-primary">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-xl font-bold my-2 text-concord-primary">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-lg font-bold my-2 text-concord-primary">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-bold my-2 text-concord-primary">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message actions */}
|
||||
{isHovered && (
|
||||
<div className="absolute top-0 right-4 bg-concord-secondary border border-border rounded-md shadow-md flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 interactive-hover"
|
||||
onClick={() => onReply?.(message.id)}
|
||||
>
|
||||
<Reply className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 interactive-hover"
|
||||
onClick={() => setShowActionsModal(true)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Actions Modal */}
|
||||
<MessageActionsModal
|
||||
isOpen={showActionsModal}
|
||||
onClose={() => setShowActionsModal(false)}
|
||||
message={message}
|
||||
isOwnMessage={isOwnMessage}
|
||||
onReply={onReply}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Message Input Component
|
||||
interface MessageInputProps {
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
replyingTo?: Message | null;
|
||||
onCancelReply?: () => void;
|
||||
replyingToUser: MessageUser | null;
|
||||
}
|
||||
|
||||
const MessageInput: React.FC<MessageInputProps> = ({
|
||||
channelId,
|
||||
channelName,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
replyingToUser,
|
||||
}) => {
|
||||
const [content, setContent] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
// Use the API hook for sending messages
|
||||
const sendMessageMutation = useSendMessage();
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim() && !sendMessageMutation.isPending) {
|
||||
try {
|
||||
await sendMessageMutation.mutateAsync({
|
||||
channelId,
|
||||
content: content.trim(),
|
||||
repliedMessageId: replyingTo?.id || null,
|
||||
});
|
||||
setContent("");
|
||||
onCancelReply?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
{replyingTo && replyingToUser && (
|
||||
<div className="mb-2 p-3 bg-concord-secondary rounded-lg border border-b-0 border-border">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
|
||||
<span className="font-medium text-concord-primary">
|
||||
{replyingToUser.nickname ||
|
||||
replyingToUser.nickName ||
|
||||
replyingToUser.username ||
|
||||
replyingToUser.userName}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-1 text-concord-secondary hover:text-concord-primary"
|
||||
onClick={onCancelReply}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-concord-primary truncate pl-2">
|
||||
{replyingTo.text.replace(/```[\s\S]*?```/g, "[code]")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Message #${channelName || "channel"}`}
|
||||
disabled={sendMessageMutation.isPending}
|
||||
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
|
||||
style={{
|
||||
minHeight: "44px",
|
||||
maxHeight: "200px",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
|
||||
{sendMessageMutation.isPending
|
||||
? "Sending..."
|
||||
: "Press Enter to send • Shift+Enter for new line"}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { MessageInput } from "@/components/message/MessageInput";
|
||||
|
||||
const ChatPage: React.FC = () => {
|
||||
const { instanceId, channelId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL LOGIC
|
||||
// API hooks - called unconditionally
|
||||
const {
|
||||
data: instance,
|
||||
isLoading: instanceLoading,
|
||||
@@ -437,6 +39,7 @@ const ChatPage: React.FC = () => {
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesStartRef = useRef<HTMLDivElement>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null); // Ref for the ScrollArea content wrapper
|
||||
|
||||
// API mutation hooks - called unconditionally
|
||||
const loadMoreMessagesMutation = useLoadMoreMessages(channelId);
|
||||
@@ -469,9 +72,48 @@ const ChatPage: React.FC = () => {
|
||||
|
||||
// Effects - called unconditionally
|
||||
useEffect(() => {
|
||||
// Scroll to bottom when messages load or update
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [channelMessages]);
|
||||
|
||||
// Auto-focus on channel change or initial load (using DOM query)
|
||||
useEffect(() => {
|
||||
if (!currentUser) return; // Skip if input isn't rendered
|
||||
|
||||
let retryCount = 0;
|
||||
const maxRetries = 10;
|
||||
const retryInterval = 100; // ms
|
||||
|
||||
const focusInput = () => {
|
||||
retryCount++;
|
||||
const textarea = document.getElementById(
|
||||
"message-input-textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
} else if (retryCount < maxRetries) {
|
||||
setTimeout(focusInput, retryInterval);
|
||||
}
|
||||
};
|
||||
|
||||
focusInput();
|
||||
}, [channelId, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replyingTo) return; // Skip if no reply
|
||||
|
||||
const focusInput = () => {
|
||||
const textarea = document.getElementById(
|
||||
"message-input-textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
};
|
||||
|
||||
focusInput();
|
||||
}, [replyingTo]);
|
||||
|
||||
// Event handlers
|
||||
const handleLoadMore = React.useCallback(async () => {
|
||||
if (!channelMessages || channelMessages.length === 0 || isLoadingMore)
|
||||
@@ -500,6 +142,33 @@ const ChatPage: React.FC = () => {
|
||||
[channelMessages],
|
||||
);
|
||||
|
||||
// Effect for scroll to top and load more
|
||||
useEffect(() => {
|
||||
const scrollAreaElement = scrollAreaRef.current;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
scrollAreaElement &&
|
||||
scrollAreaElement.scrollTop === 0 &&
|
||||
channelMessages &&
|
||||
channelMessages.length > 0 &&
|
||||
!isLoadingMore
|
||||
) {
|
||||
handleLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
if (scrollAreaElement) {
|
||||
scrollAreaElement.addEventListener("scroll", handleScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (scrollAreaElement) {
|
||||
scrollAreaElement.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
};
|
||||
}, [channelMessages, isLoadingMore, handleLoadMore]);
|
||||
|
||||
// Handle loading states
|
||||
if (instanceLoading || messagesLoading || usersLoading) {
|
||||
return (
|
||||
@@ -558,9 +227,8 @@ const ChatPage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
} else if (!channelId || !currentChannel) {
|
||||
const existingChannelId = categories
|
||||
?.flatMap((cat) => cat.channels)
|
||||
?.find((channel) => channel.position === 0)?.id;
|
||||
const existingChannelId = categories?.flatMap((cat) => cat.channels)?.[0]
|
||||
?.id; // Get the first channel from the flattened list
|
||||
|
||||
if (existingChannelId) {
|
||||
navigate(`/channels/${instanceId}/${existingChannelId}`);
|
||||
@@ -584,10 +252,10 @@ const ChatPage: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
|
||||
{/* Channel Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-concord bg-concord-secondary">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-concord bg-concord-secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ChannelIcon size={20} className="text-concord-secondary" />
|
||||
<span className="font-semibold text-concord-primary">
|
||||
<span className="font-semibold text-xl text-concord-primary">
|
||||
{currentChannel?.name}
|
||||
</span>
|
||||
{currentChannel?.description && (
|
||||
@@ -619,88 +287,64 @@ const ChatPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Chat Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
{/* Messages Area */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{/* Load More Button */}
|
||||
{channelMessages && channelMessages.length > 0 && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary mr-2"></div>
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isLoadingMore ? "Loading..." : "Load older messages"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Attach ref to the actual scrollable content */}
|
||||
<div ref={scrollAreaRef} className="h-full overflow-y-auto">
|
||||
<div ref={messagesStartRef} />
|
||||
|
||||
<div ref={messagesStartRef} />
|
||||
|
||||
{/* Welcome Message */}
|
||||
<div className="px-4 py-6 border-b border-concord/50 flex-shrink-0">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center">
|
||||
<ChannelIcon size={24} className="text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-concord-primary">
|
||||
Welcome to #{currentChannel?.name}!
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
{currentChannel?.description && (
|
||||
<div className="text-concord-secondary bg-concord-secondary/50 p-3 rounded border-l-4 border-primary">
|
||||
{currentChannel.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pb-4">
|
||||
{/* Messages */}
|
||||
{sortedMessages && sortedMessages.length > 0 ? (
|
||||
<div>
|
||||
{sortedMessages.map((message) => {
|
||||
console.log(message);
|
||||
const user = users?.find((u) => u.id === message.userId);
|
||||
const replyToMessage = channelMessages?.find(
|
||||
(m) => m.id === (message as any).repliedMessageId,
|
||||
);
|
||||
const replyToUser = replyToMessage
|
||||
? users?.find((u) => u.id === replyToMessage.userId)
|
||||
: undefined;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<MessageComponent
|
||||
key={message.id}
|
||||
message={message}
|
||||
user={user}
|
||||
currentUser={currentUser}
|
||||
replyTo={replyToMessage}
|
||||
onReply={handleReply}
|
||||
replyToUser={replyToUser}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center text-concord-secondary">
|
||||
<p>No messages yet. Start the conversation!</p>
|
||||
{/* Welcome Message */}
|
||||
<div className="px-4 py-6 border-b border-concord/50 flex-shrink-0">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center">
|
||||
<ChannelIcon size={24} className="text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-concord-primary">
|
||||
Welcome to #{currentChannel?.name}!
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
<div className="pb-4">
|
||||
{/* Messages */}
|
||||
{sortedMessages && sortedMessages.length > 0 ? (
|
||||
<div>
|
||||
{sortedMessages.map((message) => {
|
||||
const user = users?.find((u) => u.id === message.userId);
|
||||
const replyToMessage = channelMessages?.find(
|
||||
(m) => m.id === message.replies?.repliesToId,
|
||||
);
|
||||
const replyToUser = replyToMessage
|
||||
? users?.find((u) => u.id === replyToMessage.userId)
|
||||
: undefined;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<MessageComponent
|
||||
key={message.id}
|
||||
message={message}
|
||||
user={user}
|
||||
replyTo={replyToMessage}
|
||||
onReply={handleReply}
|
||||
replyToUser={replyToUser}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center text-concord-secondary">
|
||||
<p>No messages yet. Start the conversation!</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Palette,
|
||||
User,
|
||||
Shield,
|
||||
Mic,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
@@ -26,7 +23,6 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ThemeSelector } from "@/components/theme-selector";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
@@ -45,12 +41,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
icon: User,
|
||||
description: "Profile, privacy, and account settings",
|
||||
},
|
||||
// {
|
||||
// id: "security",
|
||||
// title: "Security",
|
||||
// icon: Lock,
|
||||
// description: "Password and security settings",
|
||||
// },
|
||||
{
|
||||
id: "appearance",
|
||||
title: "Appearance",
|
||||
@@ -65,207 +55,8 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const SecuritySettings: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
const [passwordSuccess, setPasswordSuccess] = useState("");
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordError("");
|
||||
setPasswordSuccess("");
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setPasswordError("All password fields are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError("New passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordError("New password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingPassword(true);
|
||||
|
||||
try {
|
||||
// TODO: Implement actual password change API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
|
||||
|
||||
console.log("Changing password for user:", user?.id);
|
||||
// const result = await authClient.changePassword({
|
||||
// userId: user.id,
|
||||
// currentPassword,
|
||||
// newPassword,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
setPasswordSuccess("Password changed successfully");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (error) {
|
||||
setPasswordError(
|
||||
"Failed to change password. Please check your current password.",
|
||||
);
|
||||
} finally {
|
||||
setIsChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5" />
|
||||
Change Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{passwordError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{passwordError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passwordSuccess && (
|
||||
<Alert>
|
||||
<AlertDescription>{passwordSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="max-w-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="max-w-sm"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="max-w-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isChangingPassword ||
|
||||
!currentPassword ||
|
||||
!newPassword ||
|
||||
!confirmPassword
|
||||
}
|
||||
>
|
||||
{isChangingPassword
|
||||
? "Changing Password..."
|
||||
: "Change Password"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Security Options
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Additional security features for your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Two-Factor Authentication
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add an extra layer of security to your account
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={twoFactorEnabled}
|
||||
onCheckedChange={setTwoFactorEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-medium">Active Sessions</Label>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Manage devices that are currently logged into your account
|
||||
</p>
|
||||
<Button variant="outline" size="sm">
|
||||
View Active Sessions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-medium">Account Backup</Label>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Download a copy of your account data
|
||||
</p>
|
||||
<Button variant="outline" size="sm">
|
||||
Request Data Export
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountSettings: React.FC = () => {
|
||||
const { user, updateUser } = useAuthStore();
|
||||
const { user } = useAuthStore();
|
||||
const [username, setUsername] = useState(user?.username || "");
|
||||
const [nickname, setNickname] = useState(user?.nickname || "");
|
||||
const [bio, setBio] = useState(user?.bio || "");
|
||||
@@ -281,24 +72,14 @@ const AccountSettings: React.FC = () => {
|
||||
|
||||
try {
|
||||
// TODO: Implement actual profile update API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
|
||||
|
||||
console.log("Updating profile:", { username, nickname, bio });
|
||||
// const updatedUser = await userClient.updateProfile({
|
||||
// userId: user.id,
|
||||
// Update local state
|
||||
// updateUser({
|
||||
// username: username.trim(),
|
||||
// nickname: nickname.trim() || null,
|
||||
// bio: bio.trim() || null,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
// Update local state
|
||||
updateUser({
|
||||
username: username.trim(),
|
||||
nickname: nickname.trim() || null,
|
||||
bio: bio.trim() || null,
|
||||
});
|
||||
|
||||
setSaveSuccess("Profile updated successfully");
|
||||
setIsChanged(false);
|
||||
} catch (error) {
|
||||
@@ -315,7 +96,7 @@ const AccountSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
|
||||
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-full">
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -404,50 +185,10 @@ const AccountSettings: React.FC = () => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Privacy
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control who can contact you and see your information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Allow Direct Messages
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Let other users send you direct messages
|
||||
</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Show Online Status
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display when you're online to other users
|
||||
</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Appearance Settings Component (keeping existing implementation)
|
||||
const AppearanceSettings: React.FC = () => {
|
||||
const {
|
||||
currentLightTheme,
|
||||
@@ -559,18 +300,6 @@ const AppearanceSettings: React.FC = () => {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Quick Theme Selector
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access the theme selector with custom theme creation
|
||||
</p>
|
||||
</div>
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
|
||||
{/* Current Theme Display */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
@@ -727,14 +456,9 @@ const AppearanceSettings: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Voice Settings Component
|
||||
const VoiceSettings: React.FC = () => {
|
||||
const [inputVolume, setInputVolume] = useState(75);
|
||||
const [outputVolume, setOutputVolume] = useState(100);
|
||||
const [pushToTalk, setPushToTalk] = useState(false);
|
||||
const [noiseSuppression, setNoiseSuppression] = useState(true);
|
||||
const [echoCancellation, setEchoCancellation] = useState(true);
|
||||
const [autoGainControl, setAutoGainControl] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-full">
|
||||
@@ -774,69 +498,6 @@ const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Push to Talk</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use a key to transmit voice instead of voice activity
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={pushToTalk} onCheckedChange={setPushToTalk} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Audio Processing</CardTitle>
|
||||
<CardDescription>
|
||||
Advanced audio processing features to improve call quality.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Noise Suppression</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reduce background noise during calls
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={noiseSuppression}
|
||||
onCheckedChange={setNoiseSuppression}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Echo Cancellation</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Prevent audio feedback and echo
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={echoCancellation}
|
||||
onCheckedChange={setEchoCancellation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Auto Gain Control</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically adjust microphone sensitivity
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoGainControl}
|
||||
onCheckedChange={setAutoGainControl}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -846,13 +507,12 @@ const VoiceSettings: React.FC = () => {
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { section } = useParams();
|
||||
const currentSection = section || "account";
|
||||
const navigate = useNavigate();
|
||||
|
||||
const renderSettingsContent = () => {
|
||||
switch (currentSection) {
|
||||
case "account":
|
||||
return <AccountSettings />;
|
||||
case "security":
|
||||
return <SecuritySettings />;
|
||||
case "appearance":
|
||||
return <AppearanceSettings />;
|
||||
case "voice":
|
||||
@@ -882,10 +542,11 @@ const SettingsPage: React.FC = () => {
|
||||
<Button
|
||||
key={settingsSection.id}
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
onClick={() => navigate(`/settings/${settingsSection.id}`)}
|
||||
className="w-full justify-start mb-1 h-auto p-2"
|
||||
asChild
|
||||
>
|
||||
<a href={`/settings/${settingsSection.id}`}>
|
||||
<div>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{settingsSection.title}</div>
|
||||
@@ -896,7 +557,7 @@ const SettingsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
@@ -924,7 +585,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="min-h-0 w-full">
|
||||
<ScrollArea className="min-h-0 w-full bg-concord-primary h-full">
|
||||
<div className="p-6 flex w-full">{renderSettingsContent()}</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
* @returns The configured RTCPeerConnection instance.
|
||||
*/
|
||||
const createPeerConnection = (targetUserId: string): RTCPeerConnection => {
|
||||
console.log(`Creating peer connection for: ${targetUserId}`);
|
||||
const { iceServers, localStream, socket, peerConnections } = get();
|
||||
|
||||
const peerConnection = new RTCPeerConnection({ iceServers });
|
||||
@@ -95,7 +94,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
|
||||
// Handle incoming remote tracks
|
||||
peerConnection.ontrack = (event) => {
|
||||
console.log(`Received remote track from: ${targetUserId}`);
|
||||
set((state) => {
|
||||
const newStreams = new Map(state.remoteStreams);
|
||||
newStreams.set(targetUserId, event.streams[0]);
|
||||
@@ -105,9 +103,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
|
||||
// For debugging connection state
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log(
|
||||
`Connection state change for ${targetUserId}: ${peerConnection.connectionState}`,
|
||||
);
|
||||
if (
|
||||
peerConnection.connectionState === "disconnected" ||
|
||||
peerConnection.connectionState === "failed"
|
||||
@@ -128,10 +123,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
connectedUserIds: string[];
|
||||
iceServers: IceServerConfig[];
|
||||
}) => {
|
||||
console.log(
|
||||
"Successfully joined voice channel. Users:",
|
||||
data.connectedUserIds,
|
||||
);
|
||||
set({
|
||||
iceServers: data.iceServers,
|
||||
isConnecting: false,
|
||||
@@ -150,7 +141,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
};
|
||||
|
||||
const onUserLeft = (data: { userId: string }) => {
|
||||
console.log(`User ${data.userId} left the channel.`);
|
||||
cleanupPeerConnection(data.userId);
|
||||
};
|
||||
|
||||
@@ -158,7 +148,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
senderUserId: string;
|
||||
sdp: RTCSessionDescriptionInit;
|
||||
}) => {
|
||||
console.log("Received WebRTC offer from:", data.senderUserId);
|
||||
const peerConnection = createPeerConnection(data.senderUserId);
|
||||
await peerConnection.setRemoteDescription(
|
||||
new RTCSessionDescription(data.sdp),
|
||||
@@ -175,7 +164,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
senderUserId: string;
|
||||
sdp: RTCSessionDescriptionInit;
|
||||
}) => {
|
||||
console.log("Received WebRTC answer from:", data.senderUserId);
|
||||
const peerConnection = get().peerConnections.get(data.senderUserId);
|
||||
if (peerConnection) {
|
||||
await peerConnection.setRemoteDescription(
|
||||
@@ -271,7 +259,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
get();
|
||||
if (!socket || !activeVoiceChannelId) return;
|
||||
|
||||
console.log(`Leaving voice channel: ${activeVoiceChannelId}`);
|
||||
socket.emit("leave-voicechannel", { channelId: activeVoiceChannelId });
|
||||
|
||||
// Clean up all event listeners
|
||||
|
||||
@@ -5,33 +5,40 @@ import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
electron({
|
||||
main: {
|
||||
// Shortcut of `build.lib.entry`.
|
||||
entry: "electron/main.ts",
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Check if VITE_APP_MODE is set to 'web'
|
||||
const isWebApp = process.env.VITE_APP_MODE === "web";
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
// Only include the electron plugin if not in 'web' app mode
|
||||
!isWebApp &&
|
||||
electron({
|
||||
main: {
|
||||
// Shortcut of `build.lib.entry`.
|
||||
entry: "electron/main.ts",
|
||||
},
|
||||
preload: {
|
||||
// Shortcut of `build.rollupOptions.input`.
|
||||
// Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
|
||||
input: path.join(__dirname, "electron/preload.ts"),
|
||||
},
|
||||
// Ployfill the Electron and Node.js API for Renderer process.
|
||||
// If you want use Node.js in Renderer process, the `nodeIntegration` needs to be enabled in the Main process.
|
||||
// See 👉 https://github.com/electron-vite/vite-plugin-electron-renderer
|
||||
renderer:
|
||||
process.env.NODE_ENV === "test"
|
||||
? // https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808
|
||||
undefined
|
||||
: {},
|
||||
}),
|
||||
tailwindcss(),
|
||||
].filter(Boolean), // Filter out 'false' values if electron plugin is not included
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
preload: {
|
||||
// Shortcut of `build.rollupOptions.input`.
|
||||
// Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
|
||||
input: path.join(__dirname, "electron/preload.ts"),
|
||||
},
|
||||
// Ployfill the Electron and Node.js API for Renderer process.
|
||||
// If you want use Node.js in Renderer process, the `nodeIntegration` needs to be enabled in the Main process.
|
||||
// See 👉 https://github.com/electron-vite/vite-plugin-electron-renderer
|
||||
renderer:
|
||||
process.env.NODE_ENV === "test"
|
||||
? // https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808
|
||||
undefined
|
||||
: {},
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user