harma-boutique-2026 commited on
Commit
30bc573
·
verified ·
1 Parent(s): c521e4a

Upload 26 files

Browse files
README.md CHANGED
@@ -1,43 +1,43 @@
1
- ---
2
- title: School Notes Archives
3
- emoji: 📚
4
- colorFrom: blue
5
- colorTo: indigo
6
- sdk: docker
7
- app_port: 3000
8
- pinned: false
9
- ---
10
-
11
- A private study workspace for organizing notes, uploading documents, and collaborating in subject-based group chats.
12
-
13
- ## Features
14
-
15
- - Subject-based notes archive
16
- - Image and `.docx` uploads
17
- - Searchable notes
18
- - Group chat rooms
19
- - Audio file and microphone voice-message attachments (audio up to 2 minutes, video up to 10 seconds)
20
- - User and admin controls
21
- - Persistent storage under `/Archives`
22
-
23
- ## Run locally
24
-
25
- **Prerequisites:** Node.js 22 or later
26
-
27
- ```bash
28
- npm install
29
- npm run dev
30
- ```
31
-
32
- The application runs at `http://localhost:3000`.
33
-
34
- ## Production build
35
-
36
- ```bash
37
- npm run build
38
- npm start
39
- ```
40
-
41
- For Hugging Face Spaces, the Docker Space should attach persistent storage at `/Archives` so uploaded files, call history, and application data survive restarts. The Gemini API key is not required for the core notes archive; configure any required secrets in the Space settings.
42
-
43
- Chat supports audio uploads, browser microphone recording, and short video uploads through the existing Node/Express API. Audio is limited to 2 minutes and video to 10 seconds. Microphone recording requires a secure browser context, which the HTTPS URL of a deployed Hugging Face Space provides. No Python or FastRTC service is required for this Docker-based app.
 
1
+ ---
2
+ title: School Notes Archives
3
+ emoji: 📚
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 3000
8
+ pinned: false
9
+ ---
10
+
11
+ A private study workspace for organizing notes, uploading documents, and collaborating in subject-based group chats.
12
+
13
+ ## Features
14
+
15
+ - Subject-based notes archive
16
+ - Image and `.docx` uploads
17
+ - Searchable notes
18
+ - Group chat rooms
19
+ - Audio file and microphone voice-message attachments (audio up to 2 minutes, video up to 10 seconds)
20
+ - User and admin controls
21
+ - Persistent storage under `/Archives`
22
+
23
+ ## Run locally
24
+
25
+ **Prerequisites:** Node.js 22 or later
26
+
27
+ ```bash
28
+ npm install
29
+ npm run dev
30
+ ```
31
+
32
+ The application runs at `http://localhost:3000`.
33
+
34
+ ## Production build
35
+
36
+ ```bash
37
+ npm run build
38
+ npm start
39
+ ```
40
+
41
+ For Hugging Face Spaces, the Docker Space should attach persistent storage at `/Archives` so uploaded files, call history, and application data survive restarts. The Gemini API key is not required for the core notes archive; configure any required secrets in the Space settings.
42
+
43
+ Chat supports audio uploads, browser microphone recording, and short video uploads through the existing Node/Express API. Audio is limited to 2 minutes and video to 10 seconds. Microphone recording requires a secure browser context, which the HTTPS URL of a deployed Hugging Face Space provides. No Python or FastRTC service is required for this Docker-based app.
bun.lock CHANGED
@@ -7,14 +7,16 @@
7
  "dependencies": {
8
  "@google/genai": "^2.4.0",
9
  "@tailwindcss/vite": "^4.1.14",
 
 
10
  "@vitejs/plugin-react": "^5.0.4",
11
  "dotenv": "^17.2.3",
12
  "express": "^4.21.2",
13
  "lucide-react": "^0.546.0",
14
  "motion": "^12.23.24",
15
  "multer": "^2.3.0",
16
- "react": "^19.0.1",
17
- "react-dom": "^19.0.1",
18
  "vite": "^6.2.3",
19
  },
20
  "devDependencies": {
@@ -261,10 +263,16 @@
261
 
262
  "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
263
 
 
 
264
  "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
265
 
266
  "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
267
 
 
 
 
 
268
  "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
269
 
270
  "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
@@ -319,6 +327,8 @@
319
 
320
  "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
321
 
 
 
322
  "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
323
 
324
  "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -453,6 +463,8 @@
453
 
454
  "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
455
 
 
 
456
  "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
457
 
458
  "lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
@@ -521,9 +533,9 @@
521
 
522
  "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
523
 
524
- "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
525
 
526
- "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
527
 
528
  "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
529
 
@@ -537,7 +549,7 @@
537
 
538
  "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
539
 
540
- "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
541
 
542
  "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
543
 
 
7
  "dependencies": {
8
  "@google/genai": "^2.4.0",
9
  "@tailwindcss/vite": "^4.1.14",
10
+ "@types/react": "^18.3.12",
11
+ "@types/react-dom": "^18.3.1",
12
  "@vitejs/plugin-react": "^5.0.4",
13
  "dotenv": "^17.2.3",
14
  "express": "^4.21.2",
15
  "lucide-react": "^0.546.0",
16
  "motion": "^12.23.24",
17
  "multer": "^2.3.0",
18
+ "react": "^18.3.1",
19
+ "react-dom": "^18.3.1",
20
  "vite": "^6.2.3",
21
  },
22
  "devDependencies": {
 
263
 
264
  "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
265
 
266
+ "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
267
+
268
  "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
269
 
270
  "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
271
 
272
+ "@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
273
+
274
+ "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
275
+
276
  "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
277
 
278
  "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
 
327
 
328
  "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
329
 
330
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
331
+
332
  "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
333
 
334
  "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
 
463
 
464
  "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
465
 
466
+ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
467
+
468
  "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
469
 
470
  "lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
 
533
 
534
  "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
535
 
536
+ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
537
 
538
+ "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
539
 
540
  "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
541
 
 
549
 
550
  "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
551
 
552
+ "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
553
 
554
  "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
555
 
index.html CHANGED
@@ -1,18 +1,17 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>School Notes Archives</title>
7
- <meta name="description" content="Secure academic notes repository with 9 subject folders, study group chat, document search, dark mode, and admin controls." />
8
- <meta property="og:title" content="School Notes Archives" />
9
- <meta property="og:description" content="Secure academic notes repository with 9 subject folders, study group chat, document search, dark mode, and admin controls." />
10
- <meta property="og:type" content="website" />
11
- <meta name="twitter:card" content="summary_large_image" />
12
- </head>
13
- <body>
14
- <div id="root"></div>
15
- <script type="module" src="/src/main.tsx"></script>
16
- </body>
17
- </html>
18
-
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>School Notes Archives</title>
7
+ <meta name="description" content="Secure academic notes repository with 9 subject folders, study group chat, document search, dark mode, and admin controls." />
8
+ <meta property="og:title" content="School Notes Archives" />
9
+ <meta property="og:description" content="Secure academic notes repository with 9 subject folders, study group chat, document search, dark mode, and admin controls." />
10
+ <meta property="og:type" content="website" />
11
+ <meta name="twitter:card" content="summary_large_image" />
12
+ </head>
13
+ <body>
14
+ <div id="root"></div>
15
+ <script type="module" src="/src/main.tsx"></script>
16
+ </body>
17
+ </html>
 
package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -14,14 +14,16 @@
14
  "dependencies": {
15
  "@google/genai": "^2.4.0",
16
  "@tailwindcss/vite": "^4.1.14",
 
 
17
  "@vitejs/plugin-react": "^5.0.4",
18
  "dotenv": "^17.2.3",
19
  "express": "^4.21.2",
20
  "lucide-react": "^0.546.0",
21
  "motion": "^12.23.24",
22
  "multer": "^2.3.0",
23
- "react": "^19.0.1",
24
- "react-dom": "^19.0.1",
25
  "vite": "^6.2.3"
26
  },
27
  "devDependencies": {
 
14
  "dependencies": {
15
  "@google/genai": "^2.4.0",
16
  "@tailwindcss/vite": "^4.1.14",
17
+ "@types/react": "^18.3.12",
18
+ "@types/react-dom": "^18.3.1",
19
  "@vitejs/plugin-react": "^5.0.4",
20
  "dotenv": "^17.2.3",
21
  "express": "^4.21.2",
22
  "lucide-react": "^0.546.0",
23
  "motion": "^12.23.24",
24
  "multer": "^2.3.0",
25
+ "react": "^18.3.1",
26
+ "react-dom": "^18.3.1",
27
  "vite": "^6.2.3"
28
  },
29
  "devDependencies": {
server.ts CHANGED
@@ -21,6 +21,7 @@ const CHATS_FILE = path.join(ARCHIVES_DIR, 'chats.json');
21
  const NOTIFICATIONS_FILE = path.join(ARCHIVES_DIR, 'notifications.json');
22
  const GROUPS_FILE = path.join(ARCHIVES_DIR, 'groups.json');
23
  const CALLS_FILE = path.join(ARCHIVES_DIR, 'calls.json');
 
24
 
25
  // Ensure directories exist
26
  function ensureDirsAndFiles() {
@@ -135,6 +136,10 @@ function ensureDirsAndFiles() {
135
  if (!fs.existsSync(CALLS_FILE)) {
136
  fs.writeFileSync(CALLS_FILE, JSON.stringify([], null, 2), 'utf-8');
137
  }
 
 
 
 
138
  }
139
 
140
  ensureDirsAndFiles();
@@ -158,12 +163,17 @@ function writeJson(filePath: string, data: any) {
158
  }
159
  }
160
 
161
- // Memory session tokens
162
- const sessionTokens = new Map<string, string>(); // token -> username
 
 
 
163
 
164
  function generateToken(username: string): string {
165
  const token = crypto.randomBytes(24).toString('hex');
166
- sessionTokens.set(token, username);
 
 
167
  return token;
168
  }
169
 
@@ -177,7 +187,7 @@ function authMiddleware(req: Request, res: Response, next: NextFunction) {
177
  return;
178
  }
179
 
180
- const username = sessionTokens.get(token);
181
  if (!username) {
182
  res.status(401).json({ error: 'Invalid or expired session token' });
183
  return;
@@ -656,9 +666,9 @@ app.post('/api/calls', authMiddleware, (req: Request, res: Response) => {
656
 
657
  const calls = readJson<any[]>(CALLS_FILE, []);
658
  const today = new Date().toISOString().split('T')[0];
659
- const calledToday = calls.some((call) => call.caller.toLowerCase() === currentUser.username.toLowerCase() && callDate(call.startedAt) === today);
660
- if (calledToday) {
661
- res.status(400).json({ error: 'You can start only one voice call per day.' });
662
  return;
663
  }
664
 
@@ -761,8 +771,9 @@ app.post('/api/chats/upload-file', authMiddleware, upload.single('file'), (req:
761
  }
762
 
763
  const ext = path.extname(file.originalname).toLowerCase();
764
- const isVideo = file.mimetype.startsWith('video/') || ['.mp4', '.webm', '.mov', '.avi', '.mkv'].includes(ext);
765
- const isAudio = file.mimetype.startsWith('audio/');
 
766
  const mediaDuration = req.body.mediaDuration ? parseFloat(req.body.mediaDuration) : undefined;
767
 
768
  if (isVideo && mediaDuration !== undefined && (!Number.isFinite(mediaDuration) || mediaDuration > 10)) {
@@ -781,7 +792,7 @@ app.post('/api/chats/upload-file', authMiddleware, upload.single('file'), (req:
781
  if (file.mimetype.startsWith('image/')) fileType = 'image';
782
  else if (ext === '.docx' || file.mimetype.includes('wordprocessingml')) fileType = 'docx';
783
  else if (isVideo) fileType = 'video';
784
- else if (file.mimetype.startsWith('audio/')) fileType = 'audio';
785
 
786
  const fileUrl = `/Archives/uploads/${file.filename}`;
787
 
 
21
  const NOTIFICATIONS_FILE = path.join(ARCHIVES_DIR, 'notifications.json');
22
  const GROUPS_FILE = path.join(ARCHIVES_DIR, 'groups.json');
23
  const CALLS_FILE = path.join(ARCHIVES_DIR, 'calls.json');
24
+ const SESSIONS_FILE = path.join(ARCHIVES_DIR, 'sessions.json');
25
 
26
  // Ensure directories exist
27
  function ensureDirsAndFiles() {
 
136
  if (!fs.existsSync(CALLS_FILE)) {
137
  fs.writeFileSync(CALLS_FILE, JSON.stringify([], null, 2), 'utf-8');
138
  }
139
+
140
+ if (!fs.existsSync(SESSIONS_FILE)) {
141
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify({}, null, 2), 'utf-8');
142
+ }
143
  }
144
 
145
  ensureDirsAndFiles();
 
163
  }
164
  }
165
 
166
+ // Persistent session tokens
167
+ function getUsernameForToken(token: string): string | undefined {
168
+ const sessions = readJson<Record<string, string>>(SESSIONS_FILE, {});
169
+ return sessions[token];
170
+ }
171
 
172
  function generateToken(username: string): string {
173
  const token = crypto.randomBytes(24).toString('hex');
174
+ const sessions = readJson<Record<string, string>>(SESSIONS_FILE, {});
175
+ sessions[token] = username;
176
+ writeJson(SESSIONS_FILE, sessions);
177
  return token;
178
  }
179
 
 
187
  return;
188
  }
189
 
190
+ const username = getUsernameForToken(token);
191
  if (!username) {
192
  res.status(401).json({ error: 'Invalid or expired session token' });
193
  return;
 
666
 
667
  const calls = readJson<any[]>(CALLS_FILE, []);
668
  const today = new Date().toISOString().split('T')[0];
669
+ const callsTodayCount = calls.filter((call) => call.caller.toLowerCase() === currentUser.username.toLowerCase() && callDate(call.startedAt) === today).length;
670
+ if (callsTodayCount >= 3) {
671
+ res.status(400).json({ error: 'You can start a maximum of 3 voice calls per day.' });
672
  return;
673
  }
674
 
 
771
  }
772
 
773
  const ext = path.extname(file.originalname).toLowerCase();
774
+ const audioExts = ['.webm', '.ogg', '.mp3', '.wav', '.m4a', '.aac', '.opus'];
775
+ const isAudio = file.mimetype.startsWith('audio/') || (audioExts.includes(ext) && !file.mimetype.startsWith('video/'));
776
+ const isVideo = (file.mimetype.startsWith('video/') || ['.mp4', '.mov', '.avi', '.mkv'].includes(ext)) && !isAudio;
777
  const mediaDuration = req.body.mediaDuration ? parseFloat(req.body.mediaDuration) : undefined;
778
 
779
  if (isVideo && mediaDuration !== undefined && (!Number.isFinite(mediaDuration) || mediaDuration > 10)) {
 
792
  if (file.mimetype.startsWith('image/')) fileType = 'image';
793
  else if (ext === '.docx' || file.mimetype.includes('wordprocessingml')) fileType = 'docx';
794
  else if (isVideo) fileType = 'video';
795
+ else if (isAudio) fileType = 'audio';
796
 
797
  const fileUrl = `/Archives/uploads/${file.filename}`;
798
 
src/App.tsx CHANGED
@@ -64,6 +64,12 @@ export default function App() {
64
  };
65
 
66
  checkAutoLogin();
 
 
 
 
 
 
67
  }, []);
68
 
69
  // Fetch Notes
 
64
  };
65
 
66
  checkAutoLogin();
67
+
68
+ const handleUnauthorized = () => {
69
+ setCurrentUser(null);
70
+ };
71
+ window.addEventListener('auth_unauthorized', handleUnauthorized);
72
+ return () => window.removeEventListener('auth_unauthorized', handleUnauthorized);
73
  }, []);
74
 
75
  // Fetch Notes
src/components/ChatRoom.tsx CHANGED
@@ -3,8 +3,6 @@ import {
3
  MessageSquare,
4
  Send,
5
  Paperclip,
6
- Mic,
7
- Square,
8
  Volume2,
9
  VolumeX,
10
  Users,
@@ -12,12 +10,20 @@ import {
12
  UserMinus,
13
  Video,
14
  FileText,
 
15
  AlertCircle,
 
16
  Sparkles,
17
  Download,
 
 
 
 
 
18
  } from 'lucide-react';
19
- import { User, ChatMessage, GroupInfo } from '../types';
20
  import { api } from '../services/api';
 
21
 
22
  interface ChatRoomProps {
23
  currentUser: User;
@@ -32,26 +38,182 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
32
  const [talkEffectEnabled, setTalkEffectEnabled] = useState(false);
33
  const [uploading, setUploading] = useState(false);
34
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
35
- const [isRecording, setIsRecording] = useState(false);
36
- const [mediaDuration, setMediaDuration] = useState<number | null>(null);
37
  const [fileError, setFileError] = useState<string | null>(null);
38
  const [chatError, setChatError] = useState<string | null>(null);
39
 
40
  const messagesEndRef = useRef<HTMLDivElement>(null);
41
  const lastReadChatIdRef = useRef<string | null>(null);
42
- const mediaRecorderRef = useRef<MediaRecorder | null>(null);
43
- const recordedChunksRef = useRef<Blob[]>([]);
44
- const recordingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
45
- const recordingStartedAtRef = useRef<number | null>(null);
46
 
47
  const joinedGroups = currentUser.joinedGroups || ['General Lobby'];
48
  const isJoined = joinedGroups.includes(selectedGroup);
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  // Load Groups List
51
  useEffect(() => {
52
  api.getGroups().then((g) => setGroups(g)).catch(() => {});
53
  }, []);
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  // Poll Chats every 2 seconds for live real-time updates
56
  useEffect(() => {
57
  let isMounted = true;
@@ -98,97 +260,41 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
98
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
99
  }, [chats]);
100
 
101
- useEffect(() => {
102
- return () => {
103
- mediaRecorderRef.current?.stream.getTracks().forEach((track) => track.stop());
104
- if (recordingTimerRef.current) clearTimeout(recordingTimerRef.current);
105
- };
106
- }, []);
107
-
108
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
109
  setFileError(null);
110
- setMediaDuration(null);
111
 
112
  if (e.target.files && e.target.files[0]) {
113
  const file = e.target.files[0];
114
- const isVideo = file.type.startsWith('video/');
115
- const isAudio = file.type.startsWith('audio/');
116
-
117
- if (isVideo || isAudio) {
118
- const media = document.createElement(isVideo ? 'video' : 'audio');
119
- const objectUrl = URL.createObjectURL(file);
120
- media.preload = 'metadata';
121
- media.src = objectUrl;
122
- media.onloadedmetadata = () => {
123
- URL.revokeObjectURL(objectUrl);
124
- const duration = media.duration;
125
- const maxDuration = isVideo ? 10 : 120;
126
- if (!Number.isFinite(duration) || duration > maxDuration) {
127
- setFileError(`${isVideo ? 'Video' : 'Audio'} is limited to ${maxDuration} seconds.`);
 
128
  setSelectedFile(null);
129
  return;
130
  }
131
- setMediaDuration(duration);
132
  setSelectedFile(file);
133
  };
134
- media.onerror = () => {
135
- URL.revokeObjectURL(objectUrl);
136
- setFileError('Could not read the media file duration.');
137
  setSelectedFile(null);
138
  };
139
- return;
 
140
  }
141
- setSelectedFile(file);
142
- }
143
- };
144
-
145
- const handleToggleRecording = async () => {
146
- if (isRecording) {
147
- mediaRecorderRef.current?.stop();
148
- return;
149
- }
150
-
151
- if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
152
- setFileError('Audio recording is not supported by this browser.');
153
- return;
154
- }
155
-
156
- try {
157
- setFileError(null);
158
- setSelectedFile(null);
159
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
160
- const recorder = new MediaRecorder(stream);
161
- recordedChunksRef.current = [];
162
- mediaRecorderRef.current = recorder;
163
-
164
- recorder.ondataavailable = (event) => {
165
- if (event.data.size > 0) recordedChunksRef.current.push(event.data);
166
- };
167
- recorder.onstop = () => {
168
- const extension = recorder.mimeType.includes('ogg') ? 'ogg' : 'webm';
169
- const recordedDuration = recordingStartedAtRef.current
170
- ? (Date.now() - recordingStartedAtRef.current) / 1000
171
- : undefined;
172
- const audioFile = new File(
173
- [new Blob(recordedChunksRef.current, { type: recorder.mimeType })],
174
- `voice-message-${Date.now()}.${extension}`,
175
- { type: recorder.mimeType },
176
- );
177
- setSelectedFile(audioFile);
178
- setMediaDuration(recordedDuration ?? null);
179
- stream.getTracks().forEach((track) => track.stop());
180
- mediaRecorderRef.current = null;
181
- recordingStartedAtRef.current = null;
182
- recordingTimerRef.current = null;
183
- setIsRecording(false);
184
- };
185
-
186
- recorder.start();
187
- recordingStartedAtRef.current = Date.now();
188
- setIsRecording(true);
189
- recordingTimerRef.current = setTimeout(() => recorder.stop(), 120000);
190
- } catch {
191
- setFileError('Microphone access was denied or unavailable.');
192
  }
193
  };
194
 
@@ -204,14 +310,14 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
204
  let attachment = undefined;
205
 
206
  if (selectedFile) {
207
- attachment = await api.uploadChatFile(selectedFile, mediaDuration || undefined);
208
  }
209
 
210
  await api.sendChatMessage(selectedGroup, messageText, attachment);
211
 
212
  setMessageText('');
213
  setSelectedFile(null);
214
- setMediaDuration(null);
215
 
216
  // Refresh chats
217
  const updated = await api.getChats(selectedGroup);
@@ -319,7 +425,30 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
319
  </p>
320
  </div>
321
 
322
- <div className="flex items-center gap-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  <button
324
  onClick={handleToggleJoinGroup}
325
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
@@ -341,12 +470,30 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
341
  </div>
342
  </div>
343
 
344
- {/* Audio status banner */}
345
- <div className="px-4 py-2 bg-emerald-50 dark:bg-emerald-950/40 border-b border-emerald-200/60 dark:border-emerald-900/40 text-[11px] text-emerald-800 dark:text-emerald-300 flex items-center justify-between">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  <div className="flex items-center gap-1.5">
347
- <Mic className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
348
  <span>
349
- Audio up to <strong>2 minutes</strong> and video up to <strong>10 seconds</strong> are supported.
350
  </span>
351
  </div>
352
  {talkEffectEnabled && (
@@ -397,19 +544,29 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
397
  />
398
  )}
399
 
400
- {msg.attachment.fileType === 'audio' && (
401
- <audio src={msg.attachment.fileUrl} controls className="max-w-full" />
402
- )}
403
-
404
  {msg.attachment.fileType === 'video' && (
405
  <div className="space-y-1">
406
- <video src={msg.attachment.fileUrl} controls className="max-h-48 rounded-lg w-full bg-black" />
 
 
 
 
407
  <p className="text-[10px] opacity-80 flex items-center gap-1">
408
- <Video className="w-3 h-3" /> 10-second limit
409
  </p>
410
  </div>
411
  )}
412
 
 
 
 
 
 
 
 
 
 
 
413
  {['docx', 'other'].includes(msg.attachment.fileType) && (
414
  <a
415
  href={msg.attachment.fileUrl}
@@ -440,15 +597,16 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
440
  </div>
441
 
442
  {/* Errors Alert */}
443
- {(chatError || fileError) && (
444
  <div className="px-4 py-2 bg-rose-50 dark:bg-rose-950/80 text-rose-700 dark:text-rose-300 text-xs font-semibold flex items-center justify-between border-t border-rose-200 dark:border-rose-800">
445
  <span className="flex items-center gap-1.5">
446
- <AlertCircle className="w-4 h-4 shrink-0" /> {chatError || fileError}
447
  </span>
448
  <button
449
  onClick={() => {
450
  setChatError(null);
451
  setFileError(null);
 
452
  }}
453
  className="text-xs hover:underline"
454
  >
@@ -463,16 +621,16 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
463
  <div className="flex items-center gap-2 truncate">
464
  <Paperclip className="w-4 h-4 text-indigo-500 shrink-0" />
465
  <span className="font-bold text-slate-800 dark:text-slate-200 truncate">{selectedFile.name}</span>
466
- {mediaDuration !== null && (
467
- <span className="text-[10px] text-indigo-700 dark:text-indigo-300 shrink-0">
468
- {mediaDuration.toFixed(1)}s
469
  </span>
470
  )}
471
  </div>
472
  <button
473
  onClick={() => {
474
  setSelectedFile(null);
475
- setMediaDuration(null);
476
  }}
477
  className="text-rose-500 hover:underline text-xs"
478
  >
@@ -481,49 +639,108 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
481
  </div>
482
  )}
483
 
484
- {/* Input Bar */}
485
- <form onSubmit={handleSendMessage} className="p-3 border-t border-slate-200 dark:border-slate-800 flex items-center gap-2 bg-white dark:bg-slate-900">
486
- <label className="p-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 cursor-pointer transition-colors">
487
- <Paperclip className="w-4 h-4" />
488
- <input type="file" accept="audio/*,video/*,image/*,.docx" onChange={handleFileChange} className="hidden" />
489
- </label>
490
-
491
- <button
492
- type="button"
493
- onClick={handleToggleRecording}
494
- disabled={uploading || !isJoined}
495
- title={isRecording ? 'Stop recording' : 'Record audio'}
496
- className={`p-2.5 rounded-xl transition-colors disabled:opacity-40 ${
497
- isRecording
498
- ? 'bg-rose-600 text-white hover:bg-rose-500'
499
- : 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
500
- }`}
501
- >
502
- {isRecording ? <Square className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
503
- </button>
504
 
505
- <input
506
- type="text"
507
- value={messageText}
508
- onChange={(e) => setMessageText(e.target.value)}
509
- placeholder={isJoined ? `Message #${selectedGroup}...` : `Join #${selectedGroup} to participate in chat`}
510
- disabled={!isJoined}
511
- className="flex-1 px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/80 text-slate-900 dark:text-slate-100 text-xs sm:text-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
512
- />
 
 
513
 
514
- <button
515
- type="submit"
516
- disabled={uploading || (!messageText.trim() && !selectedFile) || !isJoined}
517
- className="p-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs shadow-md shadow-indigo-600/20 disabled:opacity-40 transition-all cursor-pointer"
518
- >
519
- {uploading ? (
520
- <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
521
- ) : (
522
- <Send className="w-4 h-4" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  )}
524
- </button>
525
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  </div>
 
 
 
 
 
 
 
 
 
 
 
527
  </div>
528
  );
529
  };
 
3
  MessageSquare,
4
  Send,
5
  Paperclip,
 
 
6
  Volume2,
7
  VolumeX,
8
  Users,
 
10
  UserMinus,
11
  Video,
12
  FileText,
13
+ Image as ImageIcon,
14
  AlertCircle,
15
+ Clock,
16
  Sparkles,
17
  Download,
18
+ PhoneCall,
19
+ Shield,
20
+ Radio,
21
+ Mic,
22
+ X,
23
  } from 'lucide-react';
24
+ import { User, ChatMessage, GroupInfo, VoiceCall } from '../types';
25
  import { api } from '../services/api';
26
+ import { VoiceMeetingModal } from './VoiceMeetingModal';
27
 
28
  interface ChatRoomProps {
29
  currentUser: User;
 
38
  const [talkEffectEnabled, setTalkEffectEnabled] = useState(false);
39
  const [uploading, setUploading] = useState(false);
40
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
41
+ const [videoDuration, setVideoDuration] = useState<number | null>(null);
 
42
  const [fileError, setFileError] = useState<string | null>(null);
43
  const [chatError, setChatError] = useState<string | null>(null);
44
 
45
  const messagesEndRef = useRef<HTMLDivElement>(null);
46
  const lastReadChatIdRef = useRef<string | null>(null);
 
 
 
 
47
 
48
  const joinedGroups = currentUser.joinedGroups || ['General Lobby'];
49
  const isJoined = joinedGroups.includes(selectedGroup);
50
 
51
+ // Voice Meetings State
52
+ const [activeCalls, setActiveCalls] = useState<VoiceCall[]>([]);
53
+ const [currentCall, setCurrentCall] = useState<VoiceCall | null>(null);
54
+ const [isMeetingModalOpen, setIsMeetingModalOpen] = useState(false);
55
+ const [callError, setCallError] = useState<string | null>(null);
56
+
57
+ // Voice Recording State
58
+ const [isRecording, setIsRecording] = useState(false);
59
+ const [recordingSeconds, setRecordingSeconds] = useState(0);
60
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
61
+ const audioChunksRef = useRef<Blob[]>([]);
62
+ const recordingTimerRef = useRef<any>(null);
63
+ const mediaStreamRef = useRef<MediaStream | null>(null);
64
+
65
+ const startVoiceRecording = async () => {
66
+ setChatError(null);
67
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
68
+ setChatError('Voice recording is not supported in this browser.');
69
+ return;
70
+ }
71
+
72
+ try {
73
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
74
+ mediaStreamRef.current = stream;
75
+
76
+ let mimeType = 'audio/webm';
77
+ if (!MediaRecorder.isTypeSupported('audio/webm')) {
78
+ if (MediaRecorder.isTypeSupported('audio/mp4')) mimeType = 'audio/mp4';
79
+ else if (MediaRecorder.isTypeSupported('audio/ogg')) mimeType = 'audio/ogg';
80
+ else mimeType = '';
81
+ }
82
+
83
+ const options = mimeType ? { mimeType } : undefined;
84
+ const recorder = new MediaRecorder(stream, options);
85
+ mediaRecorderRef.current = recorder;
86
+ audioChunksRef.current = [];
87
+
88
+ recorder.ondataavailable = (event) => {
89
+ if (event.data && event.data.size > 0) {
90
+ audioChunksRef.current.push(event.data);
91
+ }
92
+ };
93
+
94
+ recorder.start(100);
95
+ setIsRecording(true);
96
+ setRecordingSeconds(0);
97
+
98
+ if (recordingTimerRef.current) clearInterval(recordingTimerRef.current);
99
+ recordingTimerRef.current = setInterval(() => {
100
+ setRecordingSeconds((prev) => prev + 1);
101
+ }, 1000);
102
+ } catch (err: any) {
103
+ console.warn('Microphone recording access attempt:', err?.name || err?.message || err);
104
+ const isDenied = err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError' || err?.message?.includes('Permission denied') || err?.message?.includes('denied');
105
+ if (isDenied) {
106
+ setChatError('Microphone permission was denied by your browser. Please grant microphone access in your browser settings or click the paperclip icon to upload an audio file.');
107
+ } else {
108
+ setChatError('Unable to access microphone. Please check your browser audio settings.');
109
+ }
110
+ }
111
+ };
112
+
113
+ const stopAndSendVoiceRecording = () => {
114
+ if (!mediaRecorderRef.current || !isRecording) return;
115
+
116
+ const recorder = mediaRecorderRef.current;
117
+ recorder.onstop = async () => {
118
+ if (recordingTimerRef.current) clearInterval(recordingTimerRef.current);
119
+ if (mediaStreamRef.current) {
120
+ mediaStreamRef.current.getTracks().forEach((track) => track.stop());
121
+ }
122
+
123
+ const audioBlob = new Blob(audioChunksRef.current, {
124
+ type: recorder.mimeType || 'audio/webm',
125
+ });
126
+
127
+ if (audioBlob.size < 100) {
128
+ setChatError('Voice message was too short.');
129
+ setIsRecording(false);
130
+ setRecordingSeconds(0);
131
+ return;
132
+ }
133
+
134
+ const mime = recorder.mimeType || 'audio/webm';
135
+ const ext = mime.includes('mp4') ? 'm4a' : mime.includes('ogg') ? 'ogg' : 'webm';
136
+ const audioFile = new File([audioBlob], `voice-note-${Date.now()}.${ext}`, { type: mime });
137
+
138
+ setUploading(true);
139
+ setIsRecording(false);
140
+ setRecordingSeconds(0);
141
+
142
+ try {
143
+ const attachment = await api.uploadChatFile(audioFile);
144
+ await api.sendChatMessage(selectedGroup, '🎤 Voice Note', attachment);
145
+ const updated = await api.getChats(selectedGroup);
146
+ setChats(updated);
147
+ } catch (err: any) {
148
+ setChatError(err.message || 'Failed to send voice message');
149
+ } finally {
150
+ setUploading(false);
151
+ }
152
+ };
153
+
154
+ recorder.stop();
155
+ };
156
+
157
+ const cancelVoiceRecording = () => {
158
+ if (recordingTimerRef.current) clearInterval(recordingTimerRef.current);
159
+ if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
160
+ mediaRecorderRef.current.stop();
161
+ }
162
+ if (mediaStreamRef.current) {
163
+ mediaStreamRef.current.getTracks().forEach((track) => track.stop());
164
+ }
165
+ setIsRecording(false);
166
+ setRecordingSeconds(0);
167
+ audioChunksRef.current = [];
168
+ };
169
+
170
  // Load Groups List
171
  useEffect(() => {
172
  api.getGroups().then((g) => setGroups(g)).catch(() => {});
173
  }, []);
174
 
175
+ // Poll Active Voice Calls for the group
176
+ const loadCalls = async () => {
177
+ try {
178
+ const calls = await api.getCalls(selectedGroup);
179
+ setActiveCalls(calls);
180
+ } catch (err) {
181
+ console.error('Calls polling error:', err);
182
+ }
183
+ };
184
+
185
+ useEffect(() => {
186
+ loadCalls();
187
+ const interval = setInterval(loadCalls, 2000);
188
+ return () => clearInterval(interval);
189
+ }, [selectedGroup]);
190
+
191
+ // Start Meeting
192
+ const handleStartCall = async (mode: 'public' | 'secret') => {
193
+ setCallError(null);
194
+ try {
195
+ const newCall = await api.createCall(selectedGroup, mode);
196
+ setCurrentCall(newCall);
197
+ setIsMeetingModalOpen(true);
198
+ await loadCalls();
199
+ } catch (err: any) {
200
+ setCallError(err.message || 'Failed to start meeting');
201
+ }
202
+ };
203
+
204
+ // Join Existing Meeting
205
+ const handleJoinCall = async (callId: string) => {
206
+ setCallError(null);
207
+ try {
208
+ const joined = await api.joinCall(callId);
209
+ setCurrentCall(joined);
210
+ setIsMeetingModalOpen(true);
211
+ await loadCalls();
212
+ } catch (err: any) {
213
+ setCallError(err.message || 'Failed to join meeting');
214
+ }
215
+ };
216
+
217
  // Poll Chats every 2 seconds for live real-time updates
218
  useEffect(() => {
219
  let isMounted = true;
 
260
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
261
  }, [chats]);
262
 
263
+ // Handle File Selection with Video Duration Inspection
 
 
 
 
 
 
264
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
265
  setFileError(null);
266
+ setVideoDuration(null);
267
 
268
  if (e.target.files && e.target.files[0]) {
269
  const file = e.target.files[0];
270
+ const isVid = file.type.startsWith('video/') || ['.mp4', '.webm', '.mov', '.avi'].some((ext) => file.name.toLowerCase().endsWith(ext));
271
+
272
+ if (isVid) {
273
+ // Load HTML5 Video element in memory to inspect duration
274
+ const videoEl = document.createElement('video');
275
+ videoEl.preload = 'metadata';
276
+ videoEl.src = URL.createObjectURL(file);
277
+
278
+ videoEl.onloadedmetadata = () => {
279
+ URL.revokeObjectURL(videoEl.src);
280
+ const dur = videoEl.duration;
281
+ setVideoDuration(dur);
282
+
283
+ if (dur > 5.5) {
284
+ setFileError(`Video Duration Exceeded! Uploaded video is ${dur.toFixed(1)} seconds. Strict limit is 5 seconds maximum.`);
285
  setSelectedFile(null);
286
  return;
287
  }
 
288
  setSelectedFile(file);
289
  };
290
+
291
+ videoEl.onerror = () => {
292
+ setFileError('Could not read video file duration.');
293
  setSelectedFile(null);
294
  };
295
+ } else {
296
+ setSelectedFile(file);
297
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  }
299
  };
300
 
 
310
  let attachment = undefined;
311
 
312
  if (selectedFile) {
313
+ attachment = await api.uploadChatFile(selectedFile, videoDuration || undefined);
314
  }
315
 
316
  await api.sendChatMessage(selectedGroup, messageText, attachment);
317
 
318
  setMessageText('');
319
  setSelectedFile(null);
320
+ setVideoDuration(null);
321
 
322
  // Refresh chats
323
  const updated = await api.getChats(selectedGroup);
 
425
  </p>
426
  </div>
427
 
428
+ <div className="flex items-center gap-2 flex-wrap">
429
+ {/* Start Live Meeting Buttons */}
430
+ {isJoined && (
431
+ <div className="flex items-center gap-1.5">
432
+ <button
433
+ onClick={() => handleStartCall('public')}
434
+ className="flex items-center gap-1 px-3 py-1.5 rounded-xl text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-white shadow-md shadow-emerald-600/20 transition-all cursor-pointer"
435
+ title="Start Google Meet-style Live Voice Meeting"
436
+ >
437
+ <PhoneCall className="w-3.5 h-3.5" />
438
+ <span>Start Live Meeting</span>
439
+ </button>
440
+
441
+ <button
442
+ onClick={() => handleStartCall('secret')}
443
+ className="flex items-center gap-1 px-3 py-1.5 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white shadow-md shadow-amber-600/20 transition-all cursor-pointer"
444
+ title="Start Secret Voice Call with Invited Members"
445
+ >
446
+ <Shield className="w-3.5 h-3.5" />
447
+ <span className="hidden sm:inline">Secret Call</span>
448
+ </button>
449
+ </div>
450
+ )}
451
+
452
  <button
453
  onClick={handleToggleJoinGroup}
454
  className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
 
470
  </div>
471
  </div>
472
 
473
+ {/* Active Voice Call Ongoing Banner */}
474
+ {activeCalls.length > 0 && (
475
+ <div className="px-4 py-2.5 bg-gradient-to-r from-emerald-600 to-teal-600 text-white text-xs font-semibold flex items-center justify-between border-b border-emerald-500 shadow-inner">
476
+ <div className="flex items-center gap-2">
477
+ <span className="w-2.5 h-2.5 rounded-full bg-white animate-ping shrink-0" />
478
+ <span>
479
+ <strong>Live Voice Meeting Active!</strong> Host: {activeCalls[0].caller} ({activeCalls[0].activeUsers.length} member{activeCalls[0].activeUsers.length === 1 ? '' : 's'} in call)
480
+ </span>
481
+ </div>
482
+ <button
483
+ onClick={() => handleJoinCall(activeCalls[0].id)}
484
+ className="px-3 py-1 rounded-lg bg-white text-emerald-800 hover:bg-emerald-50 font-bold text-xs shadow transition-all cursor-pointer flex items-center gap-1"
485
+ >
486
+ <PhoneCall className="w-3.5 h-3.5" /> Join Meeting
487
+ </button>
488
+ </div>
489
+ )}
490
+
491
+ {/* Video Limit Info Banner */}
492
+ <div className="px-4 py-2 bg-amber-50 dark:bg-amber-950/40 border-b border-amber-200/60 dark:border-amber-900/40 text-[11px] text-amber-800 dark:text-amber-300 flex items-center justify-between">
493
  <div className="flex items-center gap-1.5">
494
+ <Clock className="w-3.5 h-3.5 text-amber-600 shrink-0" />
495
  <span>
496
+ <strong>Video Rules:</strong> Max <strong>5 seconds</strong> video per upload &bull; Only <strong>1 video per user per day</strong>.
497
  </span>
498
  </div>
499
  {talkEffectEnabled && (
 
544
  />
545
  )}
546
 
 
 
 
 
547
  {msg.attachment.fileType === 'video' && (
548
  <div className="space-y-1">
549
+ <video
550
+ src={msg.attachment.fileUrl}
551
+ controls
552
+ className="max-h-48 rounded-lg w-full bg-black"
553
+ />
554
  <p className="text-[10px] opacity-80 flex items-center gap-1">
555
+ <Video className="w-3 h-3" /> 5s Video limit checked
556
  </p>
557
  </div>
558
  )}
559
 
560
+ {msg.attachment.fileType === 'audio' && (
561
+ <div className="space-y-1 mt-1">
562
+ <audio
563
+ src={msg.attachment.fileUrl}
564
+ controls
565
+ className="w-full max-w-[280px] h-9 rounded-lg"
566
+ />
567
+ </div>
568
+ )}
569
+
570
  {['docx', 'other'].includes(msg.attachment.fileType) && (
571
  <a
572
  href={msg.attachment.fileUrl}
 
597
  </div>
598
 
599
  {/* Errors Alert */}
600
+ {(chatError || fileError || callError) && (
601
  <div className="px-4 py-2 bg-rose-50 dark:bg-rose-950/80 text-rose-700 dark:text-rose-300 text-xs font-semibold flex items-center justify-between border-t border-rose-200 dark:border-rose-800">
602
  <span className="flex items-center gap-1.5">
603
+ <AlertCircle className="w-4 h-4 shrink-0" /> {chatError || fileError || callError}
604
  </span>
605
  <button
606
  onClick={() => {
607
  setChatError(null);
608
  setFileError(null);
609
+ setCallError(null);
610
  }}
611
  className="text-xs hover:underline"
612
  >
 
621
  <div className="flex items-center gap-2 truncate">
622
  <Paperclip className="w-4 h-4 text-indigo-500 shrink-0" />
623
  <span className="font-bold text-slate-800 dark:text-slate-200 truncate">{selectedFile.name}</span>
624
+ {videoDuration && (
625
+ <span className="px-1.5 py-0.5 rounded bg-indigo-200 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 text-[10px] font-bold">
626
+ {videoDuration.toFixed(1)}s Video
627
  </span>
628
  )}
629
  </div>
630
  <button
631
  onClick={() => {
632
  setSelectedFile(null);
633
+ setVideoDuration(null);
634
  }}
635
  className="text-rose-500 hover:underline text-xs"
636
  >
 
639
  </div>
640
  )}
641
 
642
+ {/* Input Bar & Voice Recorder */}
643
+ {isRecording ? (
644
+ <div className="p-3 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between gap-3 bg-rose-50/60 dark:bg-rose-950/40">
645
+ <div className="flex items-center gap-2.5">
646
+ <span className="relative flex h-3 w-3">
647
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
648
+ <span className="relative inline-flex rounded-full h-3 w-3 bg-rose-500"></span>
649
+ </span>
650
+ <Mic className="w-4 h-4 text-rose-600 dark:text-rose-400 animate-pulse" />
651
+ <span className="font-mono text-xs font-extrabold text-rose-600 dark:text-rose-400">
652
+ {Math.floor(recordingSeconds / 60)
653
+ .toString()
654
+ .padStart(2, '0')}
655
+ :
656
+ {(recordingSeconds % 60).toString().padStart(2, '0')}
657
+ </span>
658
+ <span className="text-xs text-slate-600 dark:text-slate-300 font-medium hidden sm:inline">
659
+ Recording voice note...
660
+ </span>
661
+ </div>
662
 
663
+ <div className="flex items-center gap-2">
664
+ <button
665
+ type="button"
666
+ onClick={cancelVoiceRecording}
667
+ className="p-2 rounded-xl bg-slate-200 hover:bg-slate-300 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold flex items-center gap-1 transition-all cursor-pointer"
668
+ title="Discard Recording"
669
+ >
670
+ <X className="w-4 h-4" />
671
+ <span className="hidden sm:inline">Cancel</span>
672
+ </button>
673
 
674
+ <button
675
+ type="button"
676
+ onClick={stopAndSendVoiceRecording}
677
+ disabled={uploading}
678
+ className="p-2 px-3.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center gap-1.5 shadow-md shadow-emerald-600/20 transition-all cursor-pointer disabled:opacity-50"
679
+ title="Send Voice Note"
680
+ >
681
+ {uploading ? (
682
+ <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
683
+ ) : (
684
+ <>
685
+ <Send className="w-3.5 h-3.5" />
686
+ <span>Send Voice Note</span>
687
+ </>
688
+ )}
689
+ </button>
690
+ </div>
691
+ </div>
692
+ ) : (
693
+ <form onSubmit={handleSendMessage} className="p-3 border-t border-slate-200 dark:border-slate-800 flex items-center gap-2 bg-white dark:bg-slate-900">
694
+ <label className="p-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 cursor-pointer transition-colors" title="Attach file">
695
+ <Paperclip className="w-4 h-4" />
696
+ <input type="file" onChange={handleFileChange} className="hidden" />
697
+ </label>
698
+
699
+ {isJoined && (
700
+ <button
701
+ type="button"
702
+ onClick={startVoiceRecording}
703
+ className="p-2.5 rounded-xl bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/60 dark:hover:bg-indigo-900/80 text-indigo-600 dark:text-indigo-400 transition-colors cursor-pointer"
704
+ title="Record & Send Voice Message"
705
+ >
706
+ <Mic className="w-4 h-4" />
707
+ </button>
708
  )}
709
+
710
+ <input
711
+ type="text"
712
+ value={messageText}
713
+ onChange={(e) => setMessageText(e.target.value)}
714
+ placeholder={isJoined ? `Message #${selectedGroup}...` : `Join #${selectedGroup} to participate in chat`}
715
+ disabled={!isJoined}
716
+ className="flex-1 px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/80 text-slate-900 dark:text-slate-100 text-xs sm:text-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
717
+ />
718
+
719
+ <button
720
+ type="submit"
721
+ disabled={uploading || (!messageText.trim() && !selectedFile) || !isJoined}
722
+ className="p-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs shadow-md shadow-indigo-600/20 disabled:opacity-40 transition-all cursor-pointer"
723
+ >
724
+ {uploading ? (
725
+ <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
726
+ ) : (
727
+ <Send className="w-4 h-4" />
728
+ )}
729
+ </button>
730
+ </form>
731
+ )}
732
  </div>
733
+
734
+ {/* Google Meet Style Voice Meeting Modal */}
735
+ {isMeetingModalOpen && currentCall && (
736
+ <VoiceMeetingModal
737
+ isOpen={isMeetingModalOpen}
738
+ onClose={() => setIsMeetingModalOpen(false)}
739
+ currentUser={currentUser}
740
+ call={currentCall}
741
+ onRefreshCalls={loadCalls}
742
+ />
743
+ )}
744
  </div>
745
  );
746
  };
src/components/NotesView.tsx CHANGED
@@ -48,7 +48,6 @@ export const NotesView: React.FC<NotesViewProps> = ({
48
  setDeletingId(noteId);
49
  try {
50
  await onDeleteNote(noteId);
51
- setSelectedImage((selected) => (selected?.id === noteId ? null : selected));
52
  } finally {
53
  setDeletingId(null);
54
  }
@@ -62,6 +61,13 @@ export const NotesView: React.FC<NotesViewProps> = ({
62
 
63
  return (
64
  <div className="space-y-4">
 
 
 
 
 
 
 
65
  {/* Grid of Notes */}
66
  {notes.length === 0 ? (
67
  <div className="p-12 text-center bg-white dark:bg-slate-800/60 rounded-2xl border border-dashed border-slate-300 dark:border-slate-700">
@@ -181,19 +187,15 @@ export const NotesView: React.FC<NotesViewProps> = ({
181
 
182
  {userCanDelete && (
183
  <button
184
- type="button"
185
  onClick={() => handleDelete(note.id)}
186
  disabled={deletingId === note.id}
187
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/50 transition-colors text-xs font-semibold"
188
  title="Delete this note"
189
  >
190
  {deletingId === note.id ? (
191
  <div className="w-4 h-4 border-2 border-rose-500 border-t-transparent rounded-full animate-spin" />
192
  ) : (
193
- <>
194
- <Trash2 className="w-4 h-4" />
195
- <span>Delete</span>
196
- </>
197
  )}
198
  </button>
199
  )}
@@ -240,21 +242,6 @@ export const NotesView: React.FC<NotesViewProps> = ({
240
  >
241
  <Download className="w-4 h-4" /> Download Image
242
  </a>
243
- {canDelete(selectedImage) && (
244
- <button
245
- type="button"
246
- onClick={() => handleDelete(selectedImage.id)}
247
- disabled={deletingId === selectedImage.id}
248
- className="px-4 py-2 bg-rose-600 hover:bg-rose-500 disabled:opacity-60 text-white font-semibold text-xs rounded-xl flex items-center gap-1.5 shadow"
249
- >
250
- {deletingId === selectedImage.id ? (
251
- <div className="w-4 h-4 border-2 border-white/50 border-t-white rounded-full animate-spin" />
252
- ) : (
253
- <Trash2 className="w-4 h-4" />
254
- )}
255
- Delete Image
256
- </button>
257
- )}
258
  </div>
259
  </div>
260
  </div>
 
48
  setDeletingId(noteId);
49
  try {
50
  await onDeleteNote(noteId);
 
51
  } finally {
52
  setDeletingId(null);
53
  }
 
61
 
62
  return (
63
  <div className="space-y-4">
64
+ {/* Header Info */}
65
+ <div className="flex items-center justify-between">
66
+ <h3 className="font-bold text-sm text-slate-800 dark:text-slate-200">
67
+ Archived Documents {searchQuery ? `(Search: "${searchQuery}")` : ''} ({notes.length})
68
+ </h3>
69
+ </div>
70
+
71
  {/* Grid of Notes */}
72
  {notes.length === 0 ? (
73
  <div className="p-12 text-center bg-white dark:bg-slate-800/60 rounded-2xl border border-dashed border-slate-300 dark:border-slate-700">
 
187
 
188
  {userCanDelete && (
189
  <button
 
190
  onClick={() => handleDelete(note.id)}
191
  disabled={deletingId === note.id}
192
+ className="p-1.5 rounded-lg text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/50 transition-colors"
193
  title="Delete this note"
194
  >
195
  {deletingId === note.id ? (
196
  <div className="w-4 h-4 border-2 border-rose-500 border-t-transparent rounded-full animate-spin" />
197
  ) : (
198
+ <Trash2 className="w-4 h-4" />
 
 
 
199
  )}
200
  </button>
201
  )}
 
242
  >
243
  <Download className="w-4 h-4" /> Download Image
244
  </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  </div>
246
  </div>
247
  </div>
src/components/VoiceMeetingModal.tsx ADDED
@@ -0,0 +1,785 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import {
3
+ Mic,
4
+ MicOff,
5
+ Video as VideoIcon,
6
+ VideoOff,
7
+ Monitor,
8
+ PhoneOff,
9
+ Hand,
10
+ MessageSquare,
11
+ Users,
12
+ Shield,
13
+ Clock,
14
+ Sparkles,
15
+ Volume2,
16
+ X,
17
+ Send,
18
+ UserPlus,
19
+ AlertCircle,
20
+ Copy,
21
+ Check,
22
+ } from 'lucide-react';
23
+ import { User, VoiceCall, ChatUser } from '../types';
24
+ import { api } from '../services/api';
25
+
26
+ interface VoiceMeetingModalProps {
27
+ isOpen: boolean;
28
+ onClose: () => void;
29
+ currentUser: User;
30
+ call: VoiceCall;
31
+ onRefreshCalls?: () => void;
32
+ }
33
+
34
+ interface InCallMessage {
35
+ id: string;
36
+ sender: string;
37
+ text: string;
38
+ time: string;
39
+ }
40
+
41
+ export const VoiceMeetingModal: React.FC<VoiceMeetingModalProps> = ({
42
+ isOpen,
43
+ onClose,
44
+ currentUser,
45
+ call,
46
+ onRefreshCalls,
47
+ }) => {
48
+ // Local state for controls
49
+ const [isMuted, setIsMuted] = useState(false);
50
+ const [isVideoOn, setIsVideoOn] = useState(false);
51
+ const [isScreenSharing, setIsScreenSharing] = useState(false);
52
+ const [handRaised, setHandRaised] = useState(false);
53
+ const [raisedHands, setRaisedHands] = useState<Record<string, boolean>>({});
54
+
55
+ // Active side panel: 'chat' | 'people' | null
56
+ const [activePanel, setActivePanel] = useState<'chat' | 'people' | null>(null);
57
+
58
+ // In-call text messages
59
+ const [inCallMessages, setInCallMessages] = useState<InCallMessage[]>([]);
60
+ const [chatInput, setChatInput] = useState('');
61
+
62
+ // Audio level monitoring
63
+ const [isSpeaking, setIsSpeaking] = useState(false);
64
+ const [speakingUsers, setSpeakingUsers] = useState<Record<string, boolean>>({});
65
+
66
+ // Call duration counter
67
+ const [secondsElapsed, setSecondsElapsed] = useState(0);
68
+
69
+ // Invite modal for secret call
70
+ const [showInviteModal, setShowInviteModal] = useState(false);
71
+ const [groupUsers, setGroupUsers] = useState<ChatUser[]>([]);
72
+ const [selectedInvites, setSelectedInvites] = useState<string[]>([]);
73
+ const [copySuccess, setCopySuccess] = useState(false);
74
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
75
+
76
+ // Media streams refs
77
+ const localVideoRef = useRef<HTMLVideoElement | null>(null);
78
+ const localStreamRef = useRef<MediaStream | null>(null);
79
+ const screenStreamRef = useRef<MediaStream | null>(null);
80
+ const audioContextRef = useRef<AudioContext | null>(null);
81
+ const analyserRef = useRef<AnalyserNode | null>(null);
82
+
83
+ // Active call participants refreshed from server
84
+ const [participants, setParticipants] = useState<string[]>(call.activeUsers || [currentUser.username]);
85
+
86
+ // Duration Timer
87
+ useEffect(() => {
88
+ const startTime = new Date(call.startedAt).getTime();
89
+ const updateTimer = () => {
90
+ const now = Date.now();
91
+ const diff = Math.floor((now - startTime) / 1000);
92
+ setSecondsElapsed(diff > 0 ? diff : 0);
93
+ };
94
+ updateTimer();
95
+ const interval = setInterval(updateTimer, 1000);
96
+ return () => clearInterval(interval);
97
+ }, [call.startedAt]);
98
+
99
+ // Format Duration string (HH:MM:SS or MM:SS)
100
+ const formatDuration = (totalSeconds: number) => {
101
+ const hrs = Math.floor(totalSeconds / 3600);
102
+ const mins = Math.floor((totalSeconds % 3600) / 60);
103
+ const secs = totalSeconds % 60;
104
+ const pad = (n: number) => n.toString().padStart(2, '0');
105
+ if (hrs > 0) {
106
+ return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
107
+ }
108
+ return `${pad(mins)}:${pad(secs)}`;
109
+ };
110
+
111
+ // Poll call state & signals
112
+ useEffect(() => {
113
+ let isMounted = true;
114
+
115
+ const pollCallStatus = async () => {
116
+ try {
117
+ const calls = await api.getCalls(call.group);
118
+ if (!isMounted) return;
119
+ const current = calls.find((c) => c.id === call.id);
120
+ if (current) {
121
+ setParticipants(current.activeUsers || []);
122
+ }
123
+
124
+ // Fetch signals
125
+ const signals = await api.getCallSignals(call.id);
126
+ if (!isMounted) return;
127
+
128
+ for (const sig of signals) {
129
+ if (sig.type === 'hand_raise') {
130
+ setRaisedHands((prev) => ({ ...prev, [sig.from]: sig.data.raised }));
131
+ } else if (sig.type === 'in_call_chat') {
132
+ setInCallMessages((prev) => [
133
+ ...prev,
134
+ {
135
+ id: sig.id || `${Date.now()}-${Math.random()}`,
136
+ sender: sig.from,
137
+ text: sig.data.text,
138
+ time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
139
+ },
140
+ ]);
141
+ } else if (sig.type === 'speaking') {
142
+ setSpeakingUsers((prev) => ({ ...prev, [sig.from]: sig.data.speaking }));
143
+ }
144
+ }
145
+ } catch (err) {
146
+ console.error('Call polling error:', err);
147
+ }
148
+ };
149
+
150
+ pollCallStatus();
151
+ const interval = setInterval(pollCallStatus, 1500);
152
+ return () => {
153
+ isMounted = false;
154
+ clearInterval(interval);
155
+ };
156
+ }, [call.id, call.group]);
157
+
158
+ // Initialize Microphone & Web Audio Analyzer for Live Speaking Indicator
159
+ useEffect(() => {
160
+ let animationFrameId: number;
161
+
162
+ async function initAudio() {
163
+ try {
164
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
165
+ localStreamRef.current = stream;
166
+
167
+ // AudioContext for level detection
168
+ const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
169
+ if (AudioCtx) {
170
+ const audioCtx = new AudioCtx();
171
+ audioContextRef.current = audioCtx;
172
+ const source = audioCtx.createMediaStreamSource(stream);
173
+ const analyser = audioCtx.createAnalyser();
174
+ analyser.fftSize = 256;
175
+ source.connect(analyser);
176
+ analyserRef.current = analyser;
177
+
178
+ const dataArray = new Uint8Array(analyser.frequencyBinCount);
179
+ const checkVolume = () => {
180
+ if (!analyserRef.current) return;
181
+ analyserRef.current.getByteFrequencyData(dataArray);
182
+ let sum = 0;
183
+ for (let i = 0; i < dataArray.length; i++) {
184
+ sum += dataArray[i];
185
+ }
186
+ const avg = sum / dataArray.length;
187
+ const speaking = avg > 15 && !isMuted;
188
+ setIsSpeaking(speaking);
189
+
190
+ // Broadcast speaking state periodically
191
+ if (speaking !== speakingUsers[currentUser.username]) {
192
+ setSpeakingUsers((prev) => ({ ...prev, [currentUser.username]: speaking }));
193
+ api.sendCallSignal(call.id, 'ALL', 'speaking', { speaking }).catch(() => {});
194
+ }
195
+
196
+ animationFrameId = requestAnimationFrame(checkVolume);
197
+ };
198
+ checkVolume();
199
+ }
200
+ } catch (err) {
201
+ console.warn('Microphone permission or WebAudio not available:', err);
202
+ }
203
+ }
204
+
205
+ initAudio();
206
+
207
+ return () => {
208
+ if (animationFrameId) cancelAnimationFrame(animationFrameId);
209
+ if (localStreamRef.current) {
210
+ localStreamRef.current.getTracks().forEach((track) => track.stop());
211
+ }
212
+ if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
213
+ audioContextRef.current.close();
214
+ }
215
+ };
216
+ }, [call.id, currentUser.username]);
217
+
218
+ // Handle Camera Toggle
219
+ const toggleVideo = async () => {
220
+ if (isVideoOn) {
221
+ if (localVideoRef.current && localVideoRef.current.srcObject) {
222
+ const stream = localVideoRef.current.srcObject as MediaStream;
223
+ stream.getTracks().forEach((t) => t.stop());
224
+ localVideoRef.current.srcObject = null;
225
+ }
226
+ setIsVideoOn(false);
227
+ } else {
228
+ try {
229
+ const videoStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
230
+ if (localVideoRef.current) {
231
+ localVideoRef.current.srcObject = videoStream;
232
+ }
233
+ setIsVideoOn(true);
234
+ } catch (err) {
235
+ console.warn('Camera permission denied or unavailable:', err);
236
+ setErrorMessage('Camera access was denied or device is unavailable.');
237
+ }
238
+ }
239
+ };
240
+
241
+ // Handle Screen Share Toggle
242
+ const toggleScreenShare = async () => {
243
+ if (isScreenSharing) {
244
+ if (screenStreamRef.current) {
245
+ screenStreamRef.current.getTracks().forEach((t) => t.stop());
246
+ screenStreamRef.current = null;
247
+ }
248
+ setIsScreenSharing(false);
249
+ } else {
250
+ try {
251
+ const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
252
+ screenStreamRef.current = screenStream;
253
+ if (localVideoRef.current) {
254
+ localVideoRef.current.srcObject = screenStream;
255
+ }
256
+ setIsScreenSharing(true);
257
+ screenStream.getVideoTracks()[0].onended = () => {
258
+ setIsScreenSharing(false);
259
+ };
260
+ } catch (err) {
261
+ console.warn('Screen share cancelled or failed:', err);
262
+ }
263
+ }
264
+ };
265
+
266
+ // Handle Hand Raise
267
+ const toggleHandRaise = () => {
268
+ const nextState = !handRaised;
269
+ setHandRaised(nextState);
270
+ setRaisedHands((prev) => ({ ...prev, [currentUser.username]: nextState }));
271
+
272
+ // Send hand raise signal to call room
273
+ for (const p of participants) {
274
+ if (p.toLowerCase() !== currentUser.username.toLowerCase()) {
275
+ api.sendCallSignal(call.id, p, 'hand_raise', { raised: nextState }).catch(() => {});
276
+ }
277
+ }
278
+ };
279
+
280
+ // Send In-Call Message
281
+ const handleSendInCallMessage = (e: React.FormEvent) => {
282
+ e.preventDefault();
283
+ if (!chatInput.trim()) return;
284
+
285
+ const newMsg: InCallMessage = {
286
+ id: `${Date.now()}-${Math.random()}`,
287
+ sender: currentUser.username,
288
+ text: chatInput.trim(),
289
+ time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
290
+ };
291
+
292
+ setInCallMessages((prev) => [...prev, newMsg]);
293
+ setChatInput('');
294
+
295
+ // Send to other active participants
296
+ for (const p of participants) {
297
+ if (p.toLowerCase() !== currentUser.username.toLowerCase()) {
298
+ api.sendCallSignal(call.id, p, 'in_call_chat', { text: newMsg.text }).catch(() => {});
299
+ }
300
+ }
301
+ };
302
+
303
+ // Handle Leaving Call
304
+ const handleLeaveCall = async () => {
305
+ try {
306
+ await api.leaveCall(call.id);
307
+ if (onRefreshCalls) onRefreshCalls();
308
+ } catch (err) {
309
+ console.error('Error leaving call:', err);
310
+ } finally {
311
+ onClose();
312
+ }
313
+ };
314
+
315
+ // Load Group Users for Invite Modal
316
+ const handleOpenInviteModal = async () => {
317
+ try {
318
+ const users = await api.getChatUsers(call.group);
319
+ setGroupUsers(users);
320
+ setShowInviteModal(true);
321
+ } catch (err) {
322
+ console.error('Failed to fetch group users for call invite:', err);
323
+ }
324
+ };
325
+
326
+ const handleCopyMeetingInfo = () => {
327
+ const info = `Join Live Study Meeting in ${call.group}! Host: ${call.caller}. Meeting ID: ${call.id}`;
328
+ navigator.clipboard.writeText(info);
329
+ setCopySuccess(true);
330
+ setTimeout(() => setCopySuccess(false), 2000);
331
+ };
332
+
333
+ // Calculate Grid layout CSS based on participant count
334
+ const getGridColsClass = (count: number) => {
335
+ if (count <= 1) return 'grid-cols-1 max-w-2xl';
336
+ if (count === 2) return 'grid-cols-1 sm:grid-cols-2 max-w-4xl';
337
+ if (count <= 4) return 'grid-cols-2 max-w-5xl';
338
+ return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 max-w-6xl';
339
+ };
340
+
341
+ return (
342
+ <div className="fixed inset-0 z-50 bg-slate-950 flex flex-col font-sans select-none overflow-hidden">
343
+ {/* Top Meeting Header */}
344
+ <div className="px-4 py-3 bg-slate-900/90 border-b border-slate-800 flex items-center justify-between z-10 shrink-0">
345
+ <div className="flex items-center gap-3 min-w-0">
346
+ <div className="flex items-center gap-2">
347
+ <span className="w-3 h-3 rounded-full bg-emerald-500 animate-pulse" />
348
+ <h2 className="font-bold text-sm sm:text-base text-white truncate">
349
+ {call.group} &bull; Live Study Meeting
350
+ </h2>
351
+ </div>
352
+
353
+ <span
354
+ className={`hidden sm:inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${
355
+ call.mode === 'secret'
356
+ ? 'bg-amber-950/80 text-amber-300 border border-amber-800/60'
357
+ : 'bg-indigo-950/80 text-indigo-300 border border-indigo-800/60'
358
+ }`}
359
+ >
360
+ <Shield className="w-3.5 h-3.5" />
361
+ {call.mode === 'secret' ? 'Secret Call' : 'Public Room'}
362
+ </span>
363
+
364
+ {/* Meeting Timer */}
365
+ <div className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-slate-800/80 text-slate-300 text-xs font-mono">
366
+ <Clock className="w-3.5 h-3.5 text-indigo-400" />
367
+ {formatDuration(secondsElapsed)}
368
+ </div>
369
+ </div>
370
+
371
+ <div className="flex items-center gap-2">
372
+ {/* Copy Call Info */}
373
+ <button
374
+ onClick={handleCopyMeetingInfo}
375
+ className="p-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-xs font-semibold flex items-center gap-1.5 transition-colors cursor-pointer"
376
+ title="Copy Meeting Info"
377
+ >
378
+ {copySuccess ? (
379
+ <>
380
+ <Check className="w-4 h-4 text-emerald-400" />
381
+ <span className="hidden md:inline text-emerald-400">Copied</span>
382
+ </>
383
+ ) : (
384
+ <>
385
+ <Copy className="w-4 h-4" />
386
+ <span className="hidden md:inline">Share Info</span>
387
+ </>
388
+ )}
389
+ </button>
390
+
391
+ {/* Invite Button for Secret Call */}
392
+ {call.mode === 'secret' && (
393
+ <button
394
+ onClick={handleOpenInviteModal}
395
+ className="p-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold flex items-center gap-1.5 transition-all shadow-md shadow-indigo-600/20 cursor-pointer"
396
+ >
397
+ <UserPlus className="w-4 h-4" />
398
+ <span className="hidden sm:inline">Invite Members</span>
399
+ </button>
400
+ )}
401
+
402
+ {/* Close / Minimalize Button */}
403
+ <button
404
+ onClick={handleLeaveCall}
405
+ className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition-colors cursor-pointer"
406
+ title="Leave Meeting"
407
+ >
408
+ <X className="w-5 h-5" />
409
+ </button>
410
+ </div>
411
+ </div>
412
+
413
+ {/* Main Body Area: Participant Video Cards Grid + Side Panel */}
414
+ <div className="flex-1 flex overflow-hidden relative">
415
+ {/* Video / Cards Grid Area */}
416
+ <div className="flex-1 p-4 sm:p-6 overflow-y-auto flex items-center justify-center">
417
+ <div className={`w-full grid gap-4 ${getGridColsClass(participants.length)}`}>
418
+ {participants.map((username) => {
419
+ const isMe = username.toLowerCase() === currentUser.username.toLowerCase();
420
+ const userSpeaking = isMe ? isSpeaking : Boolean(speakingUsers[username]);
421
+ const userHand = Boolean(raisedHands[username]);
422
+
423
+ return (
424
+ <div
425
+ key={username}
426
+ className={`relative rounded-3xl bg-slate-900 border transition-all overflow-hidden flex flex-col items-center justify-center min-h-[220px] sm:min-h-[260px] p-6 shadow-2xl ${
427
+ userSpeaking
428
+ ? 'border-emerald-500 ring-2 ring-emerald-500/40'
429
+ : 'border-slate-800 hover:border-slate-700'
430
+ }`}
431
+ >
432
+ {/* Camera Video View for Local User */}
433
+ {isMe && (isVideoOn || isScreenSharing) ? (
434
+ <video
435
+ key={`video-stream-${username}`}
436
+ ref={localVideoRef}
437
+ autoPlay
438
+ playsInline
439
+ muted
440
+ className="absolute inset-0 w-full h-full object-cover rounded-3xl"
441
+ />
442
+ ) : (
443
+ /* Default Google Meet Avatar Card */
444
+ <div key={`avatar-card-${username}`} className="flex flex-col items-center justify-center relative z-10">
445
+ <div className="relative">
446
+ {/* Audio Speaking Ripple Effect Ring */}
447
+ {userSpeaking && (
448
+ <div className="absolute -inset-3 rounded-full bg-emerald-500/30 animate-ping" />
449
+ )}
450
+ <div
451
+ className={`w-20 h-20 sm:w-24 sm:h-24 rounded-full flex items-center justify-center text-2xl sm:text-3xl font-bold uppercase shadow-xl transition-transform ${
452
+ userSpeaking
453
+ ? 'bg-gradient-to-tr from-emerald-500 to-teal-400 text-white scale-105'
454
+ : 'bg-indigo-600 text-white'
455
+ }`}
456
+ >
457
+ {username.substring(0, 2)}
458
+ </div>
459
+ </div>
460
+
461
+ <h3 className="mt-4 font-bold text-sm sm:text-base text-white flex items-center gap-1.5">
462
+ {username}
463
+ {isMe && <span className="text-xs text-indigo-400 font-normal">(You)</span>}
464
+ </h3>
465
+ </div>
466
+ )}
467
+
468
+ {/* Top Badges (Hand Raise / Speaking Indicator) */}
469
+ <div className="absolute top-3 left-3 right-3 flex items-center justify-between z-20 pointer-events-none">
470
+ {userHand && (
471
+ <span className="flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-amber-500 text-slate-950 shadow-lg animate-bounce">
472
+ <Hand className="w-3.5 h-3.5 fill-current" /> Hand Raised
473
+ </span>
474
+ )}
475
+
476
+ {userSpeaking && (
477
+ <span className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold bg-emerald-500 text-slate-950 shadow-md">
478
+ <Volume2 className="w-3.5 h-3.5" /> Speaking
479
+ </span>
480
+ )}
481
+ </div>
482
+
483
+ {/* Bottom Status Tag */}
484
+ <div className="absolute bottom-3 left-3 right-3 flex items-center justify-between z-20 pointer-events-none">
485
+ <div className="px-3 py-1 rounded-xl bg-slate-950/80 backdrop-blur-md border border-slate-800 text-xs text-white font-medium flex items-center gap-1.5">
486
+ <span className="w-2 h-2 rounded-full bg-emerald-400" />
487
+ {username}
488
+ </div>
489
+
490
+ <div className="p-1.5 rounded-xl bg-slate-950/80 backdrop-blur-md border border-slate-800 text-white">
491
+ {isMe ? (
492
+ isMuted ? (
493
+ <MicOff className="w-4 h-4 text-rose-500" />
494
+ ) : (
495
+ <Mic className="w-4 h-4 text-emerald-400" />
496
+ )
497
+ ) : (
498
+ <Mic className="w-4 h-4 text-slate-400" />
499
+ )}
500
+ </div>
501
+ </div>
502
+ </div>
503
+ );
504
+ })}
505
+ </div>
506
+ </div>
507
+
508
+ {/* Right Side Panel (In-call Chat or Participant Roster) */}
509
+ {activePanel && (
510
+ <div className="w-full sm:w-80 bg-slate-900 border-l border-slate-800 flex flex-col z-20">
511
+ {/* Panel Header */}
512
+ <div className="p-4 border-b border-slate-800 flex items-center justify-between">
513
+ <h3 className="font-bold text-sm text-white flex items-center gap-2">
514
+ {activePanel === 'chat' ? (
515
+ <>
516
+ <MessageSquare className="w-4 h-4 text-indigo-400" /> In-Call Messages
517
+ </>
518
+ ) : (
519
+ <>
520
+ <Users className="w-4 h-4 text-indigo-400" /> Participants ({participants.length})
521
+ </>
522
+ )}
523
+ </h3>
524
+ <button
525
+ onClick={() => setActivePanel(null)}
526
+ className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800"
527
+ >
528
+ <X className="w-4 h-4" />
529
+ </button>
530
+ </div>
531
+
532
+ {/* Panel Content */}
533
+ <div className="flex-1 overflow-y-auto p-4">
534
+ {activePanel === 'chat' ? (
535
+ <div className="space-y-3">
536
+ {inCallMessages.length === 0 ? (
537
+ <p className="text-center text-xs text-slate-500 py-8">
538
+ Messages sent here can be seen only by people in this call.
539
+ </p>
540
+ ) : (
541
+ inCallMessages.map((msg) => (
542
+ <div key={msg.id} className="p-3 rounded-2xl bg-slate-800/80 text-xs space-y-1">
543
+ <div className="flex items-center justify-between text-[10px] text-slate-400 font-semibold">
544
+ <span className="text-indigo-300">{msg.sender}</span>
545
+ <span>{msg.time}</span>
546
+ </div>
547
+ <p className="text-slate-100 whitespace-pre-wrap">{msg.text}</p>
548
+ </div>
549
+ ))
550
+ )}
551
+ </div>
552
+ ) : (
553
+ /* Participants List */
554
+ <div className="space-y-2">
555
+ {participants.map((p) => {
556
+ const isMe = p.toLowerCase() === currentUser.username.toLowerCase();
557
+ return (
558
+ <div
559
+ key={p}
560
+ className="p-3 rounded-2xl bg-slate-800/60 border border-slate-800 flex items-center justify-between"
561
+ >
562
+ <div className="flex items-center gap-2.5">
563
+ <div className="w-8 h-8 rounded-full bg-indigo-600 text-white font-bold text-xs flex items-center justify-center">
564
+ {p.substring(0, 2).toUpperCase()}
565
+ </div>
566
+ <div>
567
+ <p className="text-xs font-bold text-white flex items-center gap-1">
568
+ {p} {isMe && <span className="text-[10px] text-indigo-400">(You)</span>}
569
+ </p>
570
+ <p className="text-[10px] text-slate-400">Active in Voice</p>
571
+ </div>
572
+ </div>
573
+
574
+ <Mic className="w-4 h-4 text-emerald-400" />
575
+ </div>
576
+ );
577
+ })}
578
+ </div>
579
+ )}
580
+ </div>
581
+
582
+ {/* In-Call Chat Input Form */}
583
+ {activePanel === 'chat' && (
584
+ <form onSubmit={handleSendInCallMessage} className="p-3 border-t border-slate-800 flex items-center gap-2">
585
+ <input
586
+ type="text"
587
+ value={chatInput}
588
+ onChange={(e) => setChatInput(e.target.value)}
589
+ placeholder="Send a message to everyone..."
590
+ className="flex-1 px-3 py-2 rounded-xl bg-slate-800 border border-slate-700 text-xs text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
591
+ />
592
+ <button
593
+ type="submit"
594
+ disabled={!chatInput.trim()}
595
+ className="p-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white cursor-pointer"
596
+ >
597
+ <Send className="w-4 h-4" />
598
+ </button>
599
+ </form>
600
+ )}
601
+ </div>
602
+ )}
603
+ </div>
604
+
605
+ {/* Error Toast Alert */}
606
+ {errorMessage && (
607
+ <div className="px-4 py-2 bg-rose-950/90 text-rose-200 border-t border-rose-800 text-xs font-semibold flex items-center justify-between shrink-0">
608
+ <span className="flex items-center gap-1.5">
609
+ <AlertCircle className="w-4 h-4 text-rose-400" /> {errorMessage}
610
+ </span>
611
+ <button onClick={() => setErrorMessage(null)} className="hover:underline">
612
+ Dismiss
613
+ </button>
614
+ </div>
615
+ )}
616
+
617
+ {/* Bottom Google Meet Controls Bar */}
618
+ <div className="p-4 bg-slate-900 border-t border-slate-800 flex items-center justify-between shrink-0">
619
+ {/* Left: Meeting Info */}
620
+ <div className="hidden md:flex items-center gap-2 text-xs text-slate-400">
621
+ <span className="font-semibold text-white">{call.group}</span>
622
+ <span>&bull;</span>
623
+ <span>{participants.length} Active in Call</span>
624
+ </div>
625
+
626
+ {/* Center Toolbar Buttons */}
627
+ <div className="flex items-center gap-2 sm:gap-3 mx-auto md:mx-0">
628
+ {/* Mic Toggle */}
629
+ <button
630
+ onClick={() => setIsMuted(!isMuted)}
631
+ className={`p-3.5 sm:p-4 rounded-2xl transition-all cursor-pointer ${
632
+ isMuted
633
+ ? 'bg-rose-600 text-white hover:bg-rose-500 shadow-lg shadow-rose-600/30'
634
+ : 'bg-slate-800 text-white hover:bg-slate-700'
635
+ }`}
636
+ title={isMuted ? 'Unmute Microphone' : 'Mute Microphone'}
637
+ >
638
+ {isMuted ? <MicOff className="w-5 h-5" /> : <Mic className="w-5 h-5 text-emerald-400" />}
639
+ </button>
640
+
641
+ {/* Camera Toggle */}
642
+ <button
643
+ onClick={toggleVideo}
644
+ className={`p-3.5 sm:p-4 rounded-2xl transition-all cursor-pointer ${
645
+ isVideoOn
646
+ ? 'bg-indigo-600 text-white hover:bg-indigo-500 shadow-lg shadow-indigo-600/30'
647
+ : 'bg-slate-800 text-white hover:bg-slate-700'
648
+ }`}
649
+ title={isVideoOn ? 'Turn Off Camera' : 'Turn On Camera'}
650
+ >
651
+ {isVideoOn ? <VideoIcon className="w-5 h-5" /> : <VideoOff className="w-5 h-5" />}
652
+ </button>
653
+
654
+ {/* Screen Share Toggle */}
655
+ <button
656
+ onClick={toggleScreenShare}
657
+ className={`p-3.5 sm:p-4 rounded-2xl transition-all cursor-pointer ${
658
+ isScreenSharing
659
+ ? 'bg-emerald-600 text-white hover:bg-emerald-500'
660
+ : 'bg-slate-800 text-white hover:bg-slate-700'
661
+ }`}
662
+ title="Share Screen"
663
+ >
664
+ <Monitor className="w-5 h-5" />
665
+ </button>
666
+
667
+ {/* Hand Raise Toggle */}
668
+ <button
669
+ onClick={toggleHandRaise}
670
+ className={`p-3.5 sm:p-4 rounded-2xl transition-all cursor-pointer ${
671
+ handRaised
672
+ ? 'bg-amber-500 text-slate-950 hover:bg-amber-400 font-bold'
673
+ : 'bg-slate-800 text-white hover:bg-slate-700'
674
+ }`}
675
+ title="Raise Hand"
676
+ >
677
+ <Hand className="w-5 h-5" />
678
+ </button>
679
+
680
+ {/* End Call / Leave Meeting Button */}
681
+ <button
682
+ onClick={handleLeaveCall}
683
+ className="px-6 py-3.5 sm:py-4 rounded-2xl bg-rose-600 hover:bg-rose-500 text-white font-bold text-xs sm:text-sm flex items-center gap-2 shadow-lg shadow-rose-600/30 transition-all cursor-pointer"
684
+ title="Leave Call"
685
+ >
686
+ <PhoneOff className="w-5 h-5" />
687
+ <span className="hidden sm:inline">Leave Call</span>
688
+ </button>
689
+ </div>
690
+
691
+ {/* Right: Side Panel Toggle Buttons */}
692
+ <div className="flex items-center gap-2">
693
+ {/* Chat Panel Button */}
694
+ <button
695
+ onClick={() => setActivePanel(activePanel === 'chat' ? null : 'chat')}
696
+ className={`p-3 rounded-xl transition-colors cursor-pointer ${
697
+ activePanel === 'chat'
698
+ ? 'bg-indigo-600 text-white'
699
+ : 'bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700'
700
+ }`}
701
+ title="In-call Messages"
702
+ >
703
+ <MessageSquare className="w-5 h-5" />
704
+ </button>
705
+
706
+ {/* People / Participants Button */}
707
+ <button
708
+ onClick={() => setActivePanel(activePanel === 'people' ? null : 'people')}
709
+ className={`p-3 rounded-xl transition-colors cursor-pointer ${
710
+ activePanel === 'people'
711
+ ? 'bg-indigo-600 text-white'
712
+ : 'bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700'
713
+ }`}
714
+ title="Participants Roster"
715
+ >
716
+ <Users className="w-5 h-5" />
717
+ </button>
718
+ </div>
719
+ </div>
720
+
721
+ {/* Secret Call Invite Modal */}
722
+ {showInviteModal && (
723
+ <div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
724
+ <div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 w-full max-w-md space-y-4 text-white">
725
+ <div className="flex items-center justify-between border-b border-slate-800 pb-3">
726
+ <h3 className="font-bold text-base flex items-center gap-2">
727
+ <UserPlus className="w-5 h-5 text-indigo-400" /> Invite Members to Secret Call
728
+ </h3>
729
+ <button
730
+ onClick={() => setShowInviteModal(false)}
731
+ className="text-slate-400 hover:text-white"
732
+ >
733
+ <X className="w-5 h-5" />
734
+ </button>
735
+ </div>
736
+
737
+ <p className="text-xs text-slate-400">
738
+ Secret calls are visible only to invited group members. Select students to invite:
739
+ </p>
740
+
741
+ <div className="max-h-60 overflow-y-auto space-y-2 pr-1">
742
+ {groupUsers.length === 0 ? (
743
+ <p className="text-xs text-slate-500 py-4 text-center">No other members in this group.</p>
744
+ ) : (
745
+ groupUsers.map((u) => {
746
+ const isSelected = selectedInvites.includes(u.username);
747
+ return (
748
+ <button
749
+ key={u.username}
750
+ type="button"
751
+ onClick={() => {
752
+ if (isSelected) {
753
+ setSelectedInvites(selectedInvites.filter((x) => x !== u.username));
754
+ } else {
755
+ setSelectedInvites([...selectedInvites, u.username]);
756
+ }
757
+ }}
758
+ className={`w-full text-left p-3 rounded-xl border flex items-center justify-between text-xs transition-colors cursor-pointer ${
759
+ isSelected
760
+ ? 'bg-indigo-950 border-indigo-600 text-white'
761
+ : 'bg-slate-800/60 border-slate-800 text-slate-300 hover:bg-slate-800'
762
+ }`}
763
+ >
764
+ <span className="font-semibold">{u.username}</span>
765
+ {isSelected && <Check className="w-4 h-4 text-indigo-400" />}
766
+ </button>
767
+ );
768
+ })
769
+ )}
770
+ </div>
771
+
772
+ <div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-800">
773
+ <button
774
+ onClick={() => setShowInviteModal(false)}
775
+ className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-300"
776
+ >
777
+ Done
778
+ </button>
779
+ </div>
780
+ </div>
781
+ </div>
782
+ )}
783
+ </div>
784
+ );
785
+ };
src/index.css CHANGED
@@ -1,3 +1 @@
1
  @import "tailwindcss";
2
-
3
- @custom-variant dark (&:where(.dark, .dark *));
 
1
  @import "tailwindcss";
 
 
src/services/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { User, NoteItem, ChatMessage, AppNotification, GroupInfo } from '../types';
2
 
3
  const TOKEN_KEY = 'school_archive_token';
4
  const SAVED_USER_KEY = 'school_archive_saved_user';
@@ -46,6 +46,10 @@ async function request<T>(endpoint: string, options: RequestInit = {}): Promise<
46
  const data = await res.json();
47
 
48
  if (!res.ok) {
 
 
 
 
49
  throw new Error(data.error || 'An error occurred while communicating with the server');
50
  }
51
 
@@ -169,11 +173,11 @@ export const api = {
169
  return data.chats;
170
  },
171
 
172
- async uploadChatFile(file: File, mediaDuration?: number): Promise<any> {
173
  const formData = new FormData();
174
  formData.append('file', file);
175
- if (mediaDuration !== undefined) {
176
- formData.append('mediaDuration', mediaDuration.toString());
177
  }
178
 
179
  const data = await request<{ attachment: any }>('/api/chats/upload-file', {
@@ -200,4 +204,50 @@ export const api = {
200
  async markNotificationsRead(): Promise<void> {
201
  await request('/api/notifications/read', { method: 'POST' });
202
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  };
 
1
+ import { User, NoteItem, ChatMessage, AppNotification, GroupInfo, VoiceCall, ChatUser } from '../types';
2
 
3
  const TOKEN_KEY = 'school_archive_token';
4
  const SAVED_USER_KEY = 'school_archive_saved_user';
 
46
  const data = await res.json();
47
 
48
  if (!res.ok) {
49
+ if (res.status === 401) {
50
+ clearStoredToken();
51
+ window.dispatchEvent(new Event('auth_unauthorized'));
52
+ }
53
  throw new Error(data.error || 'An error occurred while communicating with the server');
54
  }
55
 
 
173
  return data.chats;
174
  },
175
 
176
+ async uploadChatFile(file: File, videoDuration?: number): Promise<any> {
177
  const formData = new FormData();
178
  formData.append('file', file);
179
+ if (videoDuration !== undefined) {
180
+ formData.append('videoDuration', videoDuration.toString());
181
  }
182
 
183
  const data = await request<{ attachment: any }>('/api/chats/upload-file', {
 
204
  async markNotificationsRead(): Promise<void> {
205
  await request('/api/notifications/read', { method: 'POST' });
206
  },
207
+
208
+ // VOICE MEETINGS / CALLS
209
+ async getCalls(group?: string): Promise<VoiceCall[]> {
210
+ const query = group ? `?group=${encodeURIComponent(group)}` : '';
211
+ const data = await request<{ calls: VoiceCall[] }>(`/api/calls${query}`);
212
+ return data.calls;
213
+ },
214
+
215
+ async createCall(group: string, mode: 'public' | 'secret', invitedUsers: string[] = []): Promise<VoiceCall> {
216
+ const data = await request<{ call: VoiceCall }>('/api/calls', {
217
+ method: 'POST',
218
+ body: JSON.stringify({ group, mode, invitedUsers }),
219
+ });
220
+ return data.call;
221
+ },
222
+
223
+ async joinCall(callId: string): Promise<VoiceCall> {
224
+ const data = await request<{ call: VoiceCall }>(`/api/calls/${encodeURIComponent(callId)}/join`, {
225
+ method: 'POST',
226
+ });
227
+ return data.call;
228
+ },
229
+
230
+ async leaveCall(callId: string): Promise<void> {
231
+ await request(`/api/calls/${encodeURIComponent(callId)}/leave`, {
232
+ method: 'POST',
233
+ });
234
+ },
235
+
236
+ async sendCallSignal(callId: string, to: string, type: string, data: any): Promise<void> {
237
+ await request(`/api/calls/${encodeURIComponent(callId)}/signals`, {
238
+ method: 'POST',
239
+ body: JSON.stringify({ to, type, data }),
240
+ });
241
+ },
242
+
243
+ async getCallSignals(callId: string): Promise<any[]> {
244
+ const data = await request<{ signals: any[] }>(`/api/calls/${encodeURIComponent(callId)}/signals`);
245
+ return data.signals;
246
+ },
247
+
248
+ async getChatUsers(group: string): Promise<ChatUser[]> {
249
+ const query = group ? `?group=${encodeURIComponent(group)}` : '';
250
+ const data = await request<{ users: ChatUser[] }>(`/api/chat-users${query}`);
251
+ return data.users;
252
+ },
253
  };
src/types.ts CHANGED
@@ -12,6 +12,7 @@ export interface User {
12
  permissions: UserPermissions;
13
  joinedGroups: string[];
14
  createdAt: string;
 
15
  }
16
 
17
  export interface SubjectFolder {
@@ -54,7 +55,7 @@ export interface ChatMessage {
54
  fileType: 'image' | 'docx' | 'video' | 'audio' | 'other';
55
  mimeType: string;
56
  size: number;
57
- mediaDuration?: number;
58
  };
59
  timestamp: string;
60
  }
@@ -77,3 +78,24 @@ export interface GroupInfo {
77
  description: string;
78
  icon: string;
79
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  permissions: UserPermissions;
13
  joinedGroups: string[];
14
  createdAt: string;
15
+ password?: string;
16
  }
17
 
18
  export interface SubjectFolder {
 
55
  fileType: 'image' | 'docx' | 'video' | 'audio' | 'other';
56
  mimeType: string;
57
  size: number;
58
+ videoDuration?: number;
59
  };
60
  timestamp: string;
61
  }
 
78
  description: string;
79
  icon: string;
80
  }
81
+
82
+ export interface CallParticipant {
83
+ username: string;
84
+ active: boolean;
85
+ }
86
+
87
+ export interface VoiceCall {
88
+ id: string;
89
+ group: string;
90
+ mode: 'public' | 'secret';
91
+ caller: string;
92
+ startedAt: string;
93
+ invitedUsers: string[];
94
+ activeUsers: string[];
95
+ participantUsers: CallParticipant[];
96
+ }
97
+
98
+ export interface ChatUser {
99
+ username: string;
100
+ active: boolean;
101
+ }