Compare commits
35 Commits
fa05830996
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
7325fddd45
|
|||
|
2f91713c11
|
|||
|
24a99900b1
|
|||
|
99ade46247
|
|||
|
2edf97bf1c
|
|||
|
0cbb496fab
|
|||
|
8ca50b327a
|
|||
| e071be8def | |||
|
8bb12f94e3
|
|||
|
|
f18d192e5b | ||
|
|
8fcc775c66 | ||
|
|
ac5f6ed680 | ||
|
|
e1a7257137 | ||
| bdc448c193 | |||
| a2fb529911 | |||
| 67d041e287 | |||
|
|
4cc6f4841c | ||
|
|
afd2454674 | ||
| 83e813a950 | |||
| a5e7fa4bc6 | |||
|
|
5a5afcec32 | ||
|
|
d55049dfc4 | ||
| af8371ed84 | |||
|
|
7c5d0a4bad | ||
|
|
226ee3f998 | ||
|
|
69b03ecc61 | ||
|
|
b3e24f4493 | ||
|
|
460991f2ae | ||
|
|
4adcf81004 | ||
| b79d3ac2cf | |||
|
|
3bffb87d90 | ||
|
|
de827ad441 | ||
|
|
6ef53fd964 | ||
|
|
36a04de084 | ||
|
|
db9a9a5d13 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -137,3 +137,4 @@ dist
|
|||||||
# Vite logs files
|
# Vite logs files
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
**/.helix
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@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": "^5.90.2",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -23,33 +23,37 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.9.3",
|
"react-router": "^7.9.3",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.14",
|
||||||
"zustand": "^5.0.8",
|
"zustand": "^5.0.8",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.64",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@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/react-syntax-highlighter": "^15.5.13",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
"@types/socket.io-client": "^3.0.0",
|
||||||
"@typescript-eslint/parser": "^7.1.1",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
"electron": "^30.0.1",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"electron": "^30.5.1",
|
||||||
"electron-builder": "^24.13.3",
|
"electron-builder": "^24.13.3",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.23",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.1.6",
|
"vite": "^5.4.20",
|
||||||
"vite-plugin-electron": "^0.28.6",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"vite-plugin-electron-renderer": "^0.14.5",
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -328,37 +332,39 @@
|
|||||||
|
|
||||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||||
|
|
||||||
|
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||||
|
|
||||||
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
|
"@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=="],
|
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
|
||||||
|
|
||||||
@@ -378,6 +384,10 @@
|
|||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
|
"@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.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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||||
@@ -404,7 +414,7 @@
|
|||||||
|
|
||||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
"@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=="],
|
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||||
|
|
||||||
@@ -412,6 +422,8 @@
|
|||||||
|
|
||||||
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
||||||
|
|
||||||
|
"@types/socket.io-client": ["@types/socket.io-client@3.0.0", "", { "dependencies": { "socket.io-client": "*" } }, "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg=="],
|
||||||
|
|
||||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
|
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
|
||||||
@@ -514,6 +526,8 @@
|
|||||||
|
|
||||||
"builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="],
|
"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.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=="],
|
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
|
||||||
|
|
||||||
"cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
|
"cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
|
||||||
@@ -648,6 +662,10 @@
|
|||||||
|
|
||||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
|
"engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="],
|
||||||
|
|
||||||
|
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||||
|
|
||||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
@@ -674,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-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=="],
|
"eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="],
|
||||||
|
|
||||||
@@ -1178,6 +1196,10 @@
|
|||||||
|
|
||||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||||
|
|
||||||
|
"socket.io-client": ["socket.io-client@4.8.1", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ=="],
|
||||||
|
|
||||||
|
"socket.io-parser": ["socket.io-parser@4.2.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" } }, "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew=="],
|
||||||
|
|
||||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
@@ -1216,7 +1238,7 @@
|
|||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
"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=="],
|
"tapable": ["tapable@2.2.3", "", {}, "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg=="],
|
||||||
|
|
||||||
@@ -1250,7 +1272,7 @@
|
|||||||
|
|
||||||
"type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
|
"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=="],
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
@@ -1304,8 +1326,12 @@
|
|||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
|
||||||
|
|
||||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||||
|
|
||||||
|
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
@@ -1360,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/@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/@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=="],
|
"@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=="],
|
"@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=="],
|
"app-builder-lib/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||||
@@ -1376,8 +1404,12 @@
|
|||||||
|
|
||||||
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
"filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||||
@@ -1418,6 +1450,10 @@
|
|||||||
|
|
||||||
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||||
|
|
||||||
|
"socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||||
|
|
||||||
|
"socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||||
|
|
||||||
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
"stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"build": "tsc && bunx --bun vite build && electron-builder",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "bunx --bun vite preview"
|
"preview": "bunx --bun vite preview"
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@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": "^5.90.2",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -29,33 +30,37 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.9.3",
|
"react-router": "^7.9.3",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.14",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.64",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@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/react-syntax-highlighter": "^15.5.13",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
"@types/socket.io-client": "^3.0.0",
|
||||||
"@typescript-eslint/parser": "^7.1.1",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
"electron": "^30.0.1",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"electron": "^30.5.1",
|
||||||
"electron-builder": "^24.13.3",
|
"electron-builder": "^24.13.3",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.23",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.1.6",
|
"vite": "^5.4.20",
|
||||||
"vite-plugin-electron": "^0.28.6",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"vite-plugin-electron-renderer": "^0.14.5"
|
"vite-plugin-electron-renderer": "^0.14.6"
|
||||||
},
|
},
|
||||||
"main": "dist-electron/main.js"
|
"main": "dist-electron/main.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { BrowserRouter as Router, Routes, Route } from "react-router";
|
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
@@ -10,34 +10,25 @@ import LoginPage from "@/pages/LoginPage";
|
|||||||
import ChatPage from "@/pages/ChatPage";
|
import ChatPage from "@/pages/ChatPage";
|
||||||
import SettingsPage from "@/pages/SettingsPage";
|
import SettingsPage from "@/pages/SettingsPage";
|
||||||
import NotFoundPage from "@/pages/NotFoundPage";
|
import NotFoundPage from "@/pages/NotFoundPage";
|
||||||
|
import { useVoiceStore } from "@/stores/voiceStore";
|
||||||
|
|
||||||
// import { useAuthStore } from "@/stores/authStore";
|
import { queryClient } from "@/lib/api-client";
|
||||||
// import { useUiStore } from "@/stores/uiStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import ErrorBoundary from "@/components/common/ErrorBoundary";
|
import ErrorBoundary from "@/components/common/ErrorBoundary";
|
||||||
import { Home } from "lucide-react";
|
import { Home } from "lucide-react";
|
||||||
|
import { Socket } from "socket.io-client";
|
||||||
// Create a client
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
retry: (failureCount, error: any) => {
|
|
||||||
if (error?.status === 401) return false;
|
|
||||||
return failureCount < 3;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Protected Route wrapper
|
// Protected Route wrapper
|
||||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
|
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
// const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
// if (!isAuthenticated) {
|
|
||||||
// return <Navigate to="/login" replace />;
|
// Enable this when you want to enforce authentication
|
||||||
// }
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,11 +52,20 @@ const HomePage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App(props: { socket: Socket }) {
|
||||||
|
const initVoiceStore = useVoiceStore((state) => state.init);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initVoiceStore(props.socket);
|
||||||
|
return () => {
|
||||||
|
useVoiceStore.getState().cleanup();
|
||||||
|
};
|
||||||
|
}, [props.socket, initVoiceStore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider defaultTheme="system" storageKey="discord-theme">
|
<ThemeProvider defaultTheme="system" storageKey="concord-theme">
|
||||||
<Router>
|
<Router>
|
||||||
<div className="h-screen w-screen overflow-hidden bg-background text-foreground">
|
<div className="h-screen w-screen overflow-hidden bg-background text-foreground">
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
103
concord-client/src/components/channel/ChannelItem.tsx
Normal file
103
concord-client/src/components/channel/ChannelItem.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Hash, Volume2 } from "lucide-react";
|
||||||
|
import { useNavigate, useParams } from "react-router";
|
||||||
|
import { Channel } from "@/lib/api-client";
|
||||||
|
import { useVoiceStore } from "@/stores/voiceStore";
|
||||||
|
import { useInstanceMembers } from "@/hooks/useServers";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
||||||
|
interface ChannelItemProps {
|
||||||
|
channel: Channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChannelItem: React.FC<ChannelItemProps> = ({ channel }) => {
|
||||||
|
const { instanceId, channelId: activeChannelId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Voice store hooks
|
||||||
|
const {
|
||||||
|
joinChannel,
|
||||||
|
leaveChannel,
|
||||||
|
activeVoiceChannelId,
|
||||||
|
remoteStreams,
|
||||||
|
localStream,
|
||||||
|
} = useVoiceStore();
|
||||||
|
|
||||||
|
// Data hooks
|
||||||
|
const { data: members } = useInstanceMembers(instanceId);
|
||||||
|
const { user: currentUser, token } = useAuthStore(); // Get token from auth store
|
||||||
|
|
||||||
|
const isConnectedToThisChannel = activeVoiceChannelId === channel.id;
|
||||||
|
const isActive = activeChannelId === channel.id;
|
||||||
|
|
||||||
|
const handleChannelClick = () => {
|
||||||
|
if (channel.type === "text") {
|
||||||
|
navigate(`/channels/${instanceId}/${channel.id}`);
|
||||||
|
} else if (channel.type === "voice") {
|
||||||
|
if (isConnectedToThisChannel) {
|
||||||
|
leaveChannel();
|
||||||
|
} else if (currentUser && token) {
|
||||||
|
joinChannel(channel.id, currentUser.id, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = channel.type === "voice" ? Volume2 : Hash;
|
||||||
|
const connectedUserIds = Array.from(remoteStreams.keys());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${isActive ?? "visible"}`}>
|
||||||
|
<button
|
||||||
|
onClick={handleChannelClick}
|
||||||
|
className={`w-full flex items-center p-1.5 rounded-md text-left transition-colors ${
|
||||||
|
isActive || isConnectedToThisChannel
|
||||||
|
? "bg-concord-secondary text-concord-primary"
|
||||||
|
: "text-concord-secondary hover:bg-concord-secondary/50 hover:text-concord-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate flex-1">{channel.name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Render connected users for this voice channel */}
|
||||||
|
{isConnectedToThisChannel && (
|
||||||
|
<div className="pl-4 mt-1 space-y-1">
|
||||||
|
{/* Current User */}
|
||||||
|
{localStream && currentUser && (
|
||||||
|
<div className="flex items-center p-1">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={currentUser.picture || ""} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{currentUser.username.slice(0, 2)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="ml-2 text-sm text-concord-primary">
|
||||||
|
{currentUser.nickname || currentUser.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remote Users */}
|
||||||
|
{connectedUserIds.map((userId) => {
|
||||||
|
const member = members?.find((m) => m.id === userId);
|
||||||
|
if (!member) return null;
|
||||||
|
return (
|
||||||
|
<div key={userId} className="flex items-center p-1">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={member.picture || ""} />
|
||||||
|
<AvatarFallback>{member.username.slice(0, 2)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="ml-2 text-sm text-concord-primary">
|
||||||
|
{member.nickname || member.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelItem;
|
||||||
@@ -1,52 +1,8 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import {
|
|
||||||
Hash,
|
|
||||||
Volume2,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Plus,
|
|
||||||
Edit,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { CategoryWithChannels } from "@/types/api";
|
import { CategoryWithChannels } from "@/types/api";
|
||||||
import { Channel } from "@/types/database";
|
import ChannelItem from "@/components/channel/ChannelItem";
|
||||||
|
|
||||||
interface ChannelItemProps {
|
|
||||||
channel: Channel;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChannelItem: React.FC<ChannelItemProps> = ({
|
|
||||||
channel,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const Icon = channel.type === "voice" ? Volume2 : Hash;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={`w-full justify-start px-2 py-2 h-8 text-left font-medium p-3 rounded-lg transition-all ${
|
|
||||||
isActive
|
|
||||||
? "border-primary bg-primary/10 border-2 "
|
|
||||||
: "hover:border-primary/50"
|
|
||||||
}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<Icon size={16} className="mr-2 flex-shrink-0" />
|
|
||||||
<span className="truncate">{channel.name}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CategoryHeaderProps {
|
interface CategoryHeaderProps {
|
||||||
category: CategoryWithChannels;
|
category: CategoryWithChannels;
|
||||||
@@ -60,16 +16,11 @@ const CategoryHeader: React.FC<CategoryHeaderProps> = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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"
|
className="w-full justify-between p-4 h-6 text-md text-concord-primary font-semibold interactive-hover uppercase tracking-wide group"
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
// Only toggle if not right-clicking (which opens dropdown)
|
|
||||||
if (e.button === 0) {
|
|
||||||
onToggle();
|
onToggle();
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -80,28 +31,7 @@ const CategoryHeader: React.FC<CategoryHeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
<span className="truncate">{category.name}</span>
|
<span className="truncate">{category.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Plus
|
|
||||||
size={12}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
/>
|
|
||||||
</Button>
|
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,18 +40,11 @@ interface ChannelListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
|
const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { instanceId, channelId } = useParams();
|
|
||||||
|
|
||||||
// Track expanded categories
|
// Track expanded categories
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
new Set(categories.map((cat) => cat.id)), // Start with all expanded
|
new Set(categories.map((cat) => cat.id)), // Start with all expanded
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChannelClick = (channel: Channel) => {
|
|
||||||
navigate(`/channels/${instanceId}/${channel.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCategory = (categoryId: string) => {
|
const toggleCategory = (categoryId: string) => {
|
||||||
setExpandedCategories((prev) => {
|
setExpandedCategories((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -158,21 +81,17 @@ const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
|
|||||||
onToggle={() => toggleCategory(category.id)}
|
onToggle={() => toggleCategory(category.id)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Channels */}
|
<div
|
||||||
{isExpanded && (
|
className={`ml-2 space-y-0.5 transition-all duration-300 ease-in-out overflow-hidden ${
|
||||||
<div className="ml-2 space-y-0.5">
|
isExpanded ? "max-h-screen opacity-100" : "max-h-0 opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{category.channels
|
{category.channels
|
||||||
.sort((a, b) => a.position - b.position)
|
.sort((a, b) => a.position - b.position)
|
||||||
.map((channel) => (
|
.map((channel) => (
|
||||||
<ChannelItem
|
<ChannelItem key={channel.id} channel={channel} />
|
||||||
key={channel.id}
|
|
||||||
channel={channel}
|
|
||||||
isActive={channelId === channel.id}
|
|
||||||
onClick={() => handleChannelClick(channel)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const Avatar: React.FC<AvatarProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUserInitials = (username: string, nickname?: string) => {
|
const getUserInitials = (username: string, nickname: string | null) => {
|
||||||
const name = nickname || username;
|
const name = nickname || username;
|
||||||
return name
|
return name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@@ -116,7 +116,7 @@ const Avatar: React.FC<AvatarProps> = ({
|
|||||||
size === "xl" && "text-lg",
|
size === "xl" && "text-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getUserInitials(user.username, user.nickname)}
|
{getUserInitials(user.username, user.nickname ? user.nickname : null)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</ShadcnAvatar>
|
</ShadcnAvatar>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, ErrorInfo, ReactNode } from "react";
|
|||||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -48,8 +49,8 @@ class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-concord-primary flex items-center justify-center p-4">
|
||||||
<div className="max-w-md w-full space-y-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">
|
<Alert className="border-red-500 bg-red-950/50">
|
||||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||||
<AlertTitle className="text-red-400">
|
<AlertTitle className="text-red-400">
|
||||||
@@ -82,7 +83,7 @@ class ErrorBoundary extends Component<Props, State> {
|
|||||||
|
|
||||||
{/* Error details in development */}
|
{/* Error details in development */}
|
||||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
{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">
|
<summary className="cursor-pointer text-red-400 font-medium mb-2">
|
||||||
Error Details (Development)
|
Error Details (Development)
|
||||||
</summary>
|
</summary>
|
||||||
@@ -107,7 +108,7 @@ class ErrorBoundary extends Component<Props, State> {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ChannelSidebar from "@/components/layout/ChannelSidebar";
|
|||||||
import UserPanel from "@/components/layout/UserPanel";
|
import UserPanel from "@/components/layout/UserPanel";
|
||||||
import MemberList from "@/components/layout/MemberList";
|
import MemberList from "@/components/layout/MemberList";
|
||||||
import LoadingSpinner from "@/components/common/LoadingSpinner";
|
import LoadingSpinner from "@/components/common/LoadingSpinner";
|
||||||
|
import VoiceConnectionManager from "@/components/voice/VoiceConnectionManager";
|
||||||
|
|
||||||
const AppLayout: React.FC = () => {
|
const AppLayout: React.FC = () => {
|
||||||
const { isLoading } = useAuthStore();
|
const { isLoading } = useAuthStore();
|
||||||
@@ -32,22 +33,15 @@ const AppLayout: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uncomment if auth is required
|
|
||||||
// if (!user) {
|
|
||||||
// return (
|
|
||||||
// <div className="h-screen w-screen flex items-center justify-center bg-concord-primary">
|
|
||||||
// <div className="text-red-400">Authentication required</div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 />
|
<ServerSidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel Sidebar - Only shown when in a server context and not collapsed */}
|
{/* Channel Sidebar - Only shown when in a server context and not collapsed */}
|
||||||
{shouldShowChannelSidebar && (
|
{shouldShowChannelSidebar && (
|
||||||
<div
|
<div
|
||||||
@@ -64,7 +58,6 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div
|
<div
|
||||||
className={`flex-1 flex flex-col min-w-0 ${
|
className={`flex-1 flex flex-col min-w-0 ${
|
||||||
@@ -73,10 +66,9 @@ const AppLayout: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Member List - Only shown when in a channel and member list is enabled */}
|
{/* Member List - Only shown when in a channel and member list is enabled */}
|
||||||
{showMemberList && shouldShowChannelSidebar && (
|
{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 />
|
<MemberList />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useParams } from "react-router";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useInstanceDetails } from "@/hooks/useServers";
|
import { useInstanceDetails } from "@/hooks/useServers";
|
||||||
import { useUiStore } from "@/stores/uiStore";
|
import { useUiStore } from "@/stores/uiStore";
|
||||||
import ChannelList from "@/components/channel/ChannelList";
|
import ChannelList from "@/components/channel/ChannelList";
|
||||||
|
import { CreateChannelModal } from "@/components/modals/CreateChannelModal";
|
||||||
|
|
||||||
const ChannelSidebar: React.FC = () => {
|
const ChannelSidebar: React.FC = () => {
|
||||||
const { instanceId } = useParams();
|
const { instanceId } = useParams();
|
||||||
@@ -13,8 +14,8 @@ const ChannelSidebar: React.FC = () => {
|
|||||||
useInstanceDetails(instanceId);
|
useInstanceDetails(instanceId);
|
||||||
const categories = instance?.categories;
|
const categories = instance?.categories;
|
||||||
const {
|
const {
|
||||||
toggleMemberList,
|
showCreateChannel,
|
||||||
showMemberList,
|
closeCreateChannel,
|
||||||
openCreateChannel,
|
openCreateChannel,
|
||||||
openServerSettings,
|
openServerSettings,
|
||||||
} = useUiStore();
|
} = useUiStore();
|
||||||
@@ -44,11 +45,11 @@ const ChannelSidebar: React.FC = () => {
|
|||||||
<div className="sidebar-secondary flex-1">
|
<div className="sidebar-secondary flex-1">
|
||||||
<ScrollArea className="">
|
<ScrollArea className="">
|
||||||
{/* Server Header */}
|
{/* 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">
|
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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}
|
onClick={openServerSettings}
|
||||||
>
|
>
|
||||||
<span className="truncate">{instance.name}</span>
|
<span className="truncate">{instance.name}</span>
|
||||||
@@ -72,28 +73,25 @@ const ChannelSidebar: React.FC = () => {
|
|||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
<div className="border-t border-sidebar px-2 py-2">
|
<div className="border-t border-sidebar px-2 py-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="interactive-hover"
|
className="justify-start interactive-hover flex-grow-1"
|
||||||
onClick={openCreateChannel}
|
onClick={openCreateChannel}
|
||||||
>
|
>
|
||||||
<Plus size={16} className="mr-1" />
|
<Plus size={16} className="mr-1" />
|
||||||
Add Channel
|
Add Channel
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
<CreateChannelModal
|
||||||
|
isOpen={showCreateChannel}
|
||||||
|
onClose={closeCreateChannel}
|
||||||
|
categories={categories}
|
||||||
|
instanceId={instance.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { useParams } from "react-router";
|
|||||||
import { Crown, Shield, UserIcon } from "lucide-react";
|
import { Crown, Shield, UserIcon } from "lucide-react";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Role } from "@/types/database";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Role, User } from "@/types/database";
|
||||||
import { useInstanceMembers } from "@/hooks/useServers";
|
import { useInstanceMembers } from "@/hooks/useServers";
|
||||||
import { User } from "@/types/database";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
// Status color utility
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "online":
|
case "online":
|
||||||
@@ -23,33 +23,64 @@ const getStatusColor = (status: string) => {
|
|||||||
|
|
||||||
interface MemberItemProps {
|
interface MemberItemProps {
|
||||||
member: User;
|
member: User;
|
||||||
|
instanceId: string;
|
||||||
isOwner?: boolean;
|
isOwner?: boolean;
|
||||||
|
currentUserRolePriority: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the user's role for this specific instance
|
const getUserRoleForInstance = (roles: Role[], instanceId: string): string => {
|
||||||
const getUserRoleForInstance = (roles: Role[], instanceId: string) => {
|
if (!instanceId) return "member";
|
||||||
return roles.find((r) => r.instanceId === instanceId)?.role || "member";
|
const roleEntry = roles.find((r) => r.instanceId === instanceId);
|
||||||
|
return roleEntry?.role || "member";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Define role colors and priorities
|
|
||||||
const getRoleInfo = (role: string) => {
|
const getRoleInfo = (role: string) => {
|
||||||
switch (role) {
|
const lowerRole = role.toLowerCase();
|
||||||
|
switch (lowerRole) {
|
||||||
case "admin":
|
case "admin":
|
||||||
return { color: "#ff6b6b", priority: 3, name: "Admin" };
|
return { color: "#ff6b6b", priority: 3, name: "Admin" };
|
||||||
case "mod":
|
case "mod":
|
||||||
return { color: "#4ecdc4", priority: 2, name: "Moderator" };
|
return { color: "#4ecdc4", priority: 2, name: "Moderator" };
|
||||||
default:
|
case "member":
|
||||||
return { color: null, priority: 1, name: "Member" };
|
return { color: null, priority: 1, name: "Member" };
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: null,
|
||||||
|
priority: 0,
|
||||||
|
name: role.charAt(0).toUpperCase() + role.slice(1),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
|
const MemberItem: React.FC<MemberItemProps> = ({
|
||||||
const { instanceId } = useParams();
|
member,
|
||||||
const userRole = getUserRoleForInstance(member.roles, instanceId || "");
|
instanceId,
|
||||||
|
isOwner = false,
|
||||||
|
currentUserRolePriority,
|
||||||
|
}) => {
|
||||||
|
// Determine the role for this specific instance
|
||||||
|
const userRole = getUserRoleForInstance(member.roles, instanceId);
|
||||||
const roleInfo = getRoleInfo(userRole);
|
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 (
|
return (
|
||||||
<div className="panel-button">
|
<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">
|
<div className="relative">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
@@ -57,7 +88,7 @@ const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
|
|||||||
alt={member.username}
|
alt={member.username}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="text-xs bg-primary text-primary-foreground">
|
<AvatarFallback className="text-xs bg-primary text-primary-foreground">
|
||||||
{member.username.slice(0, 2).toUpperCase()}
|
{member.username?.slice(0, 2).toUpperCase() || "???"}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
@@ -66,21 +97,26 @@ const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-3 flex-1 min-w-0">
|
<div className="flex-1 min-w-0 text-left">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
|
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
{!isOwner && userRole !== "member" && (
|
{/* Display Shield for Admins and Mods, not for Members */}
|
||||||
|
{!isOwner && effectiveMemberRolePriority > 1 && (
|
||||||
<Shield
|
<Shield
|
||||||
size={12}
|
size={12}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
style={{ color: roleInfo.color || "var(--background)" }}
|
style={{
|
||||||
|
color: effectiveRoleInfo.color || "var(--background)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className="text-sm font-medium truncate"
|
className="text-sm font-medium truncate"
|
||||||
style={{ color: roleInfo.color || "var(--color-text-primary)" }}
|
style={{
|
||||||
|
color: effectiveRoleInfo.color || "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{member.nickname || member.username}
|
{member.nickname || member.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -92,12 +128,28 @@ const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MemberList: React.FC = () => {
|
const MemberList: React.FC = () => {
|
||||||
const { instanceId } = useParams();
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
const { data: members, isLoading } = useInstanceMembers(instanceId);
|
const { data: members, isLoading } = useInstanceMembers(instanceId);
|
||||||
|
const { user: currentUser } = useAuthStore();
|
||||||
|
|
||||||
|
const currentUserRoleInfo = React.useMemo(() => {
|
||||||
|
if (!currentUser || !instanceId) {
|
||||||
|
return { role: "member", priority: 1, name: "Member", color: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (!instanceId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -114,53 +166,73 @@ const MemberList: React.FC = () => {
|
|||||||
if (!members || members.length === 0) {
|
if (!members || members.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group members by role
|
// Group members by their role for the current instance.
|
||||||
const groupedMembers = members.reduce(
|
const groupedMembers = members.reduce(
|
||||||
(acc, member) => {
|
(acc, member) => {
|
||||||
const userRole =
|
// Determine the effective role for this instance.
|
||||||
member.roles.find((r) => r.instanceId === instanceId)?.role || "member";
|
let effectiveRoleName = getUserRoleForInstance(
|
||||||
const roleInfo = getRoleInfo(userRole);
|
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]) {
|
if (!acc[roleInfo.name]) {
|
||||||
acc[roleInfo.name] = [];
|
acc[roleInfo.name] = [];
|
||||||
}
|
}
|
||||||
acc[roleInfo.name].push(member);
|
acc[roleInfo.name].push(member as User);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, User[]>,
|
{} as Record<string, User[]>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort role groups by priority (admin > mod > member)
|
// Get all unique role names present and sort them by priority.
|
||||||
const sortedRoleGroups = Object.entries(groupedMembers).sort(
|
const sortedRoleNames = Object.keys(groupedMembers).sort(
|
||||||
([roleNameA], [roleNameB]) => {
|
(roleNameA, roleNameB) => {
|
||||||
const priorityA = getRoleInfo(roleNameA.toLowerCase())?.priority || 1;
|
const priorityA = getRoleInfo(roleNameA).priority;
|
||||||
const priorityB = getRoleInfo(roleNameB.toLowerCase())?.priority || 1;
|
const priorityB = getRoleInfo(roleNameB).priority;
|
||||||
return priorityB - priorityA;
|
return priorityB - priorityA;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-concord-primary flex items-center justify-between">
|
<div className="px-6 py-4 pb-5 border-b border-concord flex items-center justify-between">
|
||||||
<UserIcon size={20} className="text-concord-primary h-8" />
|
<div className="flex items-center space-x-2">
|
||||||
<div className="h-8 flex flex-col justify-center">
|
<UserIcon size={20} className="h-5 w-5 text-concord-secondary" />
|
||||||
<h3 className="text-sm font-semibold text-concord-secondary tracking-wide">
|
<p className="font-semibold text-xl text-concord-primary tracking-wide">
|
||||||
Members: {members.length} Online:{" "}
|
Members
|
||||||
{members.filter((m) => m.status === "online").length}
|
</p>
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="font-medium text-concord-secondary tracking-wide">
|
||||||
|
{members.length}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Member List */}
|
{/* Member List */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{sortedRoleGroups.map(([roleName, roleMembers]) => (
|
{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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={roleName} className="mb-4">
|
<div key={roleName} className="mb-4">
|
||||||
{/* Role Header */}
|
{/* Role Header */}
|
||||||
<div className="px-4 py-1">
|
<div className="px-4 py-1">
|
||||||
@@ -171,18 +243,19 @@ const MemberList: React.FC = () => {
|
|||||||
|
|
||||||
{/* Role Members */}
|
{/* Role Members */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{roleMembers
|
{sortedMembers.map((member) => (
|
||||||
.sort((a, b) => a.username.localeCompare(b.username))
|
|
||||||
.map((member) => (
|
|
||||||
<MemberItem
|
<MemberItem
|
||||||
key={member.id}
|
key={member.id}
|
||||||
member={member}
|
member={member}
|
||||||
|
instanceId={instanceId}
|
||||||
|
currentUserRolePriority={currentUserRoleInfo.priority}
|
||||||
isOwner={false}
|
isOwner={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,67 +10,78 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useServers } from "@/hooks/useServers";
|
import { useServers } from "@/hooks/useServers";
|
||||||
import { useUiStore } from "@/stores/uiStore";
|
import { useUiStore } from "@/stores/uiStore";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import ServerIcon from "@/components/server/ServerIcon";
|
import ServerIcon from "@/components/server/ServerIcon";
|
||||||
|
import { getAccessibleInstances, isGlobalAdmin } from "@/utils/permissions";
|
||||||
|
|
||||||
const ServerSidebar: React.FC = () => {
|
const ServerSidebar: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { instanceId } = useParams();
|
const { instanceId } = useParams();
|
||||||
const { data: servers, isLoading } = useServers();
|
const { data: allServers = [], isLoading } = useServers();
|
||||||
const { openCreateServer, setActiveInstance, getSelectedChannelForInstance } =
|
const { openCreateServer, setActiveInstance, getSelectedChannelForInstance } =
|
||||||
useUiStore();
|
useUiStore();
|
||||||
|
const { user: currentUser } = useAuthStore();
|
||||||
|
|
||||||
|
// Filter servers based on user permissions
|
||||||
|
const accessibleServers = getAccessibleInstances(currentUser, allServers);
|
||||||
|
const canCreateServer = isGlobalAdmin(currentUser);
|
||||||
|
|
||||||
const handleServerClick = (serverId: string) => {
|
const handleServerClick = (serverId: string) => {
|
||||||
setActiveInstance(serverId);
|
setActiveInstance(serverId);
|
||||||
const lastChannelId = getSelectedChannelForInstance(serverId);
|
const lastChannelId = getSelectedChannelForInstance(serverId);
|
||||||
|
|
||||||
console.log(servers);
|
|
||||||
|
|
||||||
if (lastChannelId) {
|
if (lastChannelId) {
|
||||||
navigate(`/channels/${serverId}/${lastChannelId}`);
|
navigate(`/channels/${serverId}/${lastChannelId}`);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: navigate to the server, let the page component handle finding a channel
|
// Fallback: navigate to the server, let the page component handle finding a channel
|
||||||
// A better UX would be to find and navigate to the first channel here.
|
|
||||||
navigate(`/channels/${serverId}`);
|
navigate(`/channels/${serverId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHomeClick = () => {
|
const handleHomeClick = () => {
|
||||||
setActiveInstance(null);
|
setActiveInstance(null);
|
||||||
navigate("/channels/@me");
|
navigate("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateServer = () => {
|
||||||
|
if (canCreateServer) {
|
||||||
|
openCreateServer();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<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 */}
|
{/* Home/DM Button */}
|
||||||
<Tooltip>
|
<Tooltip key={"home-server"}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={` w-12 h-12 ml-0 ${
|
className={`w-12 h-12 mt-2 transition-all duration-200 ${
|
||||||
!instanceId || instanceId === "@me" ? "active" : ""
|
!instanceId || instanceId === "@me"
|
||||||
|
? "rounded-xl border-primary bg-primary/10 border-2"
|
||||||
|
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleHomeClick}
|
onClick={handleHomeClick}
|
||||||
>
|
>
|
||||||
<Home size={24} />
|
<Home size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Direct Messages</p>
|
<p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Separator */}
|
{/* 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 */}
|
{/* Server List */}
|
||||||
<div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin scrollbar-thumb-border space-y-2">
|
<div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin scrollbar-thumb-border space-y-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : accessibleServers.length > 0 ? (
|
||||||
servers?.map((server) => (
|
accessibleServers.map((server) => (
|
||||||
<Tooltip key={server.id}>
|
<Tooltip key={server.id}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
@@ -86,17 +97,32 @@ const ServerSidebar: React.FC = () => {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))
|
))
|
||||||
|
) : currentUser ? (
|
||||||
|
<div className="text-center py-4 px-2">
|
||||||
|
<div className="text-xs text-concord-secondary mb-2">
|
||||||
|
No servers available
|
||||||
|
</div>
|
||||||
|
{canCreateServer && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={handleCreateServer}
|
||||||
|
>
|
||||||
|
Create One
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
{/* Add Server Button */}
|
{/* Add Server Button - Only show if user can create servers */}
|
||||||
|
{canCreateServer && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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"
|
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={openCreateServer}
|
onClick={handleCreateServer}
|
||||||
>
|
>
|
||||||
<Plus size={24} />
|
<Plus size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -105,6 +131,8 @@ const ServerSidebar: React.FC = () => {
|
|||||||
<p>Add a Server</p>
|
<p>Add a Server</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { Settings, Mic, MicOff, Headphones } from "lucide-react";
|
import { Settings, Mic, MicOff, Headphones } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
@@ -8,17 +8,10 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
|
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { useUiStore } from "@/stores/uiStore";
|
import { useVoiceStore } from "@/stores/voiceStore";
|
||||||
import { SAMPLE_USERS } from "@/hooks/useServers";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
// Status color utility
|
// Status color utility
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
@@ -34,68 +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 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={() => useAuthStore.getState().logout()}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
Log Out
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Voice Controls Component
|
// Voice Controls Component
|
||||||
interface VoiceControlsProps {
|
interface VoiceControlsProps {
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
onMuteToggle: () => void;
|
onMuteToggle: () => void;
|
||||||
onDeafenToggle: () => void;
|
onDeafenToggle: () => void;
|
||||||
onSettingsClick: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VoiceControls: React.FC<VoiceControlsProps> = ({
|
const VoiceControls: React.FC<VoiceControlsProps> = ({
|
||||||
@@ -103,7 +40,6 @@ const VoiceControls: React.FC<VoiceControlsProps> = ({
|
|||||||
isDeafened,
|
isDeafened,
|
||||||
onMuteToggle,
|
onMuteToggle,
|
||||||
onDeafenToggle,
|
onDeafenToggle,
|
||||||
onSettingsClick,
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
@@ -143,21 +79,6 @@ const VoiceControls: React.FC<VoiceControlsProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Settings */}
|
{/* 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>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -204,69 +125,53 @@ const UserAvatar: React.FC<UserAvatarProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Main UserPanel Component
|
|
||||||
const UserPanel: React.FC = () => {
|
const UserPanel: React.FC = () => {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const { openUserSettings } = useUiStore();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const { isConnected, isMuted, isDeafened, toggleMute, toggleDeafen } =
|
||||||
const [isDeafened, setIsDeafened] = useState(false);
|
useVoiceStore();
|
||||||
|
|
||||||
const displayUser = user || SAMPLE_USERS.find((u) => u.id === "current");
|
|
||||||
|
|
||||||
if (!displayUser) {
|
|
||||||
return (
|
|
||||||
<div className="flex-shrink-0 p-2 bg-concord-tertiary">
|
|
||||||
<div className="text-concord-secondary text-sm">No user data</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStatusChange = (newStatus: string) => {
|
|
||||||
console.log("Status change to:", newStatus);
|
|
||||||
// TODO: Implement API call to update user status
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMuteToggle = () => setIsMuted(!isMuted);
|
|
||||||
const handleDeafenToggle = () => {
|
|
||||||
const newDeafenState = !isDeafened;
|
|
||||||
setIsDeafened(newDeafenState);
|
|
||||||
if (newDeafenState) {
|
|
||||||
setIsMuted(true); // Deafening also mutes
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
|
<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 with Dropdown */}
|
{/* User Info */}
|
||||||
<UserStatusDropdown
|
<UserAvatar user={user} size="md" />
|
||||||
currentStatus={displayUser.status}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="flex-1 flex items-center h-auto p-1 rounded-md hover:bg-concord-secondary"
|
|
||||||
>
|
|
||||||
<UserAvatar user={displayUser} size="md" />
|
|
||||||
<div className="ml-2 flex-1 min-w-0 text-left">
|
<div className="ml-2 flex-1 min-w-0 text-left">
|
||||||
<div className="text-sm font-medium text-concord-primary truncate">
|
<div className="text-sm font-medium text-concord-primary truncate">
|
||||||
{displayUser.nickname || displayUser.username}
|
{user?.nickname || user?.username}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-concord-secondary truncate capitalize">
|
<div className="text-xs text-concord-secondary truncate capitalize">
|
||||||
{displayUser.status}
|
{user?.status}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
|
||||||
</UserStatusDropdown>
|
|
||||||
|
|
||||||
{/* Voice Controls */}
|
{/* Voice Controls */}
|
||||||
|
{isConnected && (
|
||||||
<VoiceControls
|
<VoiceControls
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
isDeafened={isDeafened}
|
isDeafened={isDeafened}
|
||||||
onMuteToggle={handleMuteToggle}
|
onMuteToggle={toggleMute}
|
||||||
onDeafenToggle={handleDeafenToggle}
|
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>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
199
concord-client/src/components/modals/CreateChannelModal.tsx
Normal file
199
concord-client/src/components/modals/CreateChannelModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
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;
|
||||||
|
categories: CategoryWithChannels[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
categories,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [type, setType] = useState<"text" | "voice">("text");
|
||||||
|
const [categoryId, setCategoryId] = useState("");
|
||||||
|
|
||||||
|
const createChannelMutation = useCreateChannel();
|
||||||
|
|
||||||
|
// Reset form when modal opens or closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setType("text");
|
||||||
|
setCategoryId("");
|
||||||
|
} else {
|
||||||
|
setCategoryId("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// 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({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
type,
|
||||||
|
categoryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form after successful creation
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setType("text");
|
||||||
|
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]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Channel</DialogTitle>
|
||||||
|
</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" ? "secondary" : "ghost"}
|
||||||
|
onClick={() => setType("text")}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Hash className="h-4 w-4 mr-2" />
|
||||||
|
Text
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={type === "voice" ? "secondary" : "ghost"}
|
||||||
|
onClick={() => setType("voice")}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Volume2 className="h-4 w-4 mr-2" />
|
||||||
|
Voice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel Name Input */}
|
||||||
|
<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="awesome-channel"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
id="channel-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What's this channel about?"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isFormInvalid}>
|
||||||
|
{createChannelMutation.isPending ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Create Channel"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
92
concord-client/src/components/modals/CreateServerModal.tsx
Normal file
92
concord-client/src/components/modals/CreateServerModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useCreateInstance } from "@/hooks/useServers";
|
||||||
|
|
||||||
|
interface CreateServerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateServerModal: React.FC<CreateServerModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [icon, setIcon] = useState("");
|
||||||
|
|
||||||
|
const createInstanceMutation = useCreateInstance();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createInstanceMutation.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
icon: icon.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setName("");
|
||||||
|
setIcon("");
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create server:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Server</DialogTitle>
|
||||||
|
</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="My Awesome Server"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-icon">Server Icon URL (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="server-icon"
|
||||||
|
value={icon}
|
||||||
|
onChange={(e) => setIcon(e.target.value)}
|
||||||
|
placeholder="https://example.com/icon.png"
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!name.trim() || createInstanceMutation.isPending}
|
||||||
|
>
|
||||||
|
{createInstanceMutation.isPending
|
||||||
|
? "Creating..."
|
||||||
|
: "Create Server"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,17 +23,17 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className="relative group w-12">
|
||||||
{/* Active indicator */}
|
{/* Active indicator - Positioned outside to the left */}
|
||||||
<div
|
<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"
|
isActive ? "h-10 rounded-xl" : "rounded-r h-2 group-hover:h-5"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={`w-12 h-12 ml-3 transition-all duration-200 ${
|
className={`w-12 h-12 transition-all duration-200 ${
|
||||||
isActive
|
isActive
|
||||||
? "rounded-xl border-primary bg-primary/10 border-2"
|
? "rounded-xl border-primary bg-primary/10 border-2"
|
||||||
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
|
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
|
||||||
@@ -44,7 +44,7 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
|||||||
<img
|
<img
|
||||||
src={server.icon}
|
src={server.icon}
|
||||||
alt={server.name}
|
alt={server.name}
|
||||||
className="w-full h-full object-cover rounded-inherit"
|
className={`w-full h-full object-cover ${isActive ? "rounded-xl" : "rounded-2xl"}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-semibold text-sm">
|
<span className="font-semibold text-sm">
|
||||||
|
|||||||
@@ -1,419 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Moon, Sun, Monitor, Palette, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} 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 {
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
|
||||||
Create Theme
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main theme selector component
|
|
||||||
export function ThemeSelector() {
|
|
||||||
const {
|
|
||||||
mode,
|
|
||||||
currentTheme,
|
|
||||||
// themes,
|
|
||||||
setMode,
|
|
||||||
setTheme,
|
|
||||||
addCustomTheme,
|
|
||||||
removeCustomTheme,
|
|
||||||
getThemesForMode,
|
|
||||||
} = useTheme();
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
{getCurrentModeIcon()}
|
|
||||||
<span className="ml-2">{currentTheme.name}</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{/* Mode Selection */}
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="text-xs">Mode</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem onClick={() => setMode("light")}>
|
|
||||||
<Sun className="mr-2 h-4 w-4" />
|
|
||||||
<span>Light</span>
|
|
||||||
{mode === "light" && (
|
|
||||||
<Badge variant="secondary" className="ml-auto">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setMode("dark")}>
|
|
||||||
<Moon className="mr-2 h-4 w-4" />
|
|
||||||
<span>Dark</span>
|
|
||||||
{mode === "dark" && (
|
|
||||||
<Badge variant="secondary" className="ml-auto">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setMode("system")}>
|
|
||||||
<Monitor className="mr-2 h-4 w-4" />
|
|
||||||
<span>System</span>
|
|
||||||
{mode === "system" && (
|
|
||||||
<Badge variant="secondary" className="ml-auto">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{/* Light Themes */}
|
|
||||||
{lightThemes.length > 0 && (
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="text-xs">
|
|
||||||
Light Themes
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{lightThemes.map((theme) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={theme.id}
|
|
||||||
onClick={() => setTheme(theme.id)}
|
|
||||||
className="justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Palette className="mr-2 h-4 w-4" />
|
|
||||||
<span>{theme.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{currentTheme.id === theme.id && (
|
|
||||||
<Badge variant="secondary">Active</Badge>
|
|
||||||
)}
|
|
||||||
{theme.isCustom && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-auto p-1 hover:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeCustomTheme(theme.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lightThemes.length > 0 && darkThemes.length > 0 && (
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dark Themes */}
|
|
||||||
{darkThemes.length > 0 && (
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="text-xs">
|
|
||||||
Dark Themes
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{darkThemes.map((theme) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={theme.id}
|
|
||||||
onClick={() => setTheme(theme.id)}
|
|
||||||
className="justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Palette className="mr-2 h-4 w-4" />
|
|
||||||
<span>{theme.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{currentTheme.id === theme.id && (
|
|
||||||
<Badge variant="secondary">Active</Badge>
|
|
||||||
)}
|
|
||||||
{theme.isCustom && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-auto p-1 hover:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeCustomTheme(theme.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{/* Add Custom Theme */}
|
|
||||||
<DropdownMenuItem onClick={() => setIsCreateModalOpen(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
<span>Create Theme</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<CreateThemeModal
|
|
||||||
isOpen={isCreateModalOpen}
|
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
|
||||||
onSave={addCustomTheme}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -209,7 +209,7 @@ function DropdownMenuSubTrigger({
|
|||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { useVoiceStore } from "@/stores/voiceStore";
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
stream: MediaStream;
|
||||||
|
isDeafened: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioPlayer: React.FC<AudioPlayerProps> = ({ stream, isDeafened }) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.srcObject = stream;
|
||||||
|
audioRef.current.volume = isDeafened ? 0 : 1;
|
||||||
|
}
|
||||||
|
}, [stream, isDeafened]);
|
||||||
|
|
||||||
|
return <audio ref={audioRef} autoPlay playsInline />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VoiceConnectionManager: React.FC = () => {
|
||||||
|
const remoteStreams = useVoiceStore((state) => state.remoteStreams);
|
||||||
|
const isDeafened = useVoiceStore((state) => state.isDeafened);
|
||||||
|
|
||||||
|
if (remoteStreams.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "none" }}>
|
||||||
|
{Array.from(remoteStreams.entries()).map(([userId, stream]) => (
|
||||||
|
<AudioPlayer key={userId} stream={stream} isDeafened={isDeafened} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VoiceConnectionManager;
|
||||||
212
concord-client/src/hooks/useAuth.ts
Normal file
212
concord-client/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import {
|
||||||
|
authClient,
|
||||||
|
LoginCredentials,
|
||||||
|
RegisterData,
|
||||||
|
AuthResponse,
|
||||||
|
} from "@/lib/auth-client";
|
||||||
|
import { BackendUser } from "@/lib/api-client";
|
||||||
|
import { Role, UserStatus } from "@/types/database";
|
||||||
|
|
||||||
|
// Frontend User type
|
||||||
|
interface FrontendUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
picture?: string | null;
|
||||||
|
banner?: string | null;
|
||||||
|
hashPassword: string;
|
||||||
|
admin: boolean;
|
||||||
|
status: UserStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform backend user to frontend user format
|
||||||
|
function transformBackendUser(backendUser: BackendUser): FrontendUser {
|
||||||
|
return {
|
||||||
|
id: backendUser.id,
|
||||||
|
username: backendUser.userName,
|
||||||
|
nickname: backendUser.nickName,
|
||||||
|
bio: backendUser.bio,
|
||||||
|
picture: backendUser.picture,
|
||||||
|
banner: backendUser.banner,
|
||||||
|
hashPassword: "", // Don't store password
|
||||||
|
admin: backendUser.admin,
|
||||||
|
status: transformStatusToFrontend(backendUser.status),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
roles: backendUser.role.map((r) => ({
|
||||||
|
instanceId: r.instanceId || "",
|
||||||
|
role: r.role || "member",
|
||||||
|
})) as Role[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform status from backend to frontend format
|
||||||
|
function transformStatusToFrontend(
|
||||||
|
backendStatus: "online" | "offline" | "dnd" | "idle" | "invis",
|
||||||
|
): UserStatus {
|
||||||
|
switch (backendStatus) {
|
||||||
|
case "dnd":
|
||||||
|
return "busy";
|
||||||
|
case "idle":
|
||||||
|
return "away";
|
||||||
|
case "invis":
|
||||||
|
return "offline";
|
||||||
|
default:
|
||||||
|
return "online";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform status from frontend to backend format
|
||||||
|
export function transformStatusToBackend(
|
||||||
|
frontendStatus: UserStatus,
|
||||||
|
): "online" | "offline" | "dnd" | "idle" | "invis" {
|
||||||
|
switch (frontendStatus) {
|
||||||
|
case "busy":
|
||||||
|
return "dnd";
|
||||||
|
case "away":
|
||||||
|
return "idle";
|
||||||
|
case "offline":
|
||||||
|
return "invis";
|
||||||
|
default:
|
||||||
|
return "online";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for login
|
||||||
|
export const useLogin = () => {
|
||||||
|
const { setAuth, setLoading } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (
|
||||||
|
credentials: LoginCredentials,
|
||||||
|
): Promise<AuthResponse> => {
|
||||||
|
setLoading(true);
|
||||||
|
return authClient.login(credentials);
|
||||||
|
},
|
||||||
|
onSuccess: (data: AuthResponse) => {
|
||||||
|
const frontendUser = transformBackendUser(data.user);
|
||||||
|
setAuth(frontendUser, data.token, data.token); // Use token as refresh token for now
|
||||||
|
queryClient.clear();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Login failed:", error);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for registration (requires admin)
|
||||||
|
export const useRegister = () => {
|
||||||
|
const { setAuth, setLoading, user, token } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (!user || !token || !user.admin) {
|
||||||
|
throw new Error("Admin privileges required for user creation");
|
||||||
|
}
|
||||||
|
|
||||||
|
return authClient.register(userData, { id: user.id, token });
|
||||||
|
},
|
||||||
|
onSuccess: (data: AuthResponse) => {
|
||||||
|
const frontendUser = transformBackendUser(data.user);
|
||||||
|
setAuth(frontendUser, data.token, data.token);
|
||||||
|
queryClient.clear();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Registration failed:", error);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for logout
|
||||||
|
export const useLogout = () => {
|
||||||
|
const { logout, user } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (): Promise<void> => {
|
||||||
|
if (user) {
|
||||||
|
try {
|
||||||
|
await authClient.logout(user.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Logout endpoint failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
logout();
|
||||||
|
queryClient.clear();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Logout failed:", error);
|
||||||
|
// Still logout locally even if server request fails
|
||||||
|
logout();
|
||||||
|
queryClient.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for token validation
|
||||||
|
export const useValidateToken = () => {
|
||||||
|
const { token, user, setAuth, logout } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (): Promise<{ valid: boolean; user?: BackendUser }> => {
|
||||||
|
if (!token || !user) {
|
||||||
|
throw new Error("No token to validate");
|
||||||
|
}
|
||||||
|
return authClient.validateToken(token, user.id);
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (!data.valid) {
|
||||||
|
logout();
|
||||||
|
} else if (data.user) {
|
||||||
|
const frontendUser = transformBackendUser(data.user);
|
||||||
|
setAuth(frontendUser, token!, useAuthStore.getState().refreshToken!);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Token validation failed:", error);
|
||||||
|
logout();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for token refresh
|
||||||
|
export const useRefreshToken = () => {
|
||||||
|
const { refreshToken, user, setAuth, logout } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (): Promise<AuthResponse> => {
|
||||||
|
if (!refreshToken || !user) {
|
||||||
|
throw new Error("No refresh token available");
|
||||||
|
}
|
||||||
|
return authClient.refreshToken(refreshToken, user.id);
|
||||||
|
},
|
||||||
|
onSuccess: (data: AuthResponse) => {
|
||||||
|
const frontendUser = transformBackendUser(data.user);
|
||||||
|
setAuth(frontendUser, data.token, data.token);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Token refresh failed:", error);
|
||||||
|
logout();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { Message } from "@/types/database";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
// Sample messages data
|
|
||||||
export const SAMPLE_MESSAGES = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
content: "Hey everyone! Just finished the new theme system. Check it out!",
|
|
||||||
channelId: "1", // general channel
|
|
||||||
userId: "1",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
content:
|
|
||||||
"Looking great! The dark mode especially feels much better on the eyes 👀",
|
|
||||||
channelId: "1",
|
|
||||||
userId: "2",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
content: "Can we add a **high contrast mode** for accessibility?",
|
|
||||||
channelId: "1",
|
|
||||||
userId: "3",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
content:
|
|
||||||
"```typescript\nconst theme = {\n primary: 'oklch(0.6 0.2 240)',\n secondary: 'oklch(0.8 0.1 60)'\n};\n```\nHere's how the new color system works!",
|
|
||||||
channelId: "1",
|
|
||||||
userId: "3",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5",
|
|
||||||
content:
|
|
||||||
"Perfect timing! I was just about to ask about the color format. _OKLCH_ is so much better than HSL for this.",
|
|
||||||
channelId: "1",
|
|
||||||
userId: "1",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
// Messages for random channel
|
|
||||||
{
|
|
||||||
id: "6",
|
|
||||||
content: "Anyone up for a game tonight?",
|
|
||||||
channelId: "2", // random channel
|
|
||||||
userId: "2",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "7",
|
|
||||||
content: "I'm in! What are we playing?",
|
|
||||||
channelId: "2",
|
|
||||||
userId: "1",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const useChannelMessages = (channelId?: string) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["messages", channelId],
|
|
||||||
queryFn: async (): Promise<Message[]> => {
|
|
||||||
if (!channelId) return [];
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
return SAMPLE_MESSAGES.filter((msg) => msg.channelId === channelId);
|
|
||||||
},
|
|
||||||
enabled: !!channelId,
|
|
||||||
staleTime: 1000 * 60 * 1,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
303
concord-client/src/hooks/useMessages.ts
Normal file
303
concord-client/src/hooks/useMessages.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiClient, Message } from "@/lib/api-client";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
|
// Hook for getting messages in a channel with pagination
|
||||||
|
export const useChannelMessages = (channelId?: string, limit = 50) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["messages", channelId, limit],
|
||||||
|
queryFn: async (): Promise<Message[]> => {
|
||||||
|
if (!channelId) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date();
|
||||||
|
const messages = await apiClient.getMessages({
|
||||||
|
date: date.toISOString(),
|
||||||
|
channelId: channelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return messages || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch messages:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!channelId,
|
||||||
|
staleTime: 500 * 1,
|
||||||
|
refetchInterval: 500 * 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for getting older messages (pagination)
|
||||||
|
export const useChannelMessagesPaginated = (
|
||||||
|
channelId?: string,
|
||||||
|
beforeDate?: Date,
|
||||||
|
limit = 50,
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"messages",
|
||||||
|
channelId,
|
||||||
|
"paginated",
|
||||||
|
beforeDate?.toISOString(),
|
||||||
|
limit,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<Message[]> => {
|
||||||
|
if (!channelId || !beforeDate) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = await apiClient.getMessages({
|
||||||
|
date: beforeDate.toISOString(),
|
||||||
|
channelId: channelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return messages || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch paginated messages:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!channelId && !!beforeDate,
|
||||||
|
staleTime: 500 * 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for sending messages
|
||||||
|
export const useSendMessage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, token } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
channelId: string;
|
||||||
|
content: string;
|
||||||
|
repliedMessageId?: string | null;
|
||||||
|
}) => {
|
||||||
|
if (!user || !token) {
|
||||||
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
channelId: data.channelId,
|
||||||
|
userId: user.id,
|
||||||
|
content: data.content,
|
||||||
|
token: token,
|
||||||
|
repliedMessageId: data.repliedMessageId,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await apiClient.sendMessage(requestData);
|
||||||
|
return message;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send message:", error);
|
||||||
|
throw new Error("Failed to send message");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["messages", variables.channelId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Send message failed:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for deleting messages
|
||||||
|
export const useDeleteMessage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, token } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: { messageId: string; channelId: string }) => {
|
||||||
|
if (!user || !token) {
|
||||||
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with actual API call when available
|
||||||
|
|
||||||
|
return { success: true, messageId: data.messageId };
|
||||||
|
},
|
||||||
|
onSuccess: (result, variables) => {
|
||||||
|
// Update the cache to mark message as deleted
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["messages", variables.channelId],
|
||||||
|
(oldData: Message[] | undefined) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
|
||||||
|
return oldData.map((msg) =>
|
||||||
|
msg.id === result.messageId
|
||||||
|
? { ...msg, content: "[Message deleted]", deleted: true }
|
||||||
|
: msg,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Delete message failed:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for editing messages
|
||||||
|
export const useEditMessage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, token } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
messageId: string;
|
||||||
|
content: string;
|
||||||
|
channelId: string;
|
||||||
|
}) => {
|
||||||
|
if (!user || !token) {
|
||||||
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with actual API call when available
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: data.messageId,
|
||||||
|
content: data.content,
|
||||||
|
edited: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: (result, variables) => {
|
||||||
|
// Update the cache with edited message
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["messages", variables.channelId],
|
||||||
|
(oldData: Message[] | undefined) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
|
||||||
|
return oldData.map((msg) =>
|
||||||
|
msg.id === result.messageId
|
||||||
|
? { ...msg, content: result.content, edited: result.edited }
|
||||||
|
: msg,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Edit message failed:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for pinning/unpinning messages
|
||||||
|
export const usePinMessage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, token } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
messageId: string;
|
||||||
|
channelId: string;
|
||||||
|
pinned: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!user || !token) {
|
||||||
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with actual API call when available
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: data.messageId,
|
||||||
|
pinned: data.pinned,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: (result, variables) => {
|
||||||
|
// Update the cache with pinned status
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["messages", variables.channelId],
|
||||||
|
(oldData: Message[] | undefined) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
|
||||||
|
return oldData.map((msg) =>
|
||||||
|
msg.id === result.messageId
|
||||||
|
? { ...msg, pinned: result.pinned }
|
||||||
|
: msg,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also invalidate pinned messages query if it exists
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["pinned-messages", variables.channelId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Pin message failed:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for getting pinned messages
|
||||||
|
export const usePinnedMessages = (channelId?: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["pinned-messages", channelId],
|
||||||
|
queryFn: async (): Promise<Message[]> => {
|
||||||
|
if (!channelId) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Replace with actual API call when available
|
||||||
|
// For now, return empty array
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch pinned messages:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!channelId,
|
||||||
|
staleTime: 500 * 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for loading more messages (infinite scroll)
|
||||||
|
export const useLoadMoreMessages = (channelId?: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: { beforeDate: Date }) => {
|
||||||
|
if (!channelId) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = await apiClient.getMessages({
|
||||||
|
date: data.beforeDate.toISOString(),
|
||||||
|
channelId: channelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return messages || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load more messages:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (newMessages) => {
|
||||||
|
if (newMessages.length > 0) {
|
||||||
|
// Prepend new messages to existing messages
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["messages", channelId],
|
||||||
|
(oldData: Message[] | undefined) => {
|
||||||
|
if (!oldData) return newMessages;
|
||||||
|
|
||||||
|
// Remove duplicates and sort by creation date
|
||||||
|
const combined = [...newMessages, ...oldData];
|
||||||
|
const unique = combined.filter(
|
||||||
|
(msg, index, arr) =>
|
||||||
|
arr.findIndex((m) => m.id === msg.id) === index,
|
||||||
|
);
|
||||||
|
|
||||||
|
return unique.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.createdAt).getTime() -
|
||||||
|
new Date(b.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
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;
|
||||||
|
};
|
||||||
@@ -1,337 +1,119 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Instance, User } from "@/types/database";
|
import {
|
||||||
import { InstanceWithDetails } from "@/types/api";
|
apiClient,
|
||||||
import { CategoryWithChannels } from "@/types/api";
|
Instance,
|
||||||
|
Category,
|
||||||
|
Channel,
|
||||||
|
BackendUser,
|
||||||
|
} from "@/lib/api-client";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
// Sample users data with proper Role structure
|
// Extended types with relations for frontend use
|
||||||
export const SAMPLE_USERS: User[] = [
|
export interface CategoryWithChannels extends Category {
|
||||||
{
|
channels: Channel[];
|
||||||
id: "1",
|
}
|
||||||
username: "alice_dev",
|
|
||||||
nickname: "Alice",
|
export interface InstanceWithDetails extends Instance {
|
||||||
bio: "Frontend developer who loves React",
|
categories: CategoryWithChannels[];
|
||||||
picture: null,
|
}
|
||||||
banner: null,
|
|
||||||
status: "online" as const,
|
// Transform backend user to frontend user format for compatibility
|
||||||
|
function transformBackendUserToFrontend(backendUser: BackendUser) {
|
||||||
|
return {
|
||||||
|
id: backendUser.id,
|
||||||
|
username: backendUser.userName,
|
||||||
|
nickname: backendUser.nickName,
|
||||||
|
bio: backendUser.bio,
|
||||||
|
picture: backendUser.picture,
|
||||||
|
banner: backendUser.banner,
|
||||||
hashPassword: "",
|
hashPassword: "",
|
||||||
admin: false,
|
admin: backendUser.admin,
|
||||||
|
status:
|
||||||
|
backendUser.status === "dnd"
|
||||||
|
? "busy"
|
||||||
|
: backendUser.status === "idle"
|
||||||
|
? "away"
|
||||||
|
: backendUser.status === "invis"
|
||||||
|
? "offline"
|
||||||
|
: backendUser.status,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
roles: [], // Will be populated per instance
|
roles: backendUser.role.map((r) => ({
|
||||||
},
|
instanceId: r.instanceId || "",
|
||||||
{
|
role: r.role || "member",
|
||||||
id: "2",
|
})),
|
||||||
username: "bob_designer",
|
};
|
||||||
nickname: "Bob",
|
}
|
||||||
bio: "UI/UX Designer & Coffee Enthusiast",
|
|
||||||
picture:
|
|
||||||
"https://media.istockphoto.com/id/1682296067/photo/happy-studio-portrait-or-professional-man-real-estate-agent-or-asian-businessman-smile-for.jpg?s=612x612&w=0&k=20&c=9zbG2-9fl741fbTWw5fNgcEEe4ll-JegrGlQQ6m54rg=",
|
|
||||||
banner: null,
|
|
||||||
status: "away" as const,
|
|
||||||
hashPassword: "",
|
|
||||||
admin: false,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
username: "charlie_backend",
|
|
||||||
nickname: "Charlie",
|
|
||||||
bio: "Backend wizard, scaling systems since 2018",
|
|
||||||
picture: null,
|
|
||||||
banner: null,
|
|
||||||
status: "busy" as const,
|
|
||||||
hashPassword: "",
|
|
||||||
admin: false,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "current",
|
|
||||||
username: "you",
|
|
||||||
nickname: "You",
|
|
||||||
bio: "That's you!",
|
|
||||||
picture: null,
|
|
||||||
banner: null,
|
|
||||||
status: "online" as const,
|
|
||||||
hashPassword: "",
|
|
||||||
admin: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Sample servers data
|
// Hook for getting all servers/instances
|
||||||
const SAMPLE_SERVERS: Instance[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "Dev Team",
|
|
||||||
icon: null,
|
|
||||||
description: "Our development team server",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "Gaming Squad",
|
|
||||||
icon: null,
|
|
||||||
description: "Gaming and fun times",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "Book Club",
|
|
||||||
icon: null,
|
|
||||||
description: "Monthly book discussions",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Sample messages data
|
|
||||||
export const SAMPLE_MESSAGES = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
content: "Hey everyone! Just finished the new theme system. Check it out!",
|
|
||||||
channelId: "1", // general channel
|
|
||||||
userId: "1",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
content:
|
|
||||||
"Looking great! The dark mode especially feels much better on the eyes 👀",
|
|
||||||
channelId: "1",
|
|
||||||
userId: "2",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
content: "Can we add a **high contrast mode** for accessibility?",
|
|
||||||
channelId: "1",
|
|
||||||
userId: "3",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
content:
|
|
||||||
"```typescript\nconst theme = {\n primary: 'oklch(0.6 0.2 240)',\n secondary: 'oklch(0.8 0.1 60)'\n};\n```\nHere's how the new color system works!",
|
|
||||||
channelId: "1",
|
|
||||||
userId: "3",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5",
|
|
||||||
content:
|
|
||||||
"Perfect timing! I was just about to ask about the color format. _OKLCH_ is so much better than HSL for this.",
|
|
||||||
channelId: "1",
|
|
||||||
userId: "1",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
// Messages for random channel
|
|
||||||
{
|
|
||||||
id: "6",
|
|
||||||
content: "Anyone up for a game tonight?",
|
|
||||||
channelId: "2", // random channel
|
|
||||||
userId: "2",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "7",
|
|
||||||
content: "I'm in! What are we playing?",
|
|
||||||
channelId: "2",
|
|
||||||
userId: "1",
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Sample categories with channels
|
|
||||||
const createSampleCategories = (instanceId: string): CategoryWithChannels[] => [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "Text Channels",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 0,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
channels: [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "general",
|
|
||||||
type: "text",
|
|
||||||
categoryId: "1",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 0,
|
|
||||||
description: "General discussion about development and projects",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "random",
|
|
||||||
type: "text",
|
|
||||||
categoryId: "1",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 1,
|
|
||||||
description: "Random chat and off-topic discussions",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "announcements",
|
|
||||||
type: "text",
|
|
||||||
categoryId: "1",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 2,
|
|
||||||
description: "Important announcements and updates",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "Voice Channels",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 1,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
channels: [
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
name: "General",
|
|
||||||
type: "voice",
|
|
||||||
categoryId: "2",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 0,
|
|
||||||
description: "",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5",
|
|
||||||
name: "Focus Room",
|
|
||||||
type: "voice",
|
|
||||||
categoryId: "2",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 1,
|
|
||||||
description: "",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "Project Channels",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 2,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
channels: [
|
|
||||||
{
|
|
||||||
id: "6",
|
|
||||||
name: "frontend",
|
|
||||||
type: "text",
|
|
||||||
categoryId: "3",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 0,
|
|
||||||
description: "Frontend development discussions",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "7",
|
|
||||||
name: "backend",
|
|
||||||
type: "text",
|
|
||||||
categoryId: "3",
|
|
||||||
instanceId: instanceId,
|
|
||||||
position: 1,
|
|
||||||
description: "Backend and API development",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Placeholder hook for channels by instance
|
|
||||||
export const useChannels = (instanceId?: string) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["channels", instanceId],
|
|
||||||
queryFn: async (): Promise<CategoryWithChannels[]> => {
|
|
||||||
if (!instanceId) return [];
|
|
||||||
|
|
||||||
return createSampleCategories(instanceId);
|
|
||||||
},
|
|
||||||
enabled: !!instanceId,
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for getting messages in a channel
|
|
||||||
export const useChannelMessages = (channelId?: string) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["messages", channelId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!channelId) return [];
|
|
||||||
|
|
||||||
// Return messages for this channel
|
|
||||||
return SAMPLE_MESSAGES.filter((msg) => msg.channelId === channelId);
|
|
||||||
},
|
|
||||||
enabled: !!channelId,
|
|
||||||
staleTime: 1000 * 60 * 1, // 1 minute (messages are more dynamic)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Placeholder hook for servers/instances
|
|
||||||
export const useServers = () => {
|
export const useServers = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["servers"],
|
queryKey: ["servers"],
|
||||||
queryFn: async (): Promise<Instance[]> => {
|
queryFn: async (): Promise<Instance[]> => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
try {
|
||||||
return SAMPLE_SERVERS;
|
const instances = await apiClient.getInstances();
|
||||||
|
return instances;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch servers:", error);
|
||||||
|
throw new Error("Failed to fetch servers");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 500 * 1,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hook for getting detailed instance info with categories and channels
|
||||||
export const useInstanceDetails = (instanceId?: string) => {
|
export const useInstanceDetails = (instanceId?: string) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["instance", instanceId],
|
queryKey: ["instance", instanceId],
|
||||||
queryFn: async (): Promise<InstanceWithDetails | null> => {
|
queryFn: async (): Promise<InstanceWithDetails | null> => {
|
||||||
if (!instanceId) return null;
|
if (!instanceId) return null;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const server = SAMPLE_SERVERS.find((s) => s.id === instanceId);
|
try {
|
||||||
if (!server) return null;
|
// Get instance basic info
|
||||||
|
const instances = await apiClient.getInstances();
|
||||||
|
const instance = instances.find((s) => s.id === instanceId);
|
||||||
|
if (!instance) return null;
|
||||||
|
|
||||||
|
// Get categories for this instance
|
||||||
|
const categories = await apiClient.getCategoriesByInstance(instanceId);
|
||||||
|
|
||||||
|
// For each category, get its channels
|
||||||
|
const categoriesWithChannels: CategoryWithChannels[] =
|
||||||
|
await Promise.all(
|
||||||
|
categories.map(async (category): Promise<CategoryWithChannels> => {
|
||||||
|
try {
|
||||||
|
const channels = await apiClient.getChannelsByCategory(
|
||||||
|
category.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
channels: channels || [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to fetch channels for category ${category.id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
channels: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...server,
|
...instance,
|
||||||
categories: createSampleCategories(instanceId),
|
categories: categoriesWithChannels,
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch instance details:", error);
|
||||||
|
throw new Error("Failed to fetch instance details");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
enabled: !!instanceId,
|
enabled: !!instanceId,
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 500 * 1,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -339,21 +121,149 @@ export const useInstanceDetails = (instanceId?: string) => {
|
|||||||
export const useInstanceMembers = (instanceId?: string) => {
|
export const useInstanceMembers = (instanceId?: string) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["instance", instanceId, "members"],
|
queryKey: ["instance", instanceId, "members"],
|
||||||
queryFn: async (): Promise<User[]> => {
|
queryFn: async () => {
|
||||||
if (!instanceId) return [];
|
if (!instanceId) return [];
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
return SAMPLE_USERS.map((user, index) => ({
|
try {
|
||||||
...user,
|
const backendUsers = await apiClient.getUsersByInstance(instanceId);
|
||||||
roles: [
|
// Transform backend users to frontend format for compatibility
|
||||||
{
|
return backendUsers.map(transformBackendUserToFrontend);
|
||||||
instanceId: instanceId,
|
} catch (error) {
|
||||||
role: index === 0 ? "admin" : index === 1 ? "mod" : "member",
|
console.error("Failed to fetch instance members:", error);
|
||||||
},
|
throw new Error("Failed to fetch instance members");
|
||||||
],
|
}
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
enabled: !!instanceId,
|
enabled: !!instanceId,
|
||||||
staleTime: 1000 * 60 * 2,
|
staleTime: 500 * 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for creating a new server/instance
|
||||||
|
export const useCreateInstance = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, token } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: { name: string; icon?: string }) => {
|
||||||
|
if (!user || !token) {
|
||||||
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
...data,
|
||||||
|
requestingUserId: user.id,
|
||||||
|
requestingUserToken: token,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instance = await apiClient.createInstance(requestData);
|
||||||
|
return instance;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create instance:", error);
|
||||||
|
throw new Error("Failed to create instance");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate servers list to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for creating a new category
|
||||||
|
export const useCreateCategory = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, token } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
name: string;
|
||||||
|
instanceId?: string;
|
||||||
|
position: number;
|
||||||
|
}) => {
|
||||||
|
if (!user || !token) {
|
||||||
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
...data,
|
||||||
|
admin: user.admin,
|
||||||
|
requestingUserId: user.id,
|
||||||
|
requestingUserToken: token,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const category = await apiClient.createCategory(requestData);
|
||||||
|
return category;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create category:", error);
|
||||||
|
throw new Error("Failed to create category");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
// Invalidate instance details to refetch categories
|
||||||
|
if (variables.instanceId) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["instance", variables.instanceId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for creating a new channel
|
||||||
|
export const useCreateChannel = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, token } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
type: "text" | "voice";
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
categoryId?: string;
|
||||||
|
}) => {
|
||||||
|
if (!user || !token) {
|
||||||
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
...data,
|
||||||
|
admin: user.admin,
|
||||||
|
requestingUserId: user.id,
|
||||||
|
requestingUserToken: token,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await apiClient.createChannel(requestData);
|
||||||
|
return channel;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create channel:", error);
|
||||||
|
throw new Error("Failed to create channel");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
// Invalidate related queries
|
||||||
|
if (variables.categoryId) {
|
||||||
|
// Find the instance this category belongs to and invalidate it
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["instance"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Placeholder hook for channels by instance (for backward compatibility)
|
||||||
|
export const useChannels = (instanceId?: string) => {
|
||||||
|
const { data: instance } = useInstanceDetails(instanceId);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["channels", instanceId],
|
||||||
|
queryFn: async (): Promise<CategoryWithChannels[]> => {
|
||||||
|
return instance?.categories || [];
|
||||||
|
},
|
||||||
|
enabled: !!instanceId && !!instance,
|
||||||
|
staleTime: 500 * 1,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
359
concord-client/src/lib/api-client.ts
Normal file
359
concord-client/src/lib/api-client.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Base API configuration
|
||||||
|
export const API_BASE_URL =
|
||||||
|
import.meta.env.VITE_API_URL || "http://localhost:3000";
|
||||||
|
|
||||||
|
// Enhanced QueryClient with error handling
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 500 * 1, // 1 minute
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retry: (failureCount, error: any) => {
|
||||||
|
// Don't retry on auth errors
|
||||||
|
if (error?.status === 401 || error?.status === 403) return false;
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: (failureCount, error: any) => {
|
||||||
|
if (error?.status === 401 || error?.status === 403) return false;
|
||||||
|
return failureCount < 2;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Response types based on your backend
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success?: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific response types for your backend
|
||||||
|
export interface Instance {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
instanceId: string;
|
||||||
|
position: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "text" | "voice";
|
||||||
|
categoryId: string;
|
||||||
|
instanceId: string;
|
||||||
|
position: number;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendUser {
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
nickName: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
picture: string | null;
|
||||||
|
banner: string | null;
|
||||||
|
admin: boolean;
|
||||||
|
status: "online" | "offline" | "dnd" | "idle" | "invis";
|
||||||
|
role: Array<{
|
||||||
|
userId: string;
|
||||||
|
instanceId: string;
|
||||||
|
role?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
edited: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
deleted: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
replies: MessageReply;
|
||||||
|
user?: BackendUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageReply {
|
||||||
|
id: string;
|
||||||
|
repliesToId: string;
|
||||||
|
repliesToText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced fetch wrapper with auth and error handling
|
||||||
|
export class ApiClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
|
constructor(baseUrl: string = API_BASE_URL) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
|
const headers = await this.getAuthHeaders();
|
||||||
|
|
||||||
|
const config: RequestInit = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Handle auth error - logout user
|
||||||
|
const authStore = await import("@/stores/authStore");
|
||||||
|
authStore.useAuthStore.getState().logout();
|
||||||
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorData = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Unknown error" }));
|
||||||
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API request failed: ${endpoint}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance/Server methods
|
||||||
|
async getInstances(): Promise<Instance[]> {
|
||||||
|
const response = await this.request<
|
||||||
|
{ success: boolean; data: Instance[] } | Instance[]
|
||||||
|
>("/api/instance");
|
||||||
|
|
||||||
|
// Handle both wrapped and direct responses
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("data" in response && Array.isArray(response.data)) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInstance(data: {
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
requestingUserId: string;
|
||||||
|
requestingUserToken: string;
|
||||||
|
}): Promise<Instance> {
|
||||||
|
return this.request<Instance>("/api/instance", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories methods
|
||||||
|
async getCategoriesByInstance(instanceId: string): Promise<Category[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.request<Category[] | { data: Category[] }>(
|
||||||
|
`/api/category/instance/${instanceId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("data" in response && Array.isArray(response.data)) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Categories endpoint not available for instance ${instanceId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCategory(data: {
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
instanceId?: string;
|
||||||
|
admin: boolean;
|
||||||
|
requestingUserId: string;
|
||||||
|
requestingUserToken: string;
|
||||||
|
}): Promise<Category> {
|
||||||
|
return this.request<Category>("/api/category", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel methods
|
||||||
|
async getChannelsByCategory(categoryId: string): Promise<Channel[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.request<Channel[] | { data: Channel[] }>(
|
||||||
|
`/api/channel/category/${categoryId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("data" in response && Array.isArray(response.data)) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Channels endpoint not available for category ${categoryId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChannel(data: {
|
||||||
|
type: "text" | "voice";
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
categoryId?: string;
|
||||||
|
admin: boolean;
|
||||||
|
requestingUserId: string;
|
||||||
|
requestingUserToken: string;
|
||||||
|
}): Promise<Channel> {
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
return this.request<Channel>("/api/channel", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message methods
|
||||||
|
async getMessages(params: {
|
||||||
|
date: string;
|
||||||
|
channelId: string;
|
||||||
|
}): Promise<Message[]> {
|
||||||
|
const query = new URLSearchParams(params);
|
||||||
|
const response = await this.request<Message[] | { data: Message[] }>(
|
||||||
|
`/api/message?${query}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("data" in response && Array.isArray(response.data)) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(data: {
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
content: string;
|
||||||
|
token: string;
|
||||||
|
repliedMessageId?: string | null;
|
||||||
|
}): Promise<Message> {
|
||||||
|
return this.request<Message>("/api/message", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// User methods
|
||||||
|
async getUsersByInstance(instanceId: string): Promise<BackendUser[]> {
|
||||||
|
const query = new URLSearchParams({ instanceId });
|
||||||
|
const response = await this.request<
|
||||||
|
BackendUser[] | { data: BackendUser[] }
|
||||||
|
>(`/api/user?${query}`);
|
||||||
|
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("data" in response && Array.isArray(response.data)) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUser(id: string): Promise<BackendUser> {
|
||||||
|
const response = await this.request<BackendUser | { data: BackendUser }>(
|
||||||
|
`/api/user/${id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("data" in response) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response as BackendUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: {
|
||||||
|
username: string;
|
||||||
|
nickname?: string;
|
||||||
|
bio?: string;
|
||||||
|
picture?: string;
|
||||||
|
banner?: string;
|
||||||
|
status?: "online" | "offline" | "dnd" | "idle" | "invis";
|
||||||
|
admin?: boolean;
|
||||||
|
requestingUserId: string;
|
||||||
|
requestingUserToken: string;
|
||||||
|
passwordhash: string;
|
||||||
|
}): Promise<{ success: boolean; data?: BackendUser; error?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await this.request<BackendUser>("/api/user", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return { success: true, data: response };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
export const apiClient = new ApiClient();
|
||||||
175
concord-client/src/lib/auth-client.ts
Normal file
175
concord-client/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { apiClient, BackendUser } from "./api-client";
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterData {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
nickname?: string;
|
||||||
|
bio?: string;
|
||||||
|
picture?: string;
|
||||||
|
banner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: BackendUser;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
baseUrl: string = import.meta.env.VITE_API_URL || "http://localhost:3000",
|
||||||
|
) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Login failed" }));
|
||||||
|
throw new Error(errorData.error || "Login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: AuthResponse = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login failed:", error);
|
||||||
|
throw error instanceof Error ? error : new Error("Login failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(
|
||||||
|
data: RegisterData,
|
||||||
|
adminUser: { id: string; token: string },
|
||||||
|
): Promise<AuthResponse> {
|
||||||
|
try {
|
||||||
|
const createUserData = {
|
||||||
|
username: data.username,
|
||||||
|
nickname: data.nickname,
|
||||||
|
bio: data.bio,
|
||||||
|
picture: data.picture,
|
||||||
|
banner: data.banner,
|
||||||
|
status: "online" as const,
|
||||||
|
admin: false,
|
||||||
|
requestingUserId: adminUser.id,
|
||||||
|
requestingUserToken: adminUser.token,
|
||||||
|
passwordhash: data.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiClient.createUser(createUserData);
|
||||||
|
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
throw new Error(response.error || "Registration failed");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const loginResponse = await this.login({
|
||||||
|
username: data.username,
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
return loginResponse;
|
||||||
|
} catch (loginError) {
|
||||||
|
throw new Error(
|
||||||
|
"Registration successful, but auto-login failed. Please login manually.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Registration failed:", error);
|
||||||
|
throw error instanceof Error ? error : new Error("Registration failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateToken(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ valid: boolean; user?: BackendUser }> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/auth/validate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token, userId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: { valid: boolean; user?: BackendUser } =
|
||||||
|
await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token validation failed:", error);
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(oldToken: string, userId: string): Promise<AuthResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId, oldToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Token refresh failed" }));
|
||||||
|
throw new Error(errorData.error || "Token refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: AuthResponse = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token refresh failed:", error);
|
||||||
|
throw error instanceof Error ? error : new Error("Token refresh failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(userId: string): Promise<{ success: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(
|
||||||
|
"Logout endpoint failed, but continuing with local logout",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({ success: true }));
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Logout request failed:", error);
|
||||||
|
return { success: true }; // Always succeed locally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authClient = new AuthClient();
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import React from 'react'
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from "react-dom/client";
|
||||||
import App from './App.tsx'
|
import App from "./App.tsx";
|
||||||
import './index.css'
|
import { io } from "socket.io-client";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
const socket = io("http://localhost:3000");
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App socket={socket} />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
);
|
||||||
|
|
||||||
// Use contextBridge
|
|
||||||
window.ipcRenderer.on('main-process-message', (_event, message) => {
|
|
||||||
console.log(message)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,360 +1,218 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import ReactMarkdown from "react-markdown";
|
import { Hash, Volume2, Users } from "lucide-react";
|
||||||
import { Hash, Volume2, Users, Pin } from "lucide-react";
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { MessageComponent } from "@/components/message/MessageComponent";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Copy, Edit, Trash2, Reply, MoreHorizontal } from "lucide-react";
|
|
||||||
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
|
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
|
||||||
import { useChannelMessages } from "@/hooks/useChannel";
|
import { useChannelMessages, useLoadMoreMessages } from "@/hooks/useMessages";
|
||||||
import { useUiStore } from "@/stores/uiStore";
|
import { useUiStore } from "@/stores/uiStore";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { Message, User } from "@/types/database";
|
import { Message } from "@/lib/api-client";
|
||||||
import { MessageProps } from "@/components/message/Message";
|
import { MessageInput } from "@/components/message/MessageInput";
|
||||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
||||||
import {
|
|
||||||
dark,
|
|
||||||
solarizedLight,
|
|
||||||
} from "react-syntax-highlighter/dist/esm/styles/hljs";
|
|
||||||
import { useTheme } from "@/components/theme-provider";
|
|
||||||
|
|
||||||
const MessageComponent: React.FC<MessageProps> = ({
|
|
||||||
message,
|
|
||||||
user,
|
|
||||||
currentUser,
|
|
||||||
replyTo,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onReply,
|
|
||||||
isGrouped,
|
|
||||||
}) => {
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
|
||||||
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOwnMessage = currentUser?.id === message.userId;
|
|
||||||
const { mode } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`group relative px-4 hover:bg-concord-secondary/50 transition-colors ${
|
|
||||||
isGrouped ? "mt-0 py-0" : "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 className="h-10 w-10">
|
|
||||||
<AvatarImage
|
|
||||||
src={user.picture || undefined}
|
|
||||||
alt={user.username}
|
|
||||||
/>
|
|
||||||
<AvatarFallback className="text-sm bg-primary text-primary-foreground">
|
|
||||||
{user.username.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Message content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{/* Reply line and reference */}
|
|
||||||
{replyTo && (
|
|
||||||
<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">
|
|
||||||
{replyTo?.user?.nickname || replyTo?.user?.username}
|
|
||||||
</span>
|
|
||||||
<span className="truncate max-w-xs opacity-75">
|
|
||||||
{replyTo.content.replace(/```[\s\S]*?```/g, "[code]")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 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 with markdown */}
|
|
||||||
<div className="text-concord-primary leading-relaxed prose prose-sm max-w-none dark:prose-invert">
|
|
||||||
<ReactMarkdown
|
|
||||||
components={{
|
|
||||||
code: ({ node, className, children, ...props }) => {
|
|
||||||
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.content}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<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 | null;
|
|
||||||
onCancelReply?: () => void;
|
|
||||||
replyingToUser: User | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MessageInput: React.FC<MessageInputProps> = ({
|
|
||||||
channelName,
|
|
||||||
onSendMessage,
|
|
||||||
replyingTo,
|
|
||||||
onCancelReply,
|
|
||||||
replyingToUser,
|
|
||||||
}) => {
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
|
|
||||||
// Auto-resize textarea
|
|
||||||
useEffect(() => {
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.style.height = "auto";
|
|
||||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
|
||||||
}
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
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();
|
|
||||||
formRef.current?.requestSubmit(); // <-- Programmatically submit form
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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.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.content.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"}`}
|
|
||||||
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"
|
|
||||||
style={{
|
|
||||||
minHeight: "44px",
|
|
||||||
maxHeight: "200px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
|
|
||||||
Press Enter to send • Shift+Enter for new line
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ChatPage: React.FC = () => {
|
const ChatPage: React.FC = () => {
|
||||||
const { instanceId, channelId } = useParams();
|
const { instanceId, channelId } = useParams();
|
||||||
const { data: instance } = useInstanceDetails(instanceId);
|
|
||||||
const categories = instance?.categories;
|
|
||||||
const { data: channelMessages } = useChannelMessages(channelId);
|
|
||||||
const { toggleMemberList, showMemberList } = useUiStore();
|
|
||||||
const { user: currentUser } = useAuthStore();
|
|
||||||
const { data: users } = useInstanceMembers(instanceId);
|
|
||||||
|
|
||||||
// State for messages and interactions
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Use sample current user if none exists
|
const {
|
||||||
const displayCurrentUser =
|
data: instance,
|
||||||
currentUser || users?.find((u) => u.id === "current");
|
isLoading: instanceLoading,
|
||||||
|
error: instanceError,
|
||||||
|
} = useInstanceDetails(instanceId);
|
||||||
|
const {
|
||||||
|
data: channelMessages,
|
||||||
|
isLoading: messagesLoading,
|
||||||
|
error: messagesError,
|
||||||
|
} = useChannelMessages(channelId);
|
||||||
|
const { data: users, isLoading: usersLoading } =
|
||||||
|
useInstanceMembers(instanceId);
|
||||||
|
|
||||||
// Find current channel
|
// UI state hooks - called unconditionally
|
||||||
const currentChannel = categories
|
const { toggleMemberList, showMemberList } = useUiStore();
|
||||||
|
const { user: currentUser } = useAuthStore();
|
||||||
|
|
||||||
|
// Local state hooks - called unconditionally
|
||||||
|
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Memoized values - called unconditionally
|
||||||
|
const categories = instance?.categories;
|
||||||
|
|
||||||
|
const currentChannel = React.useMemo(() => {
|
||||||
|
return categories
|
||||||
?.flatMap((cat) => cat.channels)
|
?.flatMap((cat) => cat.channels)
|
||||||
?.find((ch) => ch.id === channelId);
|
?.find((ch) => ch.id === channelId);
|
||||||
|
}, [categories, channelId]);
|
||||||
|
|
||||||
// Update messages when channel messages load
|
const userHasAccess = React.useMemo(() => {
|
||||||
useEffect(() => {
|
if (!currentUser || !instanceId) return false;
|
||||||
if (channelMessages) {
|
if (currentUser.admin) return true;
|
||||||
setMessages(channelMessages.map((msg) => ({ ...msg, replyToId: null })));
|
return currentUser.roles.some((role) => role.instanceId === instanceId);
|
||||||
}
|
}, [currentUser, instanceId]);
|
||||||
|
|
||||||
|
const sortedMessages = React.useMemo(() => {
|
||||||
|
if (!channelMessages) return [];
|
||||||
|
|
||||||
|
// Sort messages by createdAt timestamp (oldest first, newest last)
|
||||||
|
return [...channelMessages].sort((a, b) => {
|
||||||
|
const dateA = new Date(a.createdAt).getTime();
|
||||||
|
const dateB = new Date(b.createdAt).getTime();
|
||||||
|
return dateA - dateB; // ascending order (oldest to newest)
|
||||||
|
});
|
||||||
}, [channelMessages]);
|
}, [channelMessages]);
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
// Effects - called unconditionally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Scroll to bottom when messages load or update
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages]);
|
}, [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)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const oldestMessage = channelMessages[0];
|
||||||
|
await loadMoreMessagesMutation.mutateAsync({
|
||||||
|
beforeDate: new Date(oldestMessage.createdAt),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load more messages:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [channelMessages, isLoadingMore, loadMoreMessagesMutation]);
|
||||||
|
|
||||||
|
const handleReply = React.useCallback(
|
||||||
|
(messageId: string) => {
|
||||||
|
const message = channelMessages?.find((m) => m.id === messageId);
|
||||||
|
if (message) {
|
||||||
|
setReplyingTo(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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 (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-concord-primary">
|
||||||
|
<div className="text-center text-concord-secondary">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
|
<p>Loading chat...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle errors and permissions
|
||||||
|
if (!userHasAccess) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-concord-primary">
|
||||||
|
<div className="text-center text-concord-secondary">
|
||||||
|
<h2 className="text-xl font-semibold mb-2 text-destructive">
|
||||||
|
Access Denied
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4">You don't have permission to view this server.</p>
|
||||||
|
<Button onClick={() => navigate("/")}>Go Home</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instanceError || messagesError) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-concord-primary">
|
||||||
|
<div className="text-center text-concord-secondary">
|
||||||
|
<h2 className="text-xl font-semibold mb-2 text-destructive">
|
||||||
|
Error Loading Chat
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4">
|
||||||
|
{instanceError?.message ||
|
||||||
|
messagesError?.message ||
|
||||||
|
"Something went wrong"}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Require both instanceId and channelId for chat
|
// Require both instanceId and channelId for chat
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
@@ -362,20 +220,20 @@ const ChatPage: React.FC = () => {
|
|||||||
<div className="flex-1 flex items-center justify-center bg-concord-primary">
|
<div className="flex-1 flex items-center justify-center bg-concord-primary">
|
||||||
<div className="text-center text-concord-secondary">
|
<div className="text-center text-concord-secondary">
|
||||||
<h2 className="text-xl font-semibold mb-2 text-concord-primary">
|
<h2 className="text-xl font-semibold mb-2 text-concord-primary">
|
||||||
No Channel Selected
|
No Server Selected
|
||||||
</h2>
|
</h2>
|
||||||
<p>Select a channel from the sidebar to start chatting.</p>
|
<p>Select a server from the sidebar to start chatting.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (!channelId || !currentChannel) {
|
} else if (!channelId || !currentChannel) {
|
||||||
const existingChannelId = categories
|
const existingChannelId = categories?.flatMap((cat) => cat.channels)?.[0]
|
||||||
?.flatMap((cat) => cat.channels)
|
?.id; // Get the first channel from the flattened list
|
||||||
?.find((channel) => channel.position === 0)?.id;
|
|
||||||
|
|
||||||
if (existingChannelId)
|
if (existingChannelId) {
|
||||||
navigate(`/channels/${instanceId}/${existingChannelId}`);
|
navigate(`/channels/${instanceId}/${existingChannelId}`);
|
||||||
else
|
return null;
|
||||||
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center bg-concord-primary">
|
<div className="flex-1 flex items-center justify-center bg-concord-primary">
|
||||||
<div className="text-center text-concord-secondary">
|
<div className="text-center text-concord-secondary">
|
||||||
@@ -387,66 +245,17 @@ const ChatPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
|
|
||||||
|
|
||||||
// Message handlers
|
|
||||||
const handleSendMessage = (content: string) => {
|
|
||||||
if (!displayCurrentUser) return;
|
|
||||||
|
|
||||||
const newMessage: Message = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
content,
|
|
||||||
channelId: channelId || "",
|
|
||||||
userId: displayCurrentUser.id,
|
|
||||||
edited: false,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
replyTo: replyingTo || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, newMessage]);
|
|
||||||
setReplyingTo(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReply = (messageId: string) => {
|
|
||||||
const message = messages.find((m) => m.id === messageId);
|
|
||||||
if (message) {
|
|
||||||
setReplyingTo(message);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (messageId: string) => {
|
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
|
||||||
// TODO: Implement edit functionality
|
|
||||||
console.log("Edit message:", messageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (messageId: string) => {
|
|
||||||
setMessages((prev) => prev.filter((m) => m.id !== messageId));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group messages by user and time
|
|
||||||
const groupedMessages = messages.reduce((acc, message, index) => {
|
|
||||||
const prevMessage = index > 0 ? messages[index - 1] : null;
|
|
||||||
const isGrouped =
|
|
||||||
prevMessage &&
|
|
||||||
prevMessage.userId === message.userId &&
|
|
||||||
!message.replyTo && // Don't group replies
|
|
||||||
!prevMessage.replyTo && // Don't group if previous was a reply
|
|
||||||
new Date(message.createdAt).getTime() -
|
|
||||||
new Date(prevMessage.createdAt).getTime() <
|
|
||||||
5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
acc.push({ ...message, isGrouped });
|
|
||||||
return acc;
|
|
||||||
}, [] as Message[]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
|
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
|
||||||
{/* Channel Header */}
|
{/* 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">
|
<div className="flex items-center space-x-2">
|
||||||
<ChannelIcon size={20} className="text-concord-secondary" />
|
<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}
|
{currentChannel?.name}
|
||||||
</span>
|
</span>
|
||||||
{currentChannel?.description && (
|
{currentChannel?.description && (
|
||||||
@@ -460,13 +269,6 @@ const ChatPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 interactive-hover"
|
|
||||||
>
|
|
||||||
<Pin size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -485,9 +287,13 @@ const ChatPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Content */}
|
{/* Chat Content */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
|
{/* Attach ref to the actual scrollable content */}
|
||||||
|
<div ref={scrollAreaRef} className="h-full overflow-y-auto">
|
||||||
|
<div ref={messagesStartRef} />
|
||||||
|
|
||||||
{/* Welcome Message */}
|
{/* Welcome Message */}
|
||||||
<div className="px-4 py-6 border-b border-concord/50 flex-shrink-0">
|
<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="flex items-center space-x-3 mb-3">
|
||||||
@@ -500,37 +306,31 @@ const ChatPage: React.FC = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="pb-4">
|
<div className="pb-4">
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{groupedMessages.length > 0 ? (
|
{sortedMessages && sortedMessages.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
{groupedMessages.map((message) => {
|
{sortedMessages.map((message) => {
|
||||||
const user = users?.find((u) => u.id === message.userId);
|
const user = users?.find((u) => u.id === message.userId);
|
||||||
const replyToMessage = messages.find(
|
const replyToMessage = channelMessages?.find(
|
||||||
(m) => m.id === message.replyTo?.id,
|
(m) => m.id === message.replies?.repliesToId,
|
||||||
);
|
);
|
||||||
const replyToUser = replyToMessage?.user;
|
const replyToUser = replyToMessage
|
||||||
|
? users?.find((u) => u.id === replyToMessage.userId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageComponent
|
<MessageComponent
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
user={user}
|
user={user}
|
||||||
currentUser={displayCurrentUser}
|
|
||||||
replyTo={replyToMessage}
|
replyTo={replyToMessage}
|
||||||
onEdit={handleEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
replyToUser={replyToUser}
|
replyToUser={replyToUser}
|
||||||
isGrouped={message.isGrouped}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -545,13 +345,15 @@ const ChatPage: React.FC = () => {
|
|||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Message Input */}
|
{/* Message Input */}
|
||||||
|
{currentUser && (
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
|
channelId={channelId}
|
||||||
channelName={currentChannel?.name}
|
channelName={currentChannel?.name}
|
||||||
onSendMessage={handleSendMessage}
|
|
||||||
replyingTo={replyingTo}
|
replyingTo={replyingTo}
|
||||||
onCancelReply={() => setReplyingTo(null)}
|
onCancelReply={() => setReplyingTo(null)}
|
||||||
replyingToUser={
|
replyingToUser={
|
||||||
@@ -561,6 +363,7 @@ const ChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,50 +10,31 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { useLogin } from "@/hooks/useAuth";
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const { isAuthenticated, setAuth } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
// Use the real login hook
|
||||||
|
const { mutate: login, isPending, error } = useLogin();
|
||||||
|
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return <Navigate to="/channels/@me" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
if (!username.trim() || !password.trim()) {
|
||||||
// TODO: Replace with actual login API call
|
return;
|
||||||
setTimeout(() => {
|
|
||||||
setAuth(
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
username,
|
|
||||||
nickname: username,
|
|
||||||
bio: "Test user",
|
|
||||||
picture: "",
|
|
||||||
banner: "",
|
|
||||||
hashPassword: "",
|
|
||||||
admin: false,
|
|
||||||
status: "online",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
"fake-token",
|
|
||||||
"fake-refresh-token",
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Login failed:", error);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
login({ username: username.trim(), password });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +50,16 @@ const LoginPage: React.FC = () => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Login failed. Please try again."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username" className="text-concord-primary">
|
<Label htmlFor="username" className="text-concord-primary">
|
||||||
Username
|
Username
|
||||||
@@ -80,9 +71,11 @@ const LoginPage: React.FC = () => {
|
|||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="bg-concord-tertiary border-concord text-concord-primary"
|
className="bg-concord-tertiary border-concord text-concord-primary"
|
||||||
placeholder="Enter your username"
|
placeholder="Enter your username"
|
||||||
|
disabled={isPending}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-concord-primary">
|
<Label htmlFor="password" className="text-concord-primary">
|
||||||
Password
|
Password
|
||||||
@@ -94,11 +87,17 @@ const LoginPage: React.FC = () => {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="bg-concord-tertiary border-concord text-concord-primary"
|
className="bg-concord-tertiary border-concord text-concord-primary"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
|
disabled={isPending}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading ? "Logging in..." : "Log In"}
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isPending || !username.trim() || !password.trim()}
|
||||||
|
>
|
||||||
|
{isPending ? "Logging in..." : "Log In"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const NotFoundPage: React.FC = () => {
|
|||||||
<h1 className="text-4xl font-bold mb-4 text-concord-primary">404</h1>
|
<h1 className="text-4xl font-bold mb-4 text-concord-primary">404</h1>
|
||||||
<p className="text-xl mb-6">Page not found</p>
|
<p className="text-xl mb-6">Page not found</p>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<a href="/channels/@me">Go Home</a>
|
<a href="/">Go Home</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
Palette,
|
Palette,
|
||||||
User,
|
User,
|
||||||
Shield,
|
|
||||||
Mic,
|
Mic,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Eye,
|
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
Monitor,
|
Monitor,
|
||||||
@@ -15,7 +13,6 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +22,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { ThemeSelector } from "@/components/theme-selector";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
@@ -58,6 +55,140 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const AccountSettings: React.FC = () => {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const [username, setUsername] = useState(user?.username || "");
|
||||||
|
const [nickname, setNickname] = useState(user?.nickname || "");
|
||||||
|
const [bio, setBio] = useState(user?.bio || "");
|
||||||
|
const [isChanged, setIsChanged] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState("");
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaveError("");
|
||||||
|
setSaveSuccess("");
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement actual profile update API call
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
// updateUser({
|
||||||
|
// username: username.trim(),
|
||||||
|
// nickname: nickname.trim() || null,
|
||||||
|
// bio: bio.trim() || null,
|
||||||
|
// });
|
||||||
|
|
||||||
|
setSaveSuccess("Profile updated successfully");
|
||||||
|
setIsChanged(false);
|
||||||
|
} catch (error) {
|
||||||
|
setSaveError("Failed to update profile. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
setIsChanged(true);
|
||||||
|
setSaveError("");
|
||||||
|
setSaveSuccess("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
Profile
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update your profile information and display settings.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{saveError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{saveError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveSuccess && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>{saveSuccess}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUsername(e.target.value);
|
||||||
|
handleChange();
|
||||||
|
}}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="nickname">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="nickname"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNickname(e.target.value);
|
||||||
|
handleChange();
|
||||||
|
}}
|
||||||
|
className="max-w-sm"
|
||||||
|
placeholder="How others see your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="bio">Bio</Label>
|
||||||
|
<Input
|
||||||
|
id="bio"
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBio(e.target.value);
|
||||||
|
handleChange();
|
||||||
|
}}
|
||||||
|
className="max-w-sm"
|
||||||
|
placeholder="Tell others about yourself"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<Button onClick={handleSave} disabled={!isChanged || isSaving}>
|
||||||
|
{isSaving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
{isChanged && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setUsername(user?.username || "");
|
||||||
|
setNickname(user?.nickname || "");
|
||||||
|
setBio(user?.bio || "");
|
||||||
|
setIsChanged(false);
|
||||||
|
setSaveError("");
|
||||||
|
setSaveSuccess("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AppearanceSettings: React.FC = () => {
|
const AppearanceSettings: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
currentLightTheme,
|
currentLightTheme,
|
||||||
@@ -67,12 +198,6 @@ const AppearanceSettings: React.FC = () => {
|
|||||||
setMode,
|
setMode,
|
||||||
setTheme,
|
setTheme,
|
||||||
} = useTheme();
|
} = useTheme();
|
||||||
const [compactMode, setCompactMode] = useState(false);
|
|
||||||
const [showTimestamps, setShowTimestamps] = useState(true);
|
|
||||||
const [animationsEnabled, setAnimationsEnabled] = useState(true);
|
|
||||||
const [reduceMotion, setReduceMotion] = useState(false);
|
|
||||||
const [highContrast, setHighContrast] = useState(false);
|
|
||||||
|
|
||||||
const lightThemes = getThemesForMode("light");
|
const lightThemes = getThemesForMode("light");
|
||||||
const darkThemes = getThemesForMode("dark");
|
const darkThemes = getThemesForMode("dark");
|
||||||
|
|
||||||
@@ -175,18 +300,6 @@ const AppearanceSettings: React.FC = () => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<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 */}
|
{/* Current Theme Display */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
@@ -339,246 +452,6 @@ const AppearanceSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Display Settings */}
|
|
||||||
<Card className="w-full p-6">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Eye className="h-5 w-5" />
|
|
||||||
Display Settings
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Customize how content is displayed in the app.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-base font-medium">Compact Mode</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Use less space between messages and interface elements
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch checked={compactMode} onCheckedChange={setCompactMode} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-base font-medium">
|
|
||||||
Show Message Timestamps
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Display timestamps next to messages
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={showTimestamps}
|
|
||||||
onCheckedChange={setShowTimestamps}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-base font-medium">Enable Animations</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Play animations throughout the interface
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={animationsEnabled}
|
|
||||||
onCheckedChange={setAnimationsEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Accessibility */}
|
|
||||||
<Card className="w-full p-6">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Accessibility</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Settings to improve accessibility and usability.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-base font-medium">Reduce Motion</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Reduce motion and animations for users with vestibular disorders
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch checked={reduceMotion} onCheckedChange={setReduceMotion} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-base font-medium">High Contrast</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Increase contrast for better visibility
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch checked={highContrast} onCheckedChange={setHighContrast} />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AccountSettings: React.FC = () => {
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
const [username, setUsername] = useState(user?.username || "");
|
|
||||||
const [nickname, setNickname] = useState(user?.nickname || "");
|
|
||||||
const [bio, setBio] = useState(user?.bio || "");
|
|
||||||
const [email, setEmail] = useState("user@example.com");
|
|
||||||
const [isChanged, setIsChanged] = useState(false);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
console.log("Saving profile changes:", { username, nickname, bio, email });
|
|
||||||
setIsChanged(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = () => {
|
|
||||||
setIsChanged(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
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">
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
Profile
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Update your profile information and display settings.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEmail(e.target.value);
|
|
||||||
handleChange();
|
|
||||||
}}
|
|
||||||
className="max-w-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="username">Username</Label>
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => {
|
|
||||||
setUsername(e.target.value);
|
|
||||||
handleChange();
|
|
||||||
}}
|
|
||||||
className="max-w-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="nickname">Display Name</Label>
|
|
||||||
<Input
|
|
||||||
id="nickname"
|
|
||||||
value={nickname}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNickname(e.target.value);
|
|
||||||
handleChange();
|
|
||||||
}}
|
|
||||||
className="max-w-sm"
|
|
||||||
placeholder="How others see your name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="bio">Bio</Label>
|
|
||||||
<Input
|
|
||||||
id="bio"
|
|
||||||
value={bio}
|
|
||||||
onChange={(e) => {
|
|
||||||
setBio(e.target.value);
|
|
||||||
handleChange();
|
|
||||||
}}
|
|
||||||
className="max-w-sm"
|
|
||||||
placeholder="Tell others about yourself"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex gap-2">
|
|
||||||
<Button onClick={handleSave} disabled={!isChanged}>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
{isChanged && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setUsername(user?.username || "");
|
|
||||||
setNickname(user?.nickname || "");
|
|
||||||
setBio(user?.bio || "");
|
|
||||||
setEmail("user@example.com");
|
|
||||||
setIsChanged(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -586,10 +459,6 @@ const AccountSettings: React.FC = () => {
|
|||||||
const VoiceSettings: React.FC = () => {
|
const VoiceSettings: React.FC = () => {
|
||||||
const [inputVolume, setInputVolume] = useState(75);
|
const [inputVolume, setInputVolume] = useState(75);
|
||||||
const [outputVolume, setOutputVolume] = useState(100);
|
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 (
|
return (
|
||||||
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-full">
|
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-full">
|
||||||
@@ -629,69 +498,6 @@ const VoiceSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -700,7 +506,8 @@ const VoiceSettings: React.FC = () => {
|
|||||||
|
|
||||||
const SettingsPage: React.FC = () => {
|
const SettingsPage: React.FC = () => {
|
||||||
const { section } = useParams();
|
const { section } = useParams();
|
||||||
const currentSection = section || "appearance";
|
const currentSection = section || "account";
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const renderSettingsContent = () => {
|
const renderSettingsContent = () => {
|
||||||
switch (currentSection) {
|
switch (currentSection) {
|
||||||
@@ -711,7 +518,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
case "voice":
|
case "voice":
|
||||||
return <VoiceSettings />;
|
return <VoiceSettings />;
|
||||||
default:
|
default:
|
||||||
return <AppearanceSettings />;
|
return <AccountSettings />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -735,10 +542,11 @@ const SettingsPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
key={settingsSection.id}
|
key={settingsSection.id}
|
||||||
variant={isActive ? "secondary" : "ghost"}
|
variant={isActive ? "secondary" : "ghost"}
|
||||||
|
onClick={() => navigate(`/settings/${settingsSection.id}`)}
|
||||||
className="w-full justify-start mb-1 h-auto p-2"
|
className="w-full justify-start mb-1 h-auto p-2"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<a href={`/settings/${settingsSection.id}`}>
|
<div>
|
||||||
<Icon className="mr-2 h-4 w-4" />
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
<div className="flex-1 text-left">
|
<div className="flex-1 text-left">
|
||||||
<div className="font-medium">{settingsSection.title}</div>
|
<div className="font-medium">{settingsSection.title}</div>
|
||||||
@@ -749,7 +557,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</a>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -777,7 +585,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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>
|
<div className="p-6 flex w-full">{renderSettingsContent()}</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
328
concord-client/src/stores/voiceStore.ts
Normal file
328
concord-client/src/stores/voiceStore.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
// --- TYPE DEFINITIONS ---
|
||||||
|
|
||||||
|
interface IceServerConfig {
|
||||||
|
urls: string | string[];
|
||||||
|
username?: string;
|
||||||
|
credential?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state managed by the store
|
||||||
|
interface VoiceState {
|
||||||
|
socket: Socket | null;
|
||||||
|
localStream: MediaStream | null;
|
||||||
|
remoteStreams: Map<string, MediaStream>;
|
||||||
|
peerConnections: Map<string, RTCPeerConnection>;
|
||||||
|
iceServers: IceServerConfig[];
|
||||||
|
isConnected: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
|
activeVoiceChannelId: string | null;
|
||||||
|
isDeafened: boolean;
|
||||||
|
isMuted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions that can be performed on the store
|
||||||
|
interface VoiceActions {
|
||||||
|
init: (socket: Socket) => void;
|
||||||
|
joinChannel: (
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
token: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
leaveChannel: () => void;
|
||||||
|
cleanup: () => void;
|
||||||
|
toggleMute: () => void;
|
||||||
|
toggleDeafen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ZUSTAND STORE IMPLEMENTATION ---
|
||||||
|
|
||||||
|
export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||||
|
// --- INTERNAL HELPERS (not exposed in the store's public interface) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely closes and removes a single peer connection.
|
||||||
|
* @param userId The ID of the user whose connection to clean up.
|
||||||
|
*/
|
||||||
|
const cleanupPeerConnection = (userId: string) => {
|
||||||
|
const { peerConnections } = get();
|
||||||
|
const peerConnection = peerConnections.get(userId);
|
||||||
|
|
||||||
|
if (peerConnection) {
|
||||||
|
peerConnection.close();
|
||||||
|
peerConnections.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const newStreams = new Map(state.remoteStreams);
|
||||||
|
newStreams.delete(userId);
|
||||||
|
return {
|
||||||
|
remoteStreams: newStreams,
|
||||||
|
peerConnections: new Map(peerConnections),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new RTCPeerConnection for a target user and configures it.
|
||||||
|
* @param targetUserId The user to connect to.
|
||||||
|
* @returns The configured RTCPeerConnection instance.
|
||||||
|
*/
|
||||||
|
const createPeerConnection = (targetUserId: string): RTCPeerConnection => {
|
||||||
|
const { iceServers, localStream, socket, peerConnections } = get();
|
||||||
|
|
||||||
|
const peerConnection = new RTCPeerConnection({ iceServers });
|
||||||
|
|
||||||
|
// Add local stream tracks to the new connection
|
||||||
|
if (localStream) {
|
||||||
|
localStream
|
||||||
|
.getTracks()
|
||||||
|
.forEach((track) => peerConnection.addTrack(track, localStream));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ICE candidates
|
||||||
|
peerConnection.onicecandidate = (event) => {
|
||||||
|
if (event.candidate && socket) {
|
||||||
|
socket.emit("webrtc-ice-candidate", {
|
||||||
|
targetUserId,
|
||||||
|
candidate: event.candidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle incoming remote tracks
|
||||||
|
peerConnection.ontrack = (event) => {
|
||||||
|
set((state) => {
|
||||||
|
const newStreams = new Map(state.remoteStreams);
|
||||||
|
newStreams.set(targetUserId, event.streams[0]);
|
||||||
|
return { remoteStreams: newStreams };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// For debugging connection state
|
||||||
|
peerConnection.onconnectionstatechange = () => {
|
||||||
|
if (
|
||||||
|
peerConnection.connectionState === "disconnected" ||
|
||||||
|
peerConnection.connectionState === "failed"
|
||||||
|
) {
|
||||||
|
cleanupPeerConnection(targetUserId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
peerConnections.set(targetUserId, peerConnection);
|
||||||
|
set({ peerConnections: new Map(peerConnections) });
|
||||||
|
return peerConnection;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- SOCKET EVENT HANDLERS ---
|
||||||
|
// These are defined once and can be reused by the join/leave actions.
|
||||||
|
|
||||||
|
const onJoinedVoiceChannel = async (data: {
|
||||||
|
connectedUserIds: string[];
|
||||||
|
iceServers: IceServerConfig[];
|
||||||
|
}) => {
|
||||||
|
set({
|
||||||
|
iceServers: data.iceServers,
|
||||||
|
isConnecting: false,
|
||||||
|
isConnected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const userId of data.connectedUserIds) {
|
||||||
|
const peerConnection = createPeerConnection(userId);
|
||||||
|
const offer = await peerConnection.createOffer();
|
||||||
|
await peerConnection.setLocalDescription(offer);
|
||||||
|
get().socket?.emit("webrtc-offer", {
|
||||||
|
targetUserId: userId,
|
||||||
|
sdp: peerConnection.localDescription,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserLeft = (data: { userId: string }) => {
|
||||||
|
cleanupPeerConnection(data.userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWebRTCOffer = async (data: {
|
||||||
|
senderUserId: string;
|
||||||
|
sdp: RTCSessionDescriptionInit;
|
||||||
|
}) => {
|
||||||
|
const peerConnection = createPeerConnection(data.senderUserId);
|
||||||
|
await peerConnection.setRemoteDescription(
|
||||||
|
new RTCSessionDescription(data.sdp),
|
||||||
|
);
|
||||||
|
const answer = await peerConnection.createAnswer();
|
||||||
|
await peerConnection.setLocalDescription(answer);
|
||||||
|
get().socket?.emit("webrtc-answer", {
|
||||||
|
targetUserId: data.senderUserId,
|
||||||
|
sdp: peerConnection.localDescription,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWebRTCAnswer = async (data: {
|
||||||
|
senderUserId: string;
|
||||||
|
sdp: RTCSessionDescriptionInit;
|
||||||
|
}) => {
|
||||||
|
const peerConnection = get().peerConnections.get(data.senderUserId);
|
||||||
|
if (peerConnection) {
|
||||||
|
await peerConnection.setRemoteDescription(
|
||||||
|
new RTCSessionDescription(data.sdp),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onICECandidate = async (data: {
|
||||||
|
senderUserId: string;
|
||||||
|
candidate: RTCIceCandidateInit;
|
||||||
|
}) => {
|
||||||
|
const peerConnection = get().peerConnections.get(data.senderUserId);
|
||||||
|
if (peerConnection && data.candidate) {
|
||||||
|
try {
|
||||||
|
await peerConnection.addIceCandidate(
|
||||||
|
new RTCIceCandidate(data.candidate),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error adding received ICE candidate", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (error: { message: string }) => {
|
||||||
|
console.error("Voice channel error:", error.message);
|
||||||
|
get().leaveChannel(); // Disconnect on error
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- STORE DEFINITION (STATE & ACTIONS) ---
|
||||||
|
return {
|
||||||
|
// Initial State
|
||||||
|
socket: null,
|
||||||
|
localStream: null,
|
||||||
|
remoteStreams: new Map(),
|
||||||
|
peerConnections: new Map(),
|
||||||
|
iceServers: [],
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
activeVoiceChannelId: null,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
init: (socketInstance) => {
|
||||||
|
set({ socket: socketInstance });
|
||||||
|
},
|
||||||
|
|
||||||
|
joinChannel: async (channelId: string, userId: string, token: string) => {
|
||||||
|
const { socket, activeVoiceChannelId, leaveChannel, isConnecting } =
|
||||||
|
get();
|
||||||
|
if (!socket || isConnecting || activeVoiceChannelId === channelId) return;
|
||||||
|
if (!userId || !token) {
|
||||||
|
console.error("Join channel requires user and token.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeVoiceChannelId) {
|
||||||
|
leaveChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isConnecting: true, activeVoiceChannelId: channelId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: true,
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
set({ localStream: stream });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Could not get user media:", error);
|
||||||
|
set({ isConnecting: false, activeVoiceChannelId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach all necessary listeners for a voice session
|
||||||
|
socket.on("joined-voicechannel", onJoinedVoiceChannel);
|
||||||
|
socket.on("user-left-voicechannel", onUserLeft);
|
||||||
|
socket.on("webrtc-offer", onWebRTCOffer);
|
||||||
|
socket.on("webrtc-answer", onWebRTCAnswer);
|
||||||
|
socket.on("webrtc-ice-candidate", onICECandidate);
|
||||||
|
socket.on("error-voicechannel", onError);
|
||||||
|
|
||||||
|
// *** THE FIX: Send user credentials with the join request ***
|
||||||
|
socket.emit("join-voicechannel", {
|
||||||
|
userId: userId,
|
||||||
|
userToken: token,
|
||||||
|
voiceChannelId: channelId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
leaveChannel: () => {
|
||||||
|
const { socket, peerConnections, localStream, activeVoiceChannelId } =
|
||||||
|
get();
|
||||||
|
if (!socket || !activeVoiceChannelId) return;
|
||||||
|
|
||||||
|
socket.emit("leave-voicechannel", { channelId: activeVoiceChannelId });
|
||||||
|
|
||||||
|
// Clean up all event listeners
|
||||||
|
socket.off("joined-voicechannel");
|
||||||
|
socket.off("user-left-voicechannel");
|
||||||
|
socket.off("webrtc-offer");
|
||||||
|
socket.off("webrtc-answer");
|
||||||
|
socket.off("webrtc-ice-candidate");
|
||||||
|
socket.off("error-voicechannel");
|
||||||
|
|
||||||
|
// Close all peer connections
|
||||||
|
peerConnections.forEach((pc) => pc.close());
|
||||||
|
|
||||||
|
// Stop local media tracks
|
||||||
|
localStream?.getTracks().forEach((track) => track.stop());
|
||||||
|
|
||||||
|
// Reset state to initial values
|
||||||
|
set({
|
||||||
|
localStream: null,
|
||||||
|
remoteStreams: new Map(),
|
||||||
|
peerConnections: new Map(),
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
activeVoiceChannelId: null,
|
||||||
|
iceServers: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMute: () => {
|
||||||
|
set((state) => {
|
||||||
|
const newMutedState = !state.isMuted;
|
||||||
|
if (state.localStream) {
|
||||||
|
state.localStream.getAudioTracks().forEach((track) => {
|
||||||
|
track.enabled = !newMutedState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Cannot be deafened and unmuted
|
||||||
|
if (state.isDeafened && !newMutedState) {
|
||||||
|
return { isMuted: newMutedState, isDeafened: false };
|
||||||
|
}
|
||||||
|
return { isMuted: newMutedState };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleDeafen: () => {
|
||||||
|
set((state) => {
|
||||||
|
const newDeafenedState = !state.isDeafened;
|
||||||
|
// When deafening, you are also muted
|
||||||
|
if (newDeafenedState && !state.isMuted) {
|
||||||
|
// Manually mute logic without toggling deafen state again
|
||||||
|
if (state.localStream) {
|
||||||
|
state.localStream.getAudioTracks().forEach((track) => {
|
||||||
|
track.enabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { isDeafened: newDeafenedState, isMuted: true };
|
||||||
|
}
|
||||||
|
return { isDeafened: newDeafenedState };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanup: () => {
|
||||||
|
get().leaveChannel();
|
||||||
|
set({ socket: null });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -28,14 +28,15 @@ export interface Channel {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserStatus = "online" | "away" | "busy" | "offline";
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
nickname?: string;
|
nickname?: string | null;
|
||||||
bio?: string;
|
bio?: string | null;
|
||||||
picture?: string | null;
|
picture?: string | null;
|
||||||
banner?: string | null;
|
banner?: string | null;
|
||||||
hashPassword: string; // Won't be sent to client
|
hashPassword: string;
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
status: "online" | "away" | "busy" | "offline";
|
status: "online" | "away" | "busy" | "offline";
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
38
concord-client/src/types/index.ts
Normal file
38
concord-client/src/types/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// API types
|
||||||
|
export type {
|
||||||
|
ApiResponse,
|
||||||
|
Instance,
|
||||||
|
Category,
|
||||||
|
Channel,
|
||||||
|
BackendUser,
|
||||||
|
Message,
|
||||||
|
} from "@/lib/api-client";
|
||||||
|
|
||||||
|
// Auth types
|
||||||
|
export type {
|
||||||
|
LoginCredentials,
|
||||||
|
RegisterData,
|
||||||
|
AuthResponse,
|
||||||
|
} from "@/lib/auth-client";
|
||||||
|
|
||||||
|
// Hook types
|
||||||
|
export type { CategoryWithChannels, InstanceWithDetails } from "@/types/api";
|
||||||
|
|
||||||
|
// Frontend User type (for compatibility with existing components)
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
picture?: string | null;
|
||||||
|
banner?: string | null;
|
||||||
|
hashPassword: string;
|
||||||
|
admin: boolean;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
roles: Array<{
|
||||||
|
instanceId: string;
|
||||||
|
role: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
198
concord-client/src/utils/permissions.ts
Normal file
198
concord-client/src/utils/permissions.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { User } from "@/types/database";
|
||||||
|
|
||||||
|
export type UserPermission =
|
||||||
|
| "view_instance"
|
||||||
|
| "create_channel"
|
||||||
|
| "delete_channel"
|
||||||
|
| "create_category"
|
||||||
|
| "delete_category"
|
||||||
|
| "manage_instance"
|
||||||
|
| "delete_messages"
|
||||||
|
| "pin_messages"
|
||||||
|
| "manage_users"
|
||||||
|
| "create_instance"
|
||||||
|
| "manage_roles";
|
||||||
|
|
||||||
|
export type UserRole = "admin" | "mod" | "member";
|
||||||
|
|
||||||
|
// Check if user has a specific role in an instance
|
||||||
|
export function hasInstanceRole(
|
||||||
|
user: User | null,
|
||||||
|
instanceId: string,
|
||||||
|
role: UserRole,
|
||||||
|
): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
// Global admins have all permissions
|
||||||
|
if (user.admin) return true;
|
||||||
|
|
||||||
|
const userRole = user.roles.find((r) => r.instanceId === instanceId);
|
||||||
|
if (!userRole) return false;
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case "admin":
|
||||||
|
return userRole.role === "admin";
|
||||||
|
case "mod":
|
||||||
|
return userRole.role === "admin" || userRole.role === "mod";
|
||||||
|
case "member":
|
||||||
|
return (
|
||||||
|
userRole.role === "admin" ||
|
||||||
|
userRole.role === "mod" ||
|
||||||
|
userRole.role === "member"
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to view an instance
|
||||||
|
export function canViewInstance(
|
||||||
|
user: User | null,
|
||||||
|
instanceId: string,
|
||||||
|
): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
// Global admins can view all instances
|
||||||
|
if (user.admin) return true;
|
||||||
|
|
||||||
|
// Check if user has any role in this instance
|
||||||
|
return user.roles.some((role) => role.instanceId === instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has a specific permission in an instance
|
||||||
|
export function hasPermission(
|
||||||
|
user: User | null,
|
||||||
|
instanceId: string,
|
||||||
|
permission: UserPermission,
|
||||||
|
): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
// Global admins have all permissions everywhere
|
||||||
|
if (user.admin) return true;
|
||||||
|
|
||||||
|
const userRole = user.roles.find((r) => r.instanceId === instanceId);
|
||||||
|
if (!userRole) return false;
|
||||||
|
|
||||||
|
switch (permission) {
|
||||||
|
case "view_instance":
|
||||||
|
return hasInstanceRole(user, instanceId, "member");
|
||||||
|
|
||||||
|
case "create_channel":
|
||||||
|
case "delete_channel":
|
||||||
|
case "create_category":
|
||||||
|
case "delete_category":
|
||||||
|
case "manage_instance":
|
||||||
|
case "manage_users":
|
||||||
|
case "manage_roles":
|
||||||
|
return hasInstanceRole(user, instanceId, "admin");
|
||||||
|
|
||||||
|
case "delete_messages":
|
||||||
|
case "pin_messages":
|
||||||
|
return hasInstanceRole(user, instanceId, "mod");
|
||||||
|
|
||||||
|
case "create_instance":
|
||||||
|
return user.admin; // Only global admins can create instances
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's role in a specific instance
|
||||||
|
export function getUserRole(
|
||||||
|
user: User | null,
|
||||||
|
instanceId: string,
|
||||||
|
): UserRole | null {
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
// Global admins are always admins
|
||||||
|
if (user.admin) return "admin";
|
||||||
|
|
||||||
|
const userRole = user.roles.find((r) => r.instanceId === instanceId);
|
||||||
|
return userRole ? (userRole.role as UserRole) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter instances that user can access
|
||||||
|
export function getAccessibleInstances(
|
||||||
|
user: User | null,
|
||||||
|
instances: any[],
|
||||||
|
): any[] {
|
||||||
|
if (!user) return [];
|
||||||
|
|
||||||
|
// Global admins can see all instances
|
||||||
|
if (user.admin) return instances;
|
||||||
|
|
||||||
|
// Filter instances where user has a role
|
||||||
|
const userInstanceIds = new Set(user.roles.map((role) => role.instanceId));
|
||||||
|
return instances.filter((instance) => userInstanceIds.has(instance.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can delete a specific message
|
||||||
|
export function canDeleteMessage(
|
||||||
|
user: User | null,
|
||||||
|
instanceId: string,
|
||||||
|
messageUserId: string,
|
||||||
|
): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
// Users can always delete their own messages
|
||||||
|
if (user.id === messageUserId) return true;
|
||||||
|
|
||||||
|
// Mods and admins can delete any message
|
||||||
|
return hasPermission(user, instanceId, "delete_messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can edit a specific message
|
||||||
|
export function canEditMessage(
|
||||||
|
user: User | null,
|
||||||
|
messageUserId: string,
|
||||||
|
): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
// Users can only edit their own messages
|
||||||
|
return user.id === messageUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can pin messages
|
||||||
|
export function canPinMessage(user: User | null, instanceId: string): boolean {
|
||||||
|
return hasPermission(user, instanceId, "pin_messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is global admin
|
||||||
|
export function isGlobalAdmin(user: User | null): boolean {
|
||||||
|
return user?.admin === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get role display info
|
||||||
|
export function getRoleDisplayInfo(role: UserRole) {
|
||||||
|
switch (role) {
|
||||||
|
case "admin":
|
||||||
|
return {
|
||||||
|
name: "Admin",
|
||||||
|
color: "#ff6b6b",
|
||||||
|
priority: 3,
|
||||||
|
description: "Full server permissions",
|
||||||
|
};
|
||||||
|
case "mod":
|
||||||
|
return {
|
||||||
|
name: "Moderator",
|
||||||
|
color: "#4ecdc4",
|
||||||
|
priority: 2,
|
||||||
|
description: "Can moderate messages and users",
|
||||||
|
};
|
||||||
|
case "member":
|
||||||
|
return {
|
||||||
|
name: "Member",
|
||||||
|
color: null,
|
||||||
|
priority: 1,
|
||||||
|
description: "Basic server access",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
name: "Unknown",
|
||||||
|
color: null,
|
||||||
|
priority: 0,
|
||||||
|
description: "Unknown role",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,15 @@ import react from "@vitejs/plugin-react";
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
// Check if VITE_APP_MODE is set to 'web'
|
||||||
|
const isWebApp = process.env.VITE_APP_MODE === "web";
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
// Only include the electron plugin if not in 'web' app mode
|
||||||
|
!isWebApp &&
|
||||||
electron({
|
electron({
|
||||||
main: {
|
main: {
|
||||||
// Shortcut of `build.lib.entry`.
|
// Shortcut of `build.lib.entry`.
|
||||||
@@ -28,10 +34,11 @@ export default defineConfig({
|
|||||||
: {},
|
: {},
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
].filter(Boolean), // Filter out 'false' values if electron plugin is not included
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to alter the column `text` on the `Message` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(2000)`.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."Message" ALTER COLUMN "text" SET DATA TYPE VARCHAR(2000);
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "postgresql"
|
|
||||||
@@ -104,7 +104,7 @@ model Message {
|
|||||||
User User @relation(fields: [userId], references: [id])
|
User User @relation(fields: [userId], references: [id])
|
||||||
userId String
|
userId String
|
||||||
deleted Boolean
|
deleted Boolean
|
||||||
text String
|
text String @db.VarChar(2000)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
replies Reply? @relation("MessageToReply")
|
replies Reply? @relation("MessageToReply")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
editMessage,
|
||||||
getMessageInformation,
|
getMessageInformation,
|
||||||
getMessagesBefore,
|
getMessagesBefore,
|
||||||
sendMessageToChannel,
|
sendMessageToChannel,
|
||||||
} from "../services/messageService";
|
} from "../services/messageService";
|
||||||
|
import { PutMessage } from "../validators/messageValidator";
|
||||||
|
|
||||||
export async function fetchMessageData(id: string) {
|
export async function fetchMessageData(id: string) {
|
||||||
return await getMessageInformation(id);
|
return await getMessageInformation(id);
|
||||||
@@ -27,3 +29,7 @@ export async function sendMessage(
|
|||||||
repliedMessageId,
|
repliedMessageId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function putMessage(data: PutMessage) {
|
||||||
|
return await editMessage(data);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { Context } from "hono";
|
|
||||||
import {
|
|
||||||
sendMessageToChannel,
|
|
||||||
removeMessageFromChannel,
|
|
||||||
} from "../services/realtime.js";
|
|
||||||
import { success } from "zod";
|
|
||||||
|
|
||||||
export async function postMessageToChannel(io: any, c: Context) {
|
|
||||||
try {
|
|
||||||
io = c.get("io");
|
|
||||||
|
|
||||||
const instanceId = c.req.param("instanceId");
|
|
||||||
const categoryId = c.req.param("categoryId");
|
|
||||||
const channelId = c.req.param("channelId");
|
|
||||||
const message = await c.req.json();
|
|
||||||
|
|
||||||
const result = await sendMessageToChannel(
|
|
||||||
instanceId,
|
|
||||||
categoryId,
|
|
||||||
channelId,
|
|
||||||
message,
|
|
||||||
"new_channel_message",
|
|
||||||
io,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === "Event not implemented") {
|
|
||||||
console.log(
|
|
||||||
"controller::realtime::postMessageToChannel - Failed to send message",
|
|
||||||
);
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
message: "Event not implemented or recognized",
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result === "no acknowledgment") {
|
|
||||||
console.log(
|
|
||||||
"controller::realtime::postMessageToChannel - No acknowledgment received from client",
|
|
||||||
);
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
message: "No acknowledgment received from client",
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new Error("failed to send message");
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
success: true,
|
|
||||||
message: "Message sent successfully",
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const errMessage = err as Error;
|
|
||||||
console.log("controller::realtime::postMessageToChannel - ", errMessage);
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
message: errMessage.message,
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMessageFromChannel(io: any, c: Context) {
|
|
||||||
try {
|
|
||||||
io = c.get("io");
|
|
||||||
|
|
||||||
const instanceId = c.req.param("instanceId");
|
|
||||||
const categoryId = c.req.param("categoryId");
|
|
||||||
const channelId = c.req.param("channelId");
|
|
||||||
const messageId = c.req.param("messageId");
|
|
||||||
|
|
||||||
const result = await removeMessageFromChannel(
|
|
||||||
instanceId,
|
|
||||||
categoryId,
|
|
||||||
channelId,
|
|
||||||
messageId,
|
|
||||||
"delete_channel_message",
|
|
||||||
io,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === "event not implemented") {
|
|
||||||
console.log(
|
|
||||||
"controller::realtime::deleteMessageFromChannel - Event not implemented",
|
|
||||||
);
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
message: "Event not implemented or recognized",
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result === "no acknowledgment") {
|
|
||||||
console.log(
|
|
||||||
"controller::realtime::deleteMessageFromChannel - No acknowledgment received from client",
|
|
||||||
);
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
message: "No acknowledgment received from client",
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new Error("failed to delete message");
|
|
||||||
}
|
|
||||||
|
|
||||||
c.json({
|
|
||||||
success: true,
|
|
||||||
message: "Message deleted successfully",
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const errMessage = err as Error;
|
|
||||||
console.log("services::realtime::deleteMessageFromChannel - ", errMessage);
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
message: errMessage.message,
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
getAllUsersFrom,
|
getAllUsersFrom,
|
||||||
getUserInformation,
|
getUserInformation,
|
||||||
createUser,
|
createUser,
|
||||||
|
getUserId,
|
||||||
} from "../services/userService";
|
} from "../services/userService";
|
||||||
import { CreateUserInput } from "../validators/userValidator";
|
import { CreateUserInput } from "../validators/userValidator";
|
||||||
|
|
||||||
@@ -16,3 +17,7 @@ export async function fetchAllUsers(instanceId: string) {
|
|||||||
export async function createNewUser(data: CreateUserInput) {
|
export async function createNewUser(data: CreateUserInput) {
|
||||||
return await createUser(data);
|
return await createUser(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchUserId(username: string) {
|
||||||
|
return await getUserId(username);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,46 +5,20 @@ import { Server } from "socket.io";
|
|||||||
import routes from "./routes/index";
|
import routes from "./routes/index";
|
||||||
import { Scalar } from "@scalar/hono-api-reference";
|
import { Scalar } from "@scalar/hono-api-reference";
|
||||||
import { openAPIRouteHandler } from "hono-openapi";
|
import { openAPIRouteHandler } from "hono-openapi";
|
||||||
|
import { registerSocketHandlers } from "./sockets";
|
||||||
|
|
||||||
//initialize socket.io server
|
// Routes
|
||||||
const io = new Server();
|
|
||||||
|
|
||||||
//initialize bun engine
|
|
||||||
//then bind to socket.io server
|
|
||||||
const engine = new Engine();
|
|
||||||
io.bind(engine);
|
|
||||||
|
|
||||||
io.on("connection", (socket) => {
|
|
||||||
//get userId and clientId from query params
|
|
||||||
const userId = socket.handshake.query.userId;
|
|
||||||
const clientId = socket.handshake.query.clientId;
|
|
||||||
if (!userId || Array.isArray(userId)) {
|
|
||||||
socket.disconnect();
|
|
||||||
throw new Error("Invalid user ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clientId || Array.isArray(clientId)) {
|
|
||||||
socket.disconnect();
|
|
||||||
throw new Error("Invalid client ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.join(userId);
|
|
||||||
console.log(
|
|
||||||
`User ${userId} connected. Client ID ${clientId} on socket ${socket.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
|
||||||
console.log(`User ${userId} disconnected from socket ${socket.id}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
"*",
|
"*",
|
||||||
cors({
|
cors({
|
||||||
origin: "http://localhost:5173",
|
origin: ["http://localhost:5173", "https://concord.kpuig.net", "http://localhost:3000"],
|
||||||
allowHeaders: ["Content-Type", "Authorization"],
|
allowHeaders: [
|
||||||
|
"Content-Type",
|
||||||
|
"Authorization",
|
||||||
|
"Access-Control-Allow-Origin",
|
||||||
|
],
|
||||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}),
|
}),
|
||||||
@@ -68,4 +42,44 @@ app.get(
|
|||||||
|
|
||||||
app.get("/scalar", Scalar({ url: "/openapi" }));
|
app.get("/scalar", Scalar({ url: "/openapi" }));
|
||||||
|
|
||||||
export default app;
|
// initialize socket.io server
|
||||||
|
const io = new Server({
|
||||||
|
cors: {
|
||||||
|
origin: ["http://localhost:5173", "https://concord.kpuig.net", "http://localhost:3000"],
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const engine = new Engine();
|
||||||
|
io.bind(engine);
|
||||||
|
|
||||||
|
// Register socket.io events
|
||||||
|
registerSocketHandlers(io);
|
||||||
|
|
||||||
|
const { websocket } = engine.handler();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
port: 3000,
|
||||||
|
idleTimeout: 30, // must be greater than the "pingInterval" option of the engine, which defaults to 25 seconds
|
||||||
|
|
||||||
|
async fetch(req: Request, server: Bun.Server) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/socket.io/") {
|
||||||
|
const response = await engine.handleRequest(req, server);
|
||||||
|
// Add CORS headers explicitly
|
||||||
|
const origin = req.headers.get("Origin");
|
||||||
|
if (
|
||||||
|
origin &&
|
||||||
|
["http://localhost:5173", "https://concord.kpuig.net", "http://localhost:3000"].includes(origin)
|
||||||
|
) {
|
||||||
|
response.headers.set("Access-Control-Allow-Origin", origin);
|
||||||
|
}
|
||||||
|
response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
return app.fetch(req, server);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
websocket,
|
||||||
|
};
|
||||||
|
|||||||
239
concord-server/src/routes/authRoutes.ts
Normal file
239
concord-server/src/routes/authRoutes.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
import {
|
||||||
|
getUserCredentials,
|
||||||
|
getUserId,
|
||||||
|
getUserInformation,
|
||||||
|
} from "../services/userService";
|
||||||
|
import shaHash from "../helper/hashing";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
loginSchema,
|
||||||
|
validateTokenSchema,
|
||||||
|
refreshTokenSchema,
|
||||||
|
logoutSchema,
|
||||||
|
authResponseSchema,
|
||||||
|
validationResponseSchema,
|
||||||
|
errorResponseSchema,
|
||||||
|
successResponseSchema,
|
||||||
|
} from "../validators/authValidator";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const authRoutes = new Hono();
|
||||||
|
|
||||||
|
// Login endpoint
|
||||||
|
authRoutes.post(
|
||||||
|
"/login",
|
||||||
|
describeRoute({
|
||||||
|
description: "User login",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Login successful",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: resolver(authResponseSchema) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Invalid credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: resolver(errorResponseSchema) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator("json", loginSchema),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = await c.req.json();
|
||||||
|
console.log(c.req.json);
|
||||||
|
|
||||||
|
// Find user by username
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { username: username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ error: "Invalid username or password" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get userId
|
||||||
|
const userIdResult = await getUserId(username);
|
||||||
|
if (!userIdResult) {
|
||||||
|
return c.json({ error: "Invalid username or password" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = userIdResult.userId;
|
||||||
|
|
||||||
|
// get user creds
|
||||||
|
const userCredentials = await getUserCredentials(userId);
|
||||||
|
if (!userCredentials) {
|
||||||
|
return c.json({ error: "Invalid username or password" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// hash the provided password with user ID as salt
|
||||||
|
const hashedPassword = shaHash(password, userId);
|
||||||
|
|
||||||
|
// verify password
|
||||||
|
if (hashedPassword !== userCredentials.password) {
|
||||||
|
return c.json({ error: "Invalid username or password" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate new token
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Update user's token in database
|
||||||
|
await prisma.userAuth.update({
|
||||||
|
where: { userId: user.id },
|
||||||
|
data: { token: token },
|
||||||
|
});
|
||||||
|
|
||||||
|
// get full user information
|
||||||
|
const userInfo = await getUserInformation(user.id);
|
||||||
|
if (!userInfo) {
|
||||||
|
return c.json({ error: "Failed to get user information" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: userInfo,
|
||||||
|
token: token,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error);
|
||||||
|
return c.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token validation endpoint
|
||||||
|
authRoutes.post(
|
||||||
|
"/validate",
|
||||||
|
describeRoute({
|
||||||
|
description: "Validate user token",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Token validation result",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: resolver(validationResponseSchema) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator("json", validateTokenSchema),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { token, userId } = await c.req.json();
|
||||||
|
|
||||||
|
// Get user credentials
|
||||||
|
const userCredentials = await getUserCredentials(userId);
|
||||||
|
if (!userCredentials || userCredentials.token !== token) {
|
||||||
|
return c.json({ valid: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user information
|
||||||
|
const userInfo = await getUserInformation(userId);
|
||||||
|
if (!userInfo) {
|
||||||
|
return c.json({ valid: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
valid: true,
|
||||||
|
user: userInfo,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return c.json({ valid: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token refresh endpoint
|
||||||
|
authRoutes.post(
|
||||||
|
"/refresh",
|
||||||
|
describeRoute({
|
||||||
|
description: "Refresh user token",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Token refreshed successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: resolver(authResponseSchema) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Invalid token",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: resolver(errorResponseSchema) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator("json", refreshTokenSchema),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { userId, oldToken } = await c.req.json();
|
||||||
|
|
||||||
|
// Verify old token
|
||||||
|
const userCredentials = await getUserCredentials(userId);
|
||||||
|
if (!userCredentials || userCredentials.token !== oldToken) {
|
||||||
|
return c.json({ error: "Invalid token" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new token
|
||||||
|
const newToken = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Update token in database
|
||||||
|
await prisma.userAuth.update({
|
||||||
|
where: { userId: userId },
|
||||||
|
data: { token: newToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user information
|
||||||
|
const userInfo = await getUserInformation(userId);
|
||||||
|
if (!userInfo) {
|
||||||
|
return c.json({ error: "Failed to get user information" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: userInfo,
|
||||||
|
token: newToken,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token refresh error:", error);
|
||||||
|
return c.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Logout endpoint (invalidate token)
|
||||||
|
authRoutes.post(
|
||||||
|
"/logout",
|
||||||
|
describeRoute({
|
||||||
|
description: "User logout",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Logout successful",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: resolver(successResponseSchema) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator("json", logoutSchema),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { userId } = await c.req.json();
|
||||||
|
|
||||||
|
// Clear token in database
|
||||||
|
await prisma.userAuth.update({
|
||||||
|
where: { userId: userId },
|
||||||
|
data: { token: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout error:", error);
|
||||||
|
return c.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default authRoutes;
|
||||||
@@ -5,6 +5,7 @@ import messageRoutes from "./messageRoutes";
|
|||||||
import { channelRoutes } from "./channelRoutes";
|
import { channelRoutes } from "./channelRoutes";
|
||||||
import instanceRoutes from "./instanceRoutes";
|
import instanceRoutes from "./instanceRoutes";
|
||||||
import { categoryRoutes } from "./categoryRoutes";
|
import { categoryRoutes } from "./categoryRoutes";
|
||||||
|
import authRoutes from "./authRoutes";
|
||||||
|
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
@@ -13,5 +14,6 @@ routes.route("/message", messageRoutes);
|
|||||||
routes.route("/channel", channelRoutes);
|
routes.route("/channel", channelRoutes);
|
||||||
routes.route("/instance", instanceRoutes);
|
routes.route("/instance", instanceRoutes);
|
||||||
routes.route("/category", categoryRoutes);
|
routes.route("/category", categoryRoutes);
|
||||||
|
routes.route("/auth", authRoutes);
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
|
|||||||
@@ -81,6 +81,28 @@ messageRoutes.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
messageRoutes.put(
|
||||||
|
"/:id",
|
||||||
|
describeRoute({
|
||||||
|
description: "Edit message fields",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Success"
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Message not found"
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Bad request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
zValidator("json", sendMessageSchema),
|
||||||
|
async (c) => {
|
||||||
|
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
messageRoutes.post(
|
messageRoutes.post(
|
||||||
"",
|
"",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { zValidator } from "@hono/zod-validator";
|
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
|
||||||
import {
|
|
||||||
postMessageToChannel,
|
|
||||||
deleteMessageFromChannel,
|
|
||||||
} from "../controller/realtime";
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
|
|
||||||
app.post(
|
|
||||||
"message/",
|
|
||||||
zValidator({
|
|
||||||
body: z.object({
|
|
||||||
content: z.string().min(1).max(500),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const { instanceId, categoryId, channelId } = c.req.params;
|
|
||||||
const { content } = c.req.body;
|
|
||||||
|
|
||||||
return postMessageToChannel(c.get("io"), {
|
|
||||||
instanceId,
|
|
||||||
categoryId,
|
|
||||||
channelId,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -3,16 +3,49 @@ import {
|
|||||||
fetchAllUsers,
|
fetchAllUsers,
|
||||||
fetchUserData,
|
fetchUserData,
|
||||||
createNewUser,
|
createNewUser,
|
||||||
|
fetchUserId,
|
||||||
} from "../controller/userController";
|
} from "../controller/userController";
|
||||||
import {
|
import {
|
||||||
createUserSchema,
|
createUserSchema,
|
||||||
queryAllUsersByInstanceId,
|
queryAllUsersByInstanceId,
|
||||||
queryUserByIdSchema,
|
queryUserByIdSchema,
|
||||||
|
queryUserByUsernameSchema,
|
||||||
} from "../validators/userValidator";
|
} from "../validators/userValidator";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
const userRoutes = new Hono();
|
const userRoutes = new Hono();
|
||||||
|
|
||||||
|
userRoutes.get(
|
||||||
|
"/username/:username",
|
||||||
|
describeRoute({
|
||||||
|
description: "Get userId by username",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Success getting userId",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: resolver(queryUserByUsernameSchema) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "userId not found",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: resolver(queryUserByUsernameSchema) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator("param", queryUserByUsernameSchema),
|
||||||
|
async (c) => {
|
||||||
|
const username = c.req.param("username");
|
||||||
|
const userId = await fetchUserId(username);
|
||||||
|
if (userId) {
|
||||||
|
return c.json(userId);
|
||||||
|
} else {
|
||||||
|
return c.json({ error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
userRoutes.get(
|
userRoutes.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -57,3 +57,64 @@ export async function getAllInstances() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInstanceByChannelId(id: string) {
|
||||||
|
try {
|
||||||
|
const instance = await prisma.instance.findFirst({
|
||||||
|
where: {
|
||||||
|
Category: {
|
||||||
|
some: {
|
||||||
|
Channel: {
|
||||||
|
some: {
|
||||||
|
id: id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching instance by channel ID:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInstancesByUserId(id: string) {
|
||||||
|
try {
|
||||||
|
const user = await getUserInformation(id);
|
||||||
|
if (user && user.admin) {
|
||||||
|
const adminInstances = await getAllInstances();
|
||||||
|
if (adminInstances && adminInstances.success) {
|
||||||
|
return adminInstances.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await prisma.instance.findMany({
|
||||||
|
where: {
|
||||||
|
Role: {
|
||||||
|
some: {
|
||||||
|
User: {
|
||||||
|
id: id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching instance by channel ID:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { getUserCredentials } from "./userService";
|
import { getUserCredentials } from "./userService";
|
||||||
|
import { PutMessage } from "../validators/messageValidator";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ export async function getMessagesBefore(date: string, channelId: string) {
|
|||||||
userId: message.userId!,
|
userId: message.userId!,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
deleted: message.deleted,
|
deleted: message.deleted,
|
||||||
|
createdAt: message.createdAt,
|
||||||
replies: originalMessage
|
replies: originalMessage
|
||||||
? {
|
? {
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
@@ -155,6 +157,38 @@ export async function getMessagesBefore(date: string, channelId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function editMessage(data: PutMessage) {
|
||||||
|
try {
|
||||||
|
const userCreds = await getUserCredentials(data.id);
|
||||||
|
if (
|
||||||
|
!userCreds ||
|
||||||
|
userCreds.token == null ||
|
||||||
|
userCreds.token != data.token
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMessage = await prisma.message.update({
|
||||||
|
where: {
|
||||||
|
id: data.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
text: data.content,
|
||||||
|
deleted: data.deleted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedMessage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedMessage;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error editing message:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMessageToChannel(
|
export async function sendMessageToChannel(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { readonly } from "zod";
|
|
||||||
|
|
||||||
const EVENTS = {
|
|
||||||
NEW_CHANNEL_MESSAGE: "new_channel_message",
|
|
||||||
DELETE_CHANNEL_MESSAGE: "delete_channel_message",
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function sendMessageToChannel(
|
|
||||||
instanceId: string,
|
|
||||||
categoryId: string,
|
|
||||||
channelId: string,
|
|
||||||
message: any,
|
|
||||||
event: string,
|
|
||||||
io: any,
|
|
||||||
): Promise<string | boolean> {
|
|
||||||
try {
|
|
||||||
//TODO: implement middleware to replace this
|
|
||||||
if (EVENTS.NEW_CHANNEL_MESSAGE === event) {
|
|
||||||
throw new Error("Event not implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: add prisma to save channel message to DB
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
io.to(instanceId).emit(event, message, (ack: any) => {
|
|
||||||
if (ack && ack.status === "received") {
|
|
||||||
console.log(`Message ${ack.messageId} acknowledged by client.`);
|
|
||||||
resolve(true);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"services::realtime::sendMessageToChannel No acknowledgment received from client.",
|
|
||||||
);
|
|
||||||
resolve("no acknowledgment");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const errMessage = err as Error;
|
|
||||||
if (errMessage.message === "Event not implemented") {
|
|
||||||
console.log(
|
|
||||||
`services::realtime::sendMessageToChannel - Event not implemented. Attempted event: ${event}`,
|
|
||||||
);
|
|
||||||
return "event not implemented";
|
|
||||||
}
|
|
||||||
console.log("services::realtime::sendMessageToChannel - ", errMessage);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeMessageFromChannel(
|
|
||||||
instanceId: string,
|
|
||||||
categoryId: string,
|
|
||||||
channelId: string,
|
|
||||||
messageId: string,
|
|
||||||
event: string,
|
|
||||||
io: any,
|
|
||||||
): Promise<string | boolean> {
|
|
||||||
try {
|
|
||||||
//TODO: implement middleware to replace this
|
|
||||||
if (EVENTS.DELETE_CHANNEL_MESSAGE === event) {
|
|
||||||
throw new Error("event not implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: add prisma to flag a channel message as deleted
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
io.to(instanceId).emit(event, { messageId }, (ack: any) => {
|
|
||||||
if (ack && ack.status === "received") {
|
|
||||||
console.log(`Message ${ack.messageId} acknowledged by client.`);
|
|
||||||
resolve(true);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"services::realtime::deleteMessageFromChannel No acknowledgment received from client.",
|
|
||||||
);
|
|
||||||
resolve("no acknowledgment");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const errMessage = err as Error;
|
|
||||||
if (errMessage.message === "Event not implemented") {
|
|
||||||
console.log(
|
|
||||||
`services::realtime::deleteMessageFromChannel - Event not implemented. Attempted event: ${event}`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
console.log("services::realtime::deleteMessageFromChannel - ", errMessage);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,6 +64,39 @@ export async function createUser(data: CreateUserInput): Promise<{
|
|||||||
return userData;
|
return userData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserId(
|
||||||
|
username: string,
|
||||||
|
): Promise<{ userId: string } | null> {
|
||||||
|
try {
|
||||||
|
if (!username) throw new Error("missing username");
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
username: username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) throw new Error("could not find user");
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const errMessage = err as Error;
|
||||||
|
|
||||||
|
if (errMessage.message === "missing username") {
|
||||||
|
console.log("services::actions::getUserId - no username given");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (errMessage.message === "could not find user") {
|
||||||
|
console.log("services::actions::getUserId - unable to find user");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.log("services::actions::getUserId - unknown error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserCredentials(userId: string): Promise<{
|
export async function getUserCredentials(userId: string): Promise<{
|
||||||
userId: string;
|
userId: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
16
concord-server/src/sockets/index.ts
Normal file
16
concord-server/src/sockets/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Server } from "socket.io";
|
||||||
|
import { registerVoiceHandlers } from "./voiceHandler";
|
||||||
|
|
||||||
|
export function registerSocketHandlers(io: Server) {
|
||||||
|
// bad practice
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
console.log("connected");
|
||||||
|
socket.on("ping", (c) => {
|
||||||
|
console.log(c);
|
||||||
|
socket.emit("pong", c);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// good practice
|
||||||
|
registerVoiceHandlers(io);
|
||||||
|
}
|
||||||
309
concord-server/src/sockets/voiceHandler.ts
Normal file
309
concord-server/src/sockets/voiceHandler.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
import { getUserCredentials, getUserInformation } from "../services/userService";
|
||||||
|
import { getAllInstances, getInstanceByChannelId, getInstancesByUserId } from "../services/instanceService";
|
||||||
|
import { getCategoriesByInstance, getCategory, getChannel } from "../services/channelService";
|
||||||
|
|
||||||
|
// Change to Map of voiceChannelId to Map of userId to socket
|
||||||
|
const voiceChannelMembers = new Map<string, Map<string, Socket>>();
|
||||||
|
|
||||||
|
// Types for WebRTC messages
|
||||||
|
interface WebRTCOffer {
|
||||||
|
targetUserId: string;
|
||||||
|
sdp: RTCSessionDescriptionInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebRTCAnswer {
|
||||||
|
targetUserId: string;
|
||||||
|
sdp: RTCSessionDescriptionInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebRTCIceCandidate {
|
||||||
|
targetUserId: string;
|
||||||
|
candidate: RTCIceCandidateInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future ICE server configuration
|
||||||
|
// This can be expanded later to include TURN servers
|
||||||
|
interface IceServerConfig {
|
||||||
|
urls: string | string[];
|
||||||
|
username?: string;
|
||||||
|
credential?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerVoiceHandlers(io: Server) {
|
||||||
|
io.on("connection", (socket: Socket) => {
|
||||||
|
// Join voice channel
|
||||||
|
socket.on("join-voicechannel", async (data) => {
|
||||||
|
const payload = data as {
|
||||||
|
userId: string
|
||||||
|
userToken: string,
|
||||||
|
voiceChannelId: string,
|
||||||
|
};
|
||||||
|
if (!payload) {
|
||||||
|
socket.emit("error-voicechannel", "no payload in voice conn")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize map for channel if not present
|
||||||
|
if (!voiceChannelMembers.has(payload.voiceChannelId)) {
|
||||||
|
voiceChannelMembers.set(payload.voiceChannelId, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelMembers = voiceChannelMembers.get(payload.voiceChannelId)!;
|
||||||
|
|
||||||
|
// Remove user if already present in this channel
|
||||||
|
if (channelMembers.has(payload.userId)) {
|
||||||
|
channelMembers.delete(payload.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticate user
|
||||||
|
const userCreds = await getUserCredentials(payload.userId);
|
||||||
|
if (!userCreds || !userCreds.token || userCreds.token != payload.userToken) {
|
||||||
|
socket.emit("error-voicechannel", "bad user creds in voice conn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine if channel is voice channel
|
||||||
|
const channel = await getChannel(payload.voiceChannelId);
|
||||||
|
if (!channel || channel.type !== "voice" || !channel.categoryId) {
|
||||||
|
socket.emit("error-voicechannel", "bad channel or channel type in voice conn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorize user using role
|
||||||
|
const user = await getUserInformation(payload.userId);
|
||||||
|
const instance = await getInstanceByChannelId(payload.voiceChannelId);
|
||||||
|
const instances = await getInstancesByUserId(payload.userId);
|
||||||
|
if (!user || !instance || !instances || !instances.find(e => e.id === instance.id)) {
|
||||||
|
socket.emit("error-voicechannel", "user not authorized for channel in voice conn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add to map
|
||||||
|
channelMembers.set(payload.userId, socket);
|
||||||
|
|
||||||
|
socket.join(payload.voiceChannelId);
|
||||||
|
socket.emit("joined-voicechannel", {
|
||||||
|
voiceChannelId: payload.voiceChannelId,
|
||||||
|
connectedUserIds: Array.from(channelMembers.keys()).filter(e => e !== payload.userId),
|
||||||
|
iceServers: getIceServers() // Send ICE server config to client
|
||||||
|
});
|
||||||
|
socket.to(payload.voiceChannelId).emit("user-joined-voicechannel", { userId: payload.userId });
|
||||||
|
|
||||||
|
// Store userId in socket.data for easier access later
|
||||||
|
socket.data.userId = payload.userId;
|
||||||
|
socket.data.currentVoiceChannelId = payload.voiceChannelId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave voice channel
|
||||||
|
socket.on("leave-voicechannel", async (data) => {
|
||||||
|
const payload = data as {
|
||||||
|
userId: string,
|
||||||
|
userToken: string,
|
||||||
|
voiceChannelId: string,
|
||||||
|
};
|
||||||
|
if (!payload) {
|
||||||
|
socket.emit("error-voicechannel", "no payload in leave voice request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelMembers = voiceChannelMembers.get(payload.voiceChannelId);
|
||||||
|
if (!channelMembers) {
|
||||||
|
socket.emit("error-voicechannel", "voice channel not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticate user
|
||||||
|
const userCreds = await getUserCredentials(payload.userId);
|
||||||
|
if (!userCreds || !userCreds.token || userCreds.token != payload.userToken) {
|
||||||
|
socket.emit("error-voicechannel", "bad user creds in leave voice request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove user from channel
|
||||||
|
if (channelMembers.has(payload.userId)) {
|
||||||
|
channelMembers.delete(payload.userId);
|
||||||
|
|
||||||
|
// Leave the socket.io room
|
||||||
|
socket.leave(payload.voiceChannelId);
|
||||||
|
|
||||||
|
// Notify other users in the channel
|
||||||
|
io.to(payload.voiceChannelId).emit("user-left-voicechannel", {
|
||||||
|
userId: payload.userId,
|
||||||
|
voiceChannelId: payload.voiceChannelId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up empty channels
|
||||||
|
if (channelMembers.size === 0) {
|
||||||
|
voiceChannelMembers.delete(payload.voiceChannelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm to the user that they've left
|
||||||
|
socket.emit("left-voicechannel", {
|
||||||
|
voiceChannelId: payload.voiceChannelId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear socket data
|
||||||
|
socket.data.currentVoiceChannelId = undefined;
|
||||||
|
} else {
|
||||||
|
socket.emit("error-voicechannel", "user not in voice channel");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle disconnection
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
// Get the user ID and current voice channel from socket data
|
||||||
|
const userId = socket.data.userId;
|
||||||
|
const voiceChannelId = socket.data.currentVoiceChannelId;
|
||||||
|
|
||||||
|
// If we have the channel ID stored, use it directly
|
||||||
|
if (userId && voiceChannelId) {
|
||||||
|
const channelMembers = voiceChannelMembers.get(voiceChannelId);
|
||||||
|
if (channelMembers && channelMembers.has(userId)) {
|
||||||
|
// Remove the user from the channel
|
||||||
|
channelMembers.delete(userId);
|
||||||
|
|
||||||
|
// Notify other members
|
||||||
|
io.to(voiceChannelId).emit("user-left-voicechannel", {
|
||||||
|
userId,
|
||||||
|
voiceChannelId,
|
||||||
|
reason: "disconnected"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up empty channels
|
||||||
|
if (channelMembers.size === 0) {
|
||||||
|
voiceChannelMembers.delete(voiceChannelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If we don't have the info stored, search through all channels
|
||||||
|
voiceChannelMembers.forEach((members, channelId) => {
|
||||||
|
// Use Array.from to convert Map entries to array for iteration
|
||||||
|
Array.from(members.entries()).forEach(([memberId, memberSocket]) => {
|
||||||
|
if (memberSocket.id === socket.id) {
|
||||||
|
// Found the user in this channel
|
||||||
|
members.delete(memberId);
|
||||||
|
|
||||||
|
// Notify other members
|
||||||
|
io.to(channelId).emit("user-left-voicechannel", {
|
||||||
|
userId: memberId,
|
||||||
|
voiceChannelId: channelId,
|
||||||
|
reason: "disconnected"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up empty channels
|
||||||
|
if (members.size === 0) {
|
||||||
|
voiceChannelMembers.delete(channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle WebRTC Offer
|
||||||
|
socket.on("webrtc-offer", async (data) => {
|
||||||
|
const payload = data as { targetUserId: string; sdp: any };
|
||||||
|
const senderUserId = socket.data.userId;
|
||||||
|
const voiceChannelId = socket.data.currentVoiceChannelId;
|
||||||
|
|
||||||
|
if (!payload || !senderUserId || !voiceChannelId) {
|
||||||
|
socket.emit("error-voicechannel", "Invalid WebRTC offer payload or sender not in voice channel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelMembers = voiceChannelMembers.get(voiceChannelId);
|
||||||
|
const targetSocket = channelMembers?.get(payload.targetUserId);
|
||||||
|
|
||||||
|
if (targetSocket) {
|
||||||
|
targetSocket.emit("webrtc-offer", {
|
||||||
|
senderUserId: senderUserId,
|
||||||
|
sdp: payload.sdp
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.emit("error-voicechannel", "Target user not found in voice channel");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle WebRTC Answer
|
||||||
|
socket.on("webrtc-answer", (data: WebRTCAnswer) => {
|
||||||
|
const senderUserId = socket.data.userId;
|
||||||
|
const voiceChannelId = socket.data.currentVoiceChannelId;
|
||||||
|
|
||||||
|
if (!data || !senderUserId || !voiceChannelId) {
|
||||||
|
socket.emit("error-voicechannel", "Invalid WebRTC answer data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward the answer to the target user
|
||||||
|
const channelMembers = voiceChannelMembers.get(voiceChannelId);
|
||||||
|
const targetSocket = channelMembers?.get(data.targetUserId);
|
||||||
|
|
||||||
|
if (targetSocket) {
|
||||||
|
targetSocket.emit("webrtc-answer", {
|
||||||
|
senderUserId: senderUserId,
|
||||||
|
sdp: data.sdp
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.emit("error-voicechannel", "Target user not found in voice channel");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle ICE Candidates
|
||||||
|
socket.on("webrtc-ice-candidate", (data: WebRTCIceCandidate) => {
|
||||||
|
const senderUserId = socket.data.userId;
|
||||||
|
const voiceChannelId = socket.data.currentVoiceChannelId;
|
||||||
|
|
||||||
|
if (!data || !senderUserId || !voiceChannelId) {
|
||||||
|
socket.emit("error-voicechannel", "Invalid ICE candidate data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward the ICE candidate to the target user
|
||||||
|
const channelMembers = voiceChannelMembers.get(voiceChannelId);
|
||||||
|
const targetSocket = channelMembers?.get(data.targetUserId);
|
||||||
|
|
||||||
|
if (targetSocket) {
|
||||||
|
targetSocket.emit("webrtc-ice-candidate", {
|
||||||
|
senderUserId: senderUserId,
|
||||||
|
candidate: data.candidate
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.emit("error-voicechannel", "Target user not found in voice channel");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current ICE server configuration.
|
||||||
|
* This function returns STUN servers and includes TURN server credentials
|
||||||
|
* if they are available in the environment variables.
|
||||||
|
*/
|
||||||
|
function getIceServers(): IceServerConfig[] {
|
||||||
|
const iceServers: IceServerConfig[] = [
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add own STUN server if configured
|
||||||
|
const stunServerUrl = process.env.STUN_SERVER_URL;
|
||||||
|
if (stunServerUrl) {
|
||||||
|
iceServers.push({ urls: stunServerUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add TURN server if configured in environment variables
|
||||||
|
const turnServerUrl = process.env.TURN_SERVER_URL;
|
||||||
|
const turnUsername = process.env.TURN_SERVER_USERNAME;
|
||||||
|
const turnCredential = process.env.TURN_SERVER_CREDENTIAL;
|
||||||
|
|
||||||
|
if (turnServerUrl && turnUsername && turnCredential) {
|
||||||
|
iceServers.push({
|
||||||
|
urls: turnServerUrl,
|
||||||
|
username: turnUsername,
|
||||||
|
credential: turnCredential,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return iceServers;
|
||||||
|
}
|
||||||
57
concord-server/src/validators/authValidator.ts
Normal file
57
concord-server/src/validators/authValidator.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
username: z.string().min(3).max(30),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const validateTokenSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
userId: z.uuidv7(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const refreshTokenSchema = z.object({
|
||||||
|
userId: z.uuidv7(),
|
||||||
|
oldToken: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logoutSchema = z.object({
|
||||||
|
userId: z.uuidv7(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response schemas for OpenAPI documentation
|
||||||
|
export const authResponseSchema = z.object({
|
||||||
|
user: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
userName: z.string(),
|
||||||
|
nickName: z.string().nullable(),
|
||||||
|
bio: z.string().nullable(),
|
||||||
|
picture: z.string().nullable(),
|
||||||
|
banner: z.string().nullable(),
|
||||||
|
admin: z.boolean(),
|
||||||
|
status: z.enum(["online", "offline", "dnd", "idle", "invis"]),
|
||||||
|
role: z.array(z.any()),
|
||||||
|
}),
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const validationResponseSchema = z.object({
|
||||||
|
valid: z.boolean(),
|
||||||
|
user: z.any().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const errorResponseSchema = z.object({
|
||||||
|
error: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const successResponseSchema = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
|
export type ValidateTokenInput = z.infer<typeof validateTokenSchema>;
|
||||||
|
export type RefreshTokenInput = z.infer<typeof refreshTokenSchema>;
|
||||||
|
export type LogoutInput = z.infer<typeof logoutSchema>;
|
||||||
|
export type AuthResponse = z.infer<typeof authResponseSchema>;
|
||||||
|
export type ValidationResponse = z.infer<typeof validationResponseSchema>;
|
||||||
@@ -18,3 +18,12 @@ export const sendMessageSchema = z.object({
|
|||||||
token: z.string(),
|
token: z.string(),
|
||||||
repliedMessageId: z.uuidv7().nullable().optional(),
|
repliedMessageId: z.uuidv7().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const putMessageSchema = z.object({
|
||||||
|
id: z.uuidv7(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
deleted: z.boolean().optional(),
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PutMessage = z.infer<typeof putMessageSchema>;
|
||||||
|
|||||||
18
concord-server/src/validators/realtimeValidator.ts
Normal file
18
concord-server/src/validators/realtimeValidator.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const postMessageToChannelSchema = z.object({
|
||||||
|
instanceId: z.uuidv7(),
|
||||||
|
categoryId: z.uuidv7(),
|
||||||
|
channelId: z.uuidv7(),
|
||||||
|
userId: z.uuidv7(),
|
||||||
|
content: z.string().min(1).max(2000),
|
||||||
|
repliedMessageId: z.uuidv7().optional(),
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
//TODO: add more realtime related validators as needed
|
||||||
|
|
||||||
|
export type PostMessageToChannelInput = z.infer<
|
||||||
|
typeof postMessageToChannelSchema
|
||||||
|
>;
|
||||||
|
//TODO: create more input schemas for other realtime actions
|
||||||
@@ -4,10 +4,14 @@ export const queryUserByIdSchema = z.object({
|
|||||||
id: z.uuidv7(),
|
id: z.uuidv7(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const queryUserByUsernameSchema = z.object({
|
||||||
|
username: z.string().min(3).max(30),
|
||||||
|
});
|
||||||
|
|
||||||
export const queryAllUsersByInstanceId = z.object({
|
export const queryAllUsersByInstanceId = z.object({
|
||||||
instanceId: z.uuidv7(),
|
instanceId: z.uuidv7(),
|
||||||
});
|
});
|
||||||
import { is } from "zod/v4/locales";
|
|
||||||
export const createUserSchema = z.object({
|
export const createUserSchema = z.object({
|
||||||
username: z.string().min(3).max(30),
|
username: z.string().min(3).max(30),
|
||||||
nickname: z.string().min(1).max(30).optional(),
|
nickname: z.string().min(1).max(30).optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user