ui: many minor changes

- Messages:
    - Separate into components in @/components/messages/
    - Fixed replies
    - Dropdown still failing
- Channel Sidebar's User Panel:
    - Navigate to /settings
- Settings:
    - Use navigate() instead of a tags
    - Remove stub settings cards
- ServerSidebar: minor padding fix
- ChannelSidebar: category collapse working
This commit is contained in:
2025-10-06 19:05:23 -04:00
parent 0cbb496fab
commit 2edf97bf1c
17 changed files with 595 additions and 1813 deletions

View File

@@ -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,37 +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", "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/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/bun": "^1.2.22", "@types/bun": "^1.2.23",
"@types/react": "^18.2.64", "@types/react": "^18.3.26",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.3.7",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/socket.io-client": "^3.0.0", "@types/socket.io-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.1.1", "@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.7.0",
"electron": "^30.0.1", "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",
}, },
}, },
}, },
@@ -336,35 +336,35 @@
"@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=="],
@@ -386,7 +386,7 @@
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], "@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
@@ -414,7 +414,7 @@
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/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=="],
@@ -526,7 +526,7 @@
"builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="], "builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="],
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
@@ -692,7 +692,7 @@
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@4.6.2", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ=="], "eslint-plugin-react-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=="],
@@ -1238,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=="],
@@ -1272,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=="],
@@ -1386,12 +1386,14 @@
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], "@tailwindcss/oxide-wasm32-wasi/@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=="],
@@ -1402,6 +1404,8 @@
"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=="], "engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],

View File

@@ -22,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",
@@ -30,37 +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", "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/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/bun": "^1.2.22", "@types/bun": "^1.2.23",
"@types/react": "^18.2.64", "@types/react": "^18.3.26",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.3.7",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/socket.io-client": "^3.0.0", "@types/socket.io-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.1.1", "@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.7.0",
"electron": "^30.0.1", "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"
} }

View File

@@ -52,7 +52,7 @@ const ChannelItem: React.FC<ChannelItemProps> = ({ channel }) => {
const connectedUserIds = Array.from(remoteStreams.keys()); const connectedUserIds = Array.from(remoteStreams.keys());
return ( return (
<div> <div className={`${isActive ?? "visible"}`}>
<button <button
onClick={handleChannelClick} onClick={handleChannelClick}
className={`w-full flex items-center p-1.5 rounded-md text-left transition-colors ${ className={`w-full flex items-center p-1.5 rounded-md text-left transition-colors ${

View File

@@ -1,13 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ChevronDown, ChevronRight, Plus, Edit } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button"; import { 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 ChannelItem from "@/components/channel/ChannelItem"; import ChannelItem from "@/components/channel/ChannelItem";
@@ -23,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-primary-foreground 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">
@@ -43,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>
); );
}; };
@@ -114,16 +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 key={channel.id} channel={channel} /> <ChannelItem key={channel.id} channel={channel} />
))} ))}
</div> </div>
)}
</div> </div>
); );
})} })}

View File

@@ -34,7 +34,7 @@ const AppLayout: React.FC = () => {
} }
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 */} {/* This component handles playing audio from remote users */}
<VoiceConnectionManager /> <VoiceConnectionManager />

View File

@@ -58,7 +58,7 @@ const ServerSidebar: React.FC = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`w-12 h-12 ml-0 rounded-2xl hover:rounded-xl transition-all duration-200 ${ className={`w-12 h-12 ml-2 rounded-2xl hover:rounded-xl transition-all duration-200 ${
!instanceId || instanceId === "@me" !instanceId || instanceId === "@me"
? "bg-primary text-primary-foreground rounded-xl" ? "bg-primary text-primary-foreground rounded-xl"
: "hover:bg-primary/10" : "hover:bg-primary/10"
@@ -74,7 +74,7 @@ const ServerSidebar: React.FC = () => {
</Tooltip> </Tooltip>
{/* Separator */} {/* Separator */}
<div className="w-8 h-0.5 bg-border rounded-full" /> <div className="w-12 ml-2 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">

View File

@@ -8,18 +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 { useLogout } from "@/hooks/useAuth";
import { useVoiceStore } from "@/stores/voiceStore"; import { useVoiceStore } from "@/stores/voiceStore";
import { useNavigate } from "react-router";
// Status color utility // Status color utility
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -35,70 +27,12 @@ const getStatusColor = (status: string) => {
} }
}; };
// User Status Dropdown Component
interface UserStatusDropdownProps {
currentStatus: string;
onStatusChange: (status: string) => void;
children: React.ReactNode;
}
const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
// currentStatus,
onStatusChange,
children,
}) => {
const { mutate: logout } = useLogout();
const statusOptions = [
{ value: "online", label: "Online", color: "bg-status-online" },
{ value: "away", label: "Away", color: "bg-status-away" },
{ value: "busy", label: "Do Not Disturb", color: "bg-status-busy" },
{ value: "offline", label: "Invisible", color: "bg-status-offline" },
];
return (
<DropdownMenu>
<DropdownMenuTrigger className="checkchekchek" asChild>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
{statusOptions.map((status) => (
<DropdownMenuItem
key={status.value}
onClick={() => onStatusChange(status.value)}
>
<div className="flex items-center space-x-2">
<div className={`w-3 h-3 rounded-full ${status.color}`} />
<span>{status.label}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => useUiStore.getState().openUserSettings()}
>
<Settings size={16} className="mr-2" />
User Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => logout()}
className="text-destructive focus:text-destructive"
>
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
// Voice Controls Component // 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> = ({
@@ -106,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">
@@ -146,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>
); );
@@ -207,29 +125,16 @@ 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 { isConnected, isMuted, isDeafened, toggleMute, toggleDeafen } = const { isConnected, isMuted, isDeafened, toggleMute, toggleDeafen } =
useVoiceStore(); useVoiceStore();
const handleStatusChange = (newStatus: string) => {
console.log("Status change to:", newStatus);
};
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-2 bg-concord-tertiary border-t border-sidebar">
{/* User Info with Dropdown */} {/* User Info */}
<UserStatusDropdown
currentStatus={user?.status as string}
onStatusChange={handleStatusChange}
>
<Button
variant="ghost"
className="flex-1 flex items-center h-auto p-1 rounded-md hover:bg-concord-secondary"
>
<UserAvatar user={user} size="md" /> <UserAvatar user={user} 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">
@@ -239,8 +144,6 @@ const UserPanel: React.FC = () => {
{user?.status} {user?.status}
</div> </div>
</div> </div>
</Button>
</UserStatusDropdown>
{/* Voice Controls */} {/* Voice Controls */}
{isConnected && ( {isConnected && (
@@ -249,9 +152,26 @@ const UserPanel: React.FC = () => {
isDeafened={isDeafened} isDeafened={isDeafened}
onMuteToggle={toggleMute} onMuteToggle={toggleMute}
onDeafenToggle={toggleDeafen} 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>
); );
}; };

View File

@@ -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 };

View File

@@ -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>
);
};

View File

@@ -0,0 +1,247 @@
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.nickName ||
replyToUser.username ||
replyToUser.userName}
</span>
<span className="truncate max-w-xs opacity-75">
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</span>
</div>
)}
{/* Header - always show */}
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-concord-primary">
{displayName}
</span>
<span className="text-xs text-concord-secondary">
{formatTimestamp(message.createdAt)}
</span>
{message.edited && (
<span className="text-xs text-concord-secondary opacity-60">
(edited)
</span>
)}
{(message as any).pinned && (
<Pin className="h-3 w-3 text-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>
</>
);
};

View File

@@ -0,0 +1,116 @@
import { Message } from "@/lib/api-client";
import { useState, useRef, useEffect } from "react";
import { useSendMessage } from "@/hooks/useMessages";
import { Button } from "@/components/ui/button";
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 textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null);
// Use the API hook for sending messages
const sendMessageMutation = useSendMessage();
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [content]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (content.trim() && !sendMessageMutation.isPending) {
try {
await sendMessageMutation.mutateAsync({
channelId,
content: content.trim(),
repliedMessageId: replyingTo?.id || null,
});
setContent("");
onCancelReply?.();
} catch (error) {
console.error("Failed to send message:", error);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
formRef.current?.requestSubmit();
}
};
return (
<div className="px-4 pb-4">
{replyingTo && replyingToUser && (
<div className="mb-2 p-3 bg-concord-secondary rounded-lg border border-b-0 border-border">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary">
{replyingToUser.nickname || replyingToUser.userName}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-auto p-1 text-concord-secondary hover:text-concord-primary"
onClick={onCancelReply}
>
×
</Button>
</div>
<div className="text-sm text-concord-primary truncate pl-2">
{replyingTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</div>
</div>
)}
<form ref={formRef} onSubmit={handleSubmit}>
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message #${channelName || "channel"}`}
disabled={sendMessageMutation.isPending}
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
style={{
minHeight: "44px",
maxHeight: "200px",
}}
/>
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
{sendMessageMutation.isPending
? "Sending..."
: "Press Enter to send • Shift+Enter for new line"}
</div>
</div>
</form>
</div>
);
};

View File

@@ -1,61 +0,0 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Copy, Reply } from "lucide-react";
import { Message } from "@/lib/api-client";
interface MessageActionsModalProps {
isOpen: boolean;
onClose: () => void;
message: Message;
isOwnMessage: boolean;
onReply?: (messageId: string) => void;
}
export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
isOpen,
onClose,
message,
onReply,
}) => {
const handleAction = (action: () => void) => {
action();
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[300px]">
<DialogHeader>
<DialogTitle>Message Actions</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => handleAction(() => onReply?.(message.id))}
>
<Reply className="h-4 w-4 mr-2" />
Reply
</Button>
<Button
variant="ghost"
className="w-full justify-start"
onClick={() =>
handleAction(() => navigator.clipboard.writeText(message.text))
}
>
<Copy className="h-4 w-4 mr-2" />
Copy Text
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,485 +0,0 @@
import React, { useState } from "react";
import {
Moon,
Sun,
Monitor,
Palette,
Plus,
Trash2,
Settings,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
useTheme,
ThemeDefinition,
ThemeColors,
} from "@/components/theme-provider";
// Theme color input component for OKLCH values
const ColorInput: React.FC<{
label: string;
value: string;
onChange: (value: string) => void;
}> = ({ label, value, onChange }) => {
return (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={label} className="text-right text-sm">
{label}
</Label>
<Input
id={label}
value={value}
onChange={(e) => onChange(e.target.value)}
className="col-span-3 font-mono text-sm"
placeholder="oklch(0.5 0.1 180)"
/>
</div>
);
};
// Custom theme creation modal
const CreateThemeModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSave: (theme: Omit<ThemeDefinition, "id" | "isCustom">) => void;
}> = ({ isOpen, onClose, onSave }) => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [mode, setMode] = useState<"light" | "dark">("dark");
const [colors, setColors] = useState<ThemeColors>({
background: "oklch(0.145 0 0)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.205 0 0)",
cardForeground: "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
popoverForeground: "oklch(0.985 0 0)",
primary: "oklch(0.922 0 0)",
primaryForeground: "oklch(0.205 0 0)",
secondary: "oklch(0.269 0 0)",
secondaryForeground: "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
mutedForeground: "oklch(0.708 0 0)",
accent: "oklch(0.269 0 0)",
accentForeground: "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
chart1: "oklch(0.488 0.243 264.376)",
chart2: "oklch(0.696 0.17 162.48)",
chart3: "oklch(0.769 0.188 70.08)",
chart4: "oklch(0.627 0.265 303.9)",
chart5: "oklch(0.645 0.246 16.439)",
sidebar: "oklch(0.205 0 0)",
sidebarForeground: "oklch(0.985 0 0)",
sidebarPrimary: "oklch(0.488 0.243 264.376)",
sidebarPrimaryForeground: "oklch(0.985 0 0)",
sidebarAccent: "oklch(0.269 0 0)",
sidebarAccentForeground: "oklch(0.985 0 0)",
sidebarBorder: "oklch(1 0 0 / 10%)",
sidebarRing: "oklch(0.556 0 0)",
});
const handleSave = () => {
if (!name.trim()) return;
onSave({
name: name.trim(),
description: description.trim() || undefined,
mode,
colors,
});
// Reset form
setName("");
setDescription("");
setMode("dark");
onClose();
};
const updateColor = (key: keyof ThemeColors, value: string) => {
setColors((prev) => ({ ...prev, [key]: value }));
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Custom Theme</DialogTitle>
<DialogDescription>
Create a custom theme by defining colors in OKLCH format (e.g.,
"oklch(0.5 0.1 180)")
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-96">
<div className="space-y-6 pr-4">
{/* Basic Info */}
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="theme-name" className="text-right">
Name
</Label>
<Input
id="theme-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="col-span-3"
placeholder="My Custom Theme"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="theme-description" className="text-right">
Description
</Label>
<Textarea
id="theme-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
placeholder="Optional description"
rows={2}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="theme-mode" className="text-right">
Mode
</Label>
<Select
value={mode}
onValueChange={(v: "light" | "dark") => setMode(v)}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* Color sections */}
<div className="space-y-4">
<h4 className="font-medium">Basic Colors</h4>
<div className="space-y-3">
<ColorInput
label="Background"
value={colors.background}
onChange={(v) => updateColor("background", v)}
/>
<ColorInput
label="Foreground"
value={colors.foreground}
onChange={(v) => updateColor("foreground", v)}
/>
<ColorInput
label="Primary"
value={colors.primary}
onChange={(v) => updateColor("primary", v)}
/>
<ColorInput
label="Secondary"
value={colors.secondary}
onChange={(v) => updateColor("secondary", v)}
/>
</div>
<Separator />
<h4 className="font-medium">Sidebar Colors</h4>
<div className="space-y-3">
<ColorInput
label="Sidebar"
value={colors.sidebar}
onChange={(v) => updateColor("sidebar", v)}
/>
<ColorInput
label="Sidebar Primary"
value={colors.sidebarPrimary}
onChange={(v) => updateColor("sidebarPrimary", v)}
/>
<ColorInput
label="Sidebar Accent"
value={colors.sidebarAccent}
onChange={(v) => updateColor("sidebarAccent", v)}
/>
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!name.trim()}>
Create Theme
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
// Main theme selector component as modal
export function ThemeSelector() {
const {
mode,
currentTheme,
setMode,
setTheme,
addCustomTheme,
removeCustomTheme,
getThemesForMode,
} = useTheme();
const [isMainModalOpen, setIsMainModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const lightThemes = getThemesForMode("light");
const darkThemes = getThemesForMode("dark");
const getCurrentModeIcon = () => {
switch (mode) {
case "light":
return <Sun className="h-4 w-4" />;
case "dark":
return <Moon className="h-4 w-4" />;
default:
return <Monitor className="h-4 w-4" />;
}
};
return (
<>
<Dialog open={isMainModalOpen} onOpenChange={setIsMainModalOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
Theme
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{getCurrentModeIcon()}
Appearance Settings
</DialogTitle>
<DialogDescription>
Choose your preferred theme and color scheme
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Current Theme Display */}
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div>
<p className="font-medium">{currentTheme.name}</p>
{currentTheme.description && (
<p className="text-sm text-muted-foreground">
{currentTheme.description}
</p>
)}
</div>
<Badge variant="secondary">Active</Badge>
</div>
{/* Mode Selection */}
<div className="space-y-3">
<Label className="text-sm font-medium">Display Mode</Label>
<div className="grid grid-cols-3 gap-2">
<Button
variant={mode === "light" ? "default" : "outline"}
size="sm"
onClick={() => setMode("light")}
className="flex flex-col gap-1 h-auto py-3"
>
<Sun className="h-4 w-4" />
<span className="text-xs">Light</span>
</Button>
<Button
variant={mode === "dark" ? "default" : "outline"}
size="sm"
onClick={() => setMode("dark")}
className="flex flex-col gap-1 h-auto py-3"
>
<Moon className="h-4 w-4" />
<span className="text-xs">Dark</span>
</Button>
<Button
variant={mode === "system" ? "default" : "outline"}
size="sm"
onClick={() => setMode("system")}
className="flex flex-col gap-1 h-auto py-3"
>
<Monitor className="h-4 w-4" />
<span className="text-xs">System</span>
</Button>
</div>
</div>
{/* Theme Selection */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Themes</Label>
<Button
variant="ghost"
size="sm"
onClick={() => setIsCreateModalOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
Create
</Button>
</div>
<ScrollArea className="max-h-64">
<div className="space-y-3">
{/* Light Themes */}
{lightThemes.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Light Themes
</p>
{lightThemes.map((theme) => (
<div
key={theme.id}
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
onClick={() => setTheme(theme.id)}
>
<div className="flex items-center gap-3">
<Palette className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{theme.name}
</p>
{theme.description && (
<p className="text-xs text-muted-foreground">
{theme.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{currentTheme.id === theme.id && (
<Badge variant="secondary" className="text-xs">
Active
</Badge>
)}
{theme.isCustom && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeCustomTheme(theme.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
))}
</div>
)}
{/* Dark Themes */}
{darkThemes.length > 0 && (
<div className="space-y-2">
{lightThemes.length > 0 && <Separator />}
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Dark Themes
</p>
{darkThemes.map((theme) => (
<div
key={theme.id}
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
onClick={() => setTheme(theme.id)}
>
<div className="flex items-center gap-3">
<Palette className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{theme.name}
</p>
{theme.description && (
<p className="text-xs text-muted-foreground">
{theme.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{currentTheme.id === theme.id && (
<Badge variant="secondary" className="text-xs">
Active
</Badge>
)}
{theme.isCustom && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeCustomTheme(theme.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMainModalOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CreateThemeModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSave={addCustomTheme}
/>
</>
);
}

View File

@@ -1,13 +1,13 @@
import * as React from "react"; import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />; return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@@ -15,7 +15,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
); )
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
@@ -26,7 +26,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
); )
} }
function DropdownMenuContent({ function DropdownMenuContent({
@@ -41,12 +41,12 @@ function DropdownMenuContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className, className
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
); )
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@@ -54,7 +54,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
); )
} }
function DropdownMenuItem({ function DropdownMenuItem({
@@ -63,8 +63,8 @@ function DropdownMenuItem({
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean
variant?: "default" | "destructive"; variant?: "default" | "destructive"
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -73,11 +73,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className
)} )}
checked={checked} checked={checked}
{...props} {...props}
@@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
); )
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
); )
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className
)} )}
{...props} {...props}
> >
@@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
); )
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@@ -146,7 +146,7 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean; inset?: boolean
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@@ -154,11 +154,11 @@ function DropdownMenuLabel({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@@ -171,7 +171,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
); )
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
@@ -183,17 +183,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />; return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@@ -202,22 +202,22 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean; inset?: boolean
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
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}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
); )
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@@ -229,11 +229,11 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
export { export {
@@ -252,4 +252,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
}; }

View File

@@ -1,419 +1,21 @@
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, Plus } from "lucide-react";
import {
Hash,
Volume2,
Users,
Pin,
MoreHorizontal,
Reply,
Plus,
} from "lucide-react";
import { formatDistanceToNow, isValid, parseISO } 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 SyntaxHighlighter from "react-syntax-highlighter";
import {
dark,
solarizedLight,
} from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useTheme } from "@/components/theme-provider";
// Updated imports for API integration
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers"; import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
import { import { useChannelMessages, useLoadMoreMessages } from "@/hooks/useMessages";
useChannelMessages,
useLoadMoreMessages,
useSendMessage,
} 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 } from "@/lib/api-client"; import { Message } from "@/lib/api-client";
import { MessageInput } from "@/components/message/MessageInput";
// Modal imports
import { MessageActionsModal } from "@/components/modals/MessageActionsModal";
// User type for message component
interface MessageUser {
id: string;
username?: string;
userName?: string;
nickname?: string | null;
nickName?: string | null;
picture?: string | null;
}
// Message Props interface
interface MessageProps {
message: Message;
user: MessageUser;
currentUser: any;
replyTo?: Message;
replyToUser?: MessageUser;
onReply?: (messageId: string) => void;
}
const MessageComponent: React.FC<MessageProps> = ({
message,
user,
currentUser,
replyTo,
replyToUser,
onReply,
}) => {
const [isHovered, setIsHovered] = useState(false);
const [showActionsModal, setShowActionsModal] = useState(false);
const formatTimestamp = (timestamp: string) => {
try {
// First try parsing as ISO string
let date = parseISO(timestamp);
// If that fails, try regular Date constructor
if (!isValid(date)) {
date = new Date(timestamp);
}
// Final check if date is valid
if (!isValid(date)) {
console.error("Invalid timestamp:", timestamp);
return "Invalid date";
}
return formatDistanceToNow(date, { addSuffix: true });
} catch (error) {
console.error("Error formatting timestamp:", timestamp, error);
return "Invalid date";
}
};
const isOwnMessage = currentUser?.id === message.userId;
const { mode } = useTheme();
// Get username with fallback
const username = user.username || user.userName || "Unknown User";
const displayName = user.nickname || user.nickName || username;
const isDeleted = message.deleted;
if (isDeleted) {
return (
<div className="px-4 py-2 opacity-50">
<div className="flex gap-3">
<div className="w-10 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-concord-secondary italic border border-border rounded px-3 py-2 bg-concord-tertiary/50">
This message has been deleted
</div>
</div>
</div>
</div>
);
}
return (
<>
<div
className="group relative px-4 py-2 hover:bg-concord-secondary/50 transition-colors"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex gap-3">
{/* Avatar - always show */}
<div className="w-10 flex-shrink-0">
<Avatar className="h-10 w-10">
<AvatarImage src={user.picture || undefined} alt={username} />
<AvatarFallback className="text-sm bg-primary text-primary-foreground">
{username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
{/* Message content */}
<div className="flex-1 min-w-0">
{/* Reply line and reference */}
{replyTo && replyToUser && (
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary">
{replyToUser.nickname ||
replyToUser.nickName ||
replyToUser.username ||
replyToUser.userName}
</span>
<span className="truncate max-w-xs opacity-75">
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</span>
</div>
)}
{/* Reply line and reference */}
{replyTo && replyToUser && (
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary">
{replyToUser.nickname ||
replyToUser.nickName ||
replyToUser.username ||
replyToUser.userName}
</span>
<span className="truncate max-w-xs opacity-75">
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</span>
</div>
)}
{/* Header - always show */}
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-concord-primary">
{displayName}
</span>
<span className="text-xs text-concord-secondary">
{formatTimestamp(message.createdAt)}
</span>
{message.edited && (
<span className="text-xs text-concord-secondary opacity-60">
(edited)
</span>
)}
{(message as any).pinned && (
<Pin className="h-3 w-3 text-yellow-500" />
)}
</div>
{/* Message content with markdown */}
<div className="text-concord-primary leading-relaxed prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown
components={{
code: ({ className, children }) => {
const match = /language-(\w+)/.exec(className || "");
return match ? (
<div className="flex flex-row flex-1 max-w-2/3 flex-wrap !bg-transparent">
<SyntaxHighlighter
PreTag="div"
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={mode === "light" ? solarizedLight : dark}
className="!bg-concord-secondary p-2 border-2 concord-border rounded-xl"
/>
</div>
) : (
<code className={className}>{children}</code>
);
},
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary pl-4 my-2 italic text-concord-secondary bg-concord-secondary/30 py-2 rounded-r">
{children}
</blockquote>
),
p: ({ children }) => (
<p className="my-1 text-concord-primary">{children}</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-concord-primary">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-concord-primary">{children}</em>
),
ul: ({ children }) => (
<ul className="list-disc list-inside my-2 text-concord-primary">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside my-2 text-concord-primary">
{children}
</ol>
),
h1: ({ children }) => (
<h1 className="text-xl font-bold my-2 text-concord-primary">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-bold my-2 text-concord-primary">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-bold my-2 text-concord-primary">
{children}
</h3>
),
a: ({ children, href }) => (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
}}
>
{message.text}
</ReactMarkdown>
</div>
</div>
{/* Message actions */}
{isHovered && (
<div className="absolute top-0 right-4 bg-concord-secondary border border-border rounded-md shadow-md flex">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 interactive-hover"
onClick={() => onReply?.(message.id)}
>
<Reply className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 interactive-hover"
onClick={() => setShowActionsModal(true)}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
{/* Message Actions Modal */}
<MessageActionsModal
isOpen={showActionsModal}
onClose={() => setShowActionsModal(false)}
message={message}
isOwnMessage={isOwnMessage}
onReply={onReply}
/>
</>
);
};
// Message Input Component
interface MessageInputProps {
channelId: string;
channelName?: string;
replyingTo?: Message | null;
onCancelReply?: () => void;
replyingToUser: MessageUser | null;
}
const MessageInput: React.FC<MessageInputProps> = ({
channelId,
channelName,
replyingTo,
onCancelReply,
replyingToUser,
}) => {
const [content, setContent] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null);
// Use the API hook for sending messages
const sendMessageMutation = useSendMessage();
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [content]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (content.trim() && !sendMessageMutation.isPending) {
try {
await sendMessageMutation.mutateAsync({
channelId,
content: content.trim(),
repliedMessageId: replyingTo?.id || null,
});
setContent("");
onCancelReply?.();
} catch (error) {
console.error("Failed to send message:", error);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
formRef.current?.requestSubmit();
}
};
return (
<div className="px-4 pb-4">
{replyingTo && replyingToUser && (
<div className="mb-2 p-3 bg-concord-secondary rounded-lg border border-b-0 border-border">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary">
{replyingToUser.nickname ||
replyingToUser.nickName ||
replyingToUser.username ||
replyingToUser.userName}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-auto p-1 text-concord-secondary hover:text-concord-primary"
onClick={onCancelReply}
>
×
</Button>
</div>
<div className="text-sm text-concord-primary truncate pl-2">
{replyingTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</div>
</div>
)}
<form ref={formRef} onSubmit={handleSubmit}>
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message #${channelName || "channel"}`}
disabled={sendMessageMutation.isPending}
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
style={{
minHeight: "44px",
maxHeight: "200px",
}}
/>
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
{sendMessageMutation.isPending
? "Sending..."
: "Press Enter to send • Shift+Enter for new line"}
</div>
</div>
</form>
</div>
);
};
const ChatPage: React.FC = () => { const ChatPage: React.FC = () => {
const { instanceId, channelId } = useParams(); const { instanceId, channelId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL LOGIC
// API hooks - called unconditionally
const { const {
data: instance, data: instance,
isLoading: instanceLoading, isLoading: instanceLoading,
@@ -618,7 +220,7 @@ 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">
{/* Load More Button */} {/* Load More Button */}
@@ -670,7 +272,7 @@ const ChatPage: React.FC = () => {
console.log(message); console.log(message);
const user = users?.find((u) => u.id === message.userId); const user = users?.find((u) => u.id === message.userId);
const replyToMessage = channelMessages?.find( const replyToMessage = channelMessages?.find(
(m) => m.id === (message as any).repliedMessageId, (m) => m.id === message.replies?.repliesToId,
); );
const replyToUser = replyToMessage const replyToUser = replyToMessage
? users?.find((u) => u.id === replyToMessage.userId) ? users?.find((u) => u.id === replyToMessage.userId)
@@ -683,7 +285,6 @@ const ChatPage: React.FC = () => {
key={message.id} key={message.id}
message={message} message={message}
user={user} user={user}
currentUser={currentUser}
replyTo={replyToMessage} replyTo={replyToMessage}
onReply={handleReply} onReply={handleReply}
replyToUser={replyToUser} replyToUser={replyToUser}

View File

@@ -1,21 +1,18 @@
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,
Moon, Moon,
Sun, Sun,
Monitor, Monitor,
Lock,
} from "lucide-react"; } from "lucide-react";
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 {
@@ -26,7 +23,6 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { ThemeSelector } from "@/components/theme-selector";
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";
@@ -45,12 +41,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
icon: User, icon: User,
description: "Profile, privacy, and account settings", description: "Profile, privacy, and account settings",
}, },
// {
// id: "security",
// title: "Security",
// icon: Lock,
// description: "Password and security settings",
// },
{ {
id: "appearance", id: "appearance",
title: "Appearance", title: "Appearance",
@@ -65,207 +55,8 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
}, },
]; ];
const SecuritySettings: React.FC = () => {
const { user } = useAuthStore();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [passwordError, setPasswordError] = useState("");
const [passwordSuccess, setPasswordSuccess] = useState("");
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError("");
setPasswordSuccess("");
if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError("All password fields are required");
return;
}
if (newPassword !== confirmPassword) {
setPasswordError("New passwords do not match");
return;
}
if (newPassword.length < 8) {
setPasswordError("New password must be at least 8 characters long");
return;
}
setIsChangingPassword(true);
try {
// TODO: Implement actual password change API call
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
console.log("Changing password for user:", user?.id);
// const result = await authClient.changePassword({
// userId: user.id,
// currentPassword,
// newPassword,
// token: authStore.token
// });
setPasswordSuccess("Password changed successfully");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (error) {
setPasswordError(
"Failed to change password. Please check your current password.",
);
} finally {
setIsChangingPassword(false);
}
};
return (
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="h-5 w-5" />
Change Password
</CardTitle>
<CardDescription>
Update your password to keep your account secure.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{passwordError && (
<Alert variant="destructive">
<AlertDescription>{passwordError}</AlertDescription>
</Alert>
)}
{passwordSuccess && (
<Alert>
<AlertDescription>{passwordSuccess}</AlertDescription>
</Alert>
)}
<form onSubmit={handlePasswordChange} className="space-y-4">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="max-w-sm"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="max-w-sm"
minLength={8}
required
/>
<p className="text-xs text-muted-foreground">
Must be at least 8 characters long
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="max-w-sm"
required
/>
</div>
</div>
<div className="mt-6">
<Button
type="submit"
disabled={
isChangingPassword ||
!currentPassword ||
!newPassword ||
!confirmPassword
}
>
{isChangingPassword
? "Changing Password..."
: "Change Password"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Security Options
</CardTitle>
<CardDescription>
Additional security features for your account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Two-Factor Authentication
</Label>
<p className="text-sm text-muted-foreground">
Add an extra layer of security to your account
</p>
</div>
<Switch
checked={twoFactorEnabled}
onCheckedChange={setTwoFactorEnabled}
/>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-base font-medium">Active Sessions</Label>
<p className="text-sm text-muted-foreground mb-4">
Manage devices that are currently logged into your account
</p>
<Button variant="outline" size="sm">
View Active Sessions
</Button>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-base font-medium">Account Backup</Label>
<p className="text-sm text-muted-foreground mb-4">
Download a copy of your account data
</p>
<Button variant="outline" size="sm">
Request Data Export
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
const AccountSettings: React.FC = () => { const AccountSettings: React.FC = () => {
const { user, updateUser } = useAuthStore(); const { user } = useAuthStore();
const [username, setUsername] = useState(user?.username || ""); const [username, setUsername] = useState(user?.username || "");
const [nickname, setNickname] = useState(user?.nickname || ""); const [nickname, setNickname] = useState(user?.nickname || "");
const [bio, setBio] = useState(user?.bio || ""); const [bio, setBio] = useState(user?.bio || "");
@@ -281,9 +72,9 @@ const AccountSettings: React.FC = () => {
try { try {
// TODO: Implement actual profile update API call // TODO: Implement actual profile update API call
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call // await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
console.log("Updating profile:", { username, nickname, bio }); // console.log("Updating profile:", { username, nickname, bio });
// const updatedUser = await userClient.updateProfile({ // const updatedUser = await userClient.updateProfile({
// userId: user.id, // userId: user.id,
// username: username.trim(), // username: username.trim(),
@@ -293,11 +84,11 @@ const AccountSettings: React.FC = () => {
// }); // });
// Update local state // Update local state
updateUser({ // updateUser({
username: username.trim(), // username: username.trim(),
nickname: nickname.trim() || null, // nickname: nickname.trim() || null,
bio: bio.trim() || null, // bio: bio.trim() || null,
}); // });
setSaveSuccess("Profile updated successfully"); setSaveSuccess("Profile updated successfully");
setIsChanged(false); setIsChanged(false);
@@ -315,7 +106,7 @@ const AccountSettings: React.FC = () => {
}; };
return ( return (
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3"> <div className="space-y-6 flex flex-col justify-center self-center items-stretch w-full">
<Card className="w-full p-6"> <Card className="w-full p-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -404,50 +195,10 @@ const AccountSettings: React.FC = () => {
</div> </div>
</CardContent> </CardContent>
</Card> </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>
); );
}; };
// Appearance Settings Component (keeping existing implementation)
const AppearanceSettings: React.FC = () => { const AppearanceSettings: React.FC = () => {
const { const {
currentLightTheme, currentLightTheme,
@@ -559,18 +310,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">
@@ -727,14 +466,9 @@ const AppearanceSettings: React.FC = () => {
); );
}; };
// Voice Settings Component
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">
@@ -774,69 +508,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>
@@ -846,13 +517,12 @@ const VoiceSettings: React.FC = () => {
const SettingsPage: React.FC = () => { const SettingsPage: React.FC = () => {
const { section } = useParams(); const { section } = useParams();
const currentSection = section || "account"; const currentSection = section || "account";
const navigate = useNavigate();
const renderSettingsContent = () => { const renderSettingsContent = () => {
switch (currentSection) { switch (currentSection) {
case "account": case "account":
return <AccountSettings />; return <AccountSettings />;
case "security":
return <SecuritySettings />;
case "appearance": case "appearance":
return <AppearanceSettings />; return <AppearanceSettings />;
case "voice": case "voice":
@@ -882,10 +552,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>
@@ -896,7 +567,7 @@ const SettingsPage: React.FC = () => {
)} )}
</div> </div>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</a> </div>
</Button> </Button>
); );
})} })}