Spaces:
Sleeping
Sleeping
update src/
Browse files- src/App.css +1611 -1599
- src/App.jsx +121 -92
- src/assets/logo.jpg +0 -0
- src/components/Achievement.jsx +274 -274
- src/components/FocusPage.jsx +264 -264
- src/components/FocusPageLocal.jsx +768 -586
- src/components/Help.jsx +90 -90
- src/components/Home.jsx +34 -87
- src/components/Records.jsx +665 -645
- src/index.css +72 -72
- src/main.jsx +10 -10
- src/utils/VideoManager.js +353 -353
- src/utils/VideoManagerLocal.js +949 -788
src/App.css
CHANGED
|
@@ -1,1599 +1,1611 @@
|
|
| 1 |
-
/* =========================================
|
| 2 |
-
1. REACT layout setting
|
| 3 |
-
========================================= */
|
| 4 |
-
html, body, #root {
|
| 5 |
-
width: 100%;
|
| 6 |
-
height: 100%;
|
| 7 |
-
margin: 0;
|
| 8 |
-
padding: 0;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
.app-container {
|
| 12 |
-
width: 100%;
|
| 13 |
-
min-height: 100vh; /* screen height */
|
| 14 |
-
display: flex;
|
| 15 |
-
flex-direction: column;
|
| 16 |
-
background-color: #f9f9f9;
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
/* =========================================
|
| 20 |
-
2. original layout
|
| 21 |
-
========================================= */
|
| 22 |
-
|
| 23 |
-
/* GLOBAL STYLES */
|
| 24 |
-
body {
|
| 25 |
-
font-family: 'Nunito', sans-serif;
|
| 26 |
-
background-color: #f9f9f9;
|
| 27 |
-
overflow-x: hidden;
|
| 28 |
-
overflow-y: auto;
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
/* dynamic class name */
|
| 32 |
-
.hidden {
|
| 33 |
-
display: none !important;
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
/* TOP MENU */
|
| 37 |
-
#top-menu {
|
| 38 |
-
height: 60px;
|
| 39 |
-
background-color: white;
|
| 40 |
-
display: flex;
|
| 41 |
-
align-items: center;
|
| 42 |
-
justify-content: center; /* Center buttons horizontally */
|
| 43 |
-
gap: 0;
|
| 44 |
-
padding: 0 24px 0 76px;
|
| 45 |
-
box-sizing: border-box;
|
| 46 |
-
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
| 47 |
-
position: fixed;
|
| 48 |
-
top: 0;
|
| 49 |
-
left: 0;
|
| 50 |
-
right: 0;
|
| 51 |
-
width: 100%;
|
| 52 |
-
z-index: 1000;
|
| 53 |
-
overflow-x: auto;
|
| 54 |
-
overflow-y: hidden;
|
| 55 |
-
white-space: nowrap;
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
.menu-btn {
|
| 59 |
-
background: none;
|
| 60 |
-
border: none;
|
| 61 |
-
font-family: 'Nunito', sans-serif;
|
| 62 |
-
font-size: 16px;
|
| 63 |
-
color: #333;
|
| 64 |
-
padding: 10px 20px;
|
| 65 |
-
cursor: pointer;
|
| 66 |
-
transition: background-color 0.2s;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
.menu-btn:hover {
|
| 70 |
-
background-color: #f0f0f0;
|
| 71 |
-
border-radius: 4px;
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
/* active for React */
|
| 75 |
-
.menu-btn.active {
|
| 76 |
-
font-weight: bold;
|
| 77 |
-
color: #007BFF;
|
| 78 |
-
background-color: #eef7ff;
|
| 79 |
-
border-radius: 4px;
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
.separator {
|
| 83 |
-
width: 1px;
|
| 84 |
-
height: 20px;
|
| 85 |
-
background-color: #555; /* Dark gray separator */
|
| 86 |
-
margin: 0 5px;
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
/* PAGE CONTAINER */
|
| 90 |
-
.page {
|
| 91 |
-
/* content under menu */
|
| 92 |
-
min-height: calc(100vh - 60px);
|
| 93 |
-
width: 100%;
|
| 94 |
-
padding-top: 60px; /* Space for fixed menu */
|
| 95 |
-
padding-bottom: 40px; /* Space at bottom for scrolling */
|
| 96 |
-
box-sizing: border-box;
|
| 97 |
-
display: flex;
|
| 98 |
-
flex-direction: column;
|
| 99 |
-
align-items: center;
|
| 100 |
-
overflow-y: auto;
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
/* Ensure page titles are black */
|
| 104 |
-
.page h1 {
|
| 105 |
-
color: #000 !important;
|
| 106 |
-
background: transparent !important;
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
.page-title {
|
| 110 |
-
color: #000 !important;
|
| 111 |
-
background: transparent !important;
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
/* PAGE A SPECIFIC */
|
| 115 |
-
#page-a {
|
| 116 |
-
justify-content: center; /* Center vertically */
|
| 117 |
-
/* Fine-tune this margin if the Home screen sits slightly too low. */
|
| 118 |
-
margin-top: -40px;
|
| 119 |
-
flex: 1; /* Fill the remaining height so vertical centering still works. */
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
#page-a h1 {
|
| 123 |
-
font-size: 80px;
|
| 124 |
-
margin: 0 0 10px 0;
|
| 125 |
-
color: #000;
|
| 126 |
-
text-align: center; /* Keep the heading centered. */
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
#page-a p {
|
| 130 |
-
color: #666;
|
| 131 |
-
font-size: 20px;
|
| 132 |
-
margin-bottom: 40px;
|
| 133 |
-
text-align: center;
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
.btn-main {
|
| 137 |
-
background-color: #007BFF; /* Blue */
|
| 138 |
-
color: white;
|
| 139 |
-
border: none;
|
| 140 |
-
padding: 15px 50px;
|
| 141 |
-
font-size: 20px;
|
| 142 |
-
font-family: 'Nunito', sans-serif;
|
| 143 |
-
border-radius: 30px; /* Fully rounded corners */
|
| 144 |
-
cursor: pointer;
|
| 145 |
-
transition: transform 0.2s ease;
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
.btn-main:hover {
|
| 149 |
-
transform: scale(1.1); /* Zoom effect */
|
| 150 |
-
}
|
| 151 |
-
|
| 152 |
-
/* PAGE B SPECIFIC */
|
| 153 |
-
#page-b {
|
| 154 |
-
justify-content: space-evenly; /* Distribute vertical space */
|
| 155 |
-
padding-bottom: 20px;
|
| 156 |
-
min-height: calc(100vh - 60px); /* Ensure the page still fills the viewport. */
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
-
/* 1. Display Area */
|
| 160 |
-
#display-area {
|
| 161 |
-
width: 60%;
|
| 162 |
-
height: 50vh; /* Use viewport height to scale more consistently across screens. */
|
| 163 |
-
min-height: 300px;
|
| 164 |
-
border: 2px solid #ddd;
|
| 165 |
-
border-radius: 12px;
|
| 166 |
-
background-color: #fff;
|
| 167 |
-
display: flex;
|
| 168 |
-
align-items: center;
|
| 169 |
-
justify-content: center;
|
| 170 |
-
color: #555;
|
| 171 |
-
font-size: 24px;
|
| 172 |
-
position: relative;
|
| 173 |
-
/* Keep video content centered without overflowing the frame. */
|
| 174 |
-
overflow: hidden;
|
| 175 |
-
}
|
| 176 |
-
|
| 177 |
-
.focus-display-shell {
|
| 178 |
-
background: linear-gradient(180deg, #f7f5f2 0%, #f1f0ec 100%);
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
padding:
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
}
|
| 225 |
-
|
| 226 |
-
.focus-flow-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
}
|
| 244 |
-
|
| 245 |
-
.focus-flow-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
}
|
| 297 |
-
|
| 298 |
-
.focus-flow-step
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
}
|
| 323 |
-
|
| 324 |
-
.focus-flow-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
}
|
| 341 |
-
|
| 342 |
-
.focus-flow-button
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
}
|
| 353 |
-
|
| 354 |
-
.focus-flow-
|
| 355 |
-
background: #
|
| 356 |
-
color: #
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
color:
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
}
|
| 393 |
-
|
| 394 |
-
.focus-state-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
}
|
| 401 |
-
|
| 402 |
-
.focus-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
background:
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
font-
|
| 438 |
-
|
| 439 |
-
}
|
| 440 |
-
|
| 441 |
-
.focus-inline-error
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
}
|
| 457 |
-
|
| 458 |
-
.focus-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
border-radius:
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
}
|
| 469 |
-
|
| 470 |
-
.focus-model-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
}
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
}
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
}
|
| 605 |
-
|
| 606 |
-
.
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
}
|
| 672 |
-
|
| 673 |
-
.filter-btn
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
}
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
margin
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
border-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
background: #
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
margin:
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
}
|
| 791 |
-
|
| 792 |
-
.records-detail-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
padding:
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
background: #
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
}
|
| 827 |
-
|
| 828 |
-
.records-detail-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
}
|
| 839 |
-
|
| 840 |
-
.records-detail-stat
|
| 841 |
-
|
| 842 |
-
border-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
}
|
| 903 |
-
|
| 904 |
-
.records-detail-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
}
|
| 909 |
-
|
| 910 |
-
.records-detail-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
}
|
| 944 |
-
|
| 945 |
-
.records-detail-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
}
|
| 961 |
-
|
| 962 |
-
.records-detail-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
}
|
| 996 |
-
|
| 997 |
-
.records-detail-
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
background: #
|
| 1062 |
-
color: #
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
margin
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
padding
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
}
|
| 1123 |
-
|
| 1124 |
-
.
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
}
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
/*
|
| 1159 |
-
|
| 1160 |
-
.
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
}
|
| 1203 |
-
|
| 1204 |
-
.fake-ad-
|
| 1205 |
-
position: absolute;
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
background:
|
| 1209 |
-
color: #fff;
|
| 1210 |
-
font-size:
|
| 1211 |
-
padding:
|
| 1212 |
-
border-radius:
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
-
}
|
| 1234 |
-
|
| 1235 |
-
.help-section
|
| 1236 |
-
|
| 1237 |
-
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
|
| 1241 |
-
|
| 1242 |
-
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
}
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
|
| 1275 |
-
|
| 1276 |
-
|
| 1277 |
-
|
| 1278 |
-
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
|
| 1282 |
-
|
| 1283 |
-
|
| 1284 |
-
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
| 1308 |
-
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
|
| 1317 |
-
|
| 1318 |
-
|
| 1319 |
-
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
|
| 1323 |
-
|
| 1324 |
-
|
| 1325 |
-
}
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
| 1341 |
-
|
| 1342 |
-
|
| 1343 |
-
|
| 1344 |
-
|
| 1345 |
-
|
| 1346 |
-
|
| 1347 |
-
|
| 1348 |
-
}
|
| 1349 |
-
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
}
|
| 1356 |
-
|
| 1357 |
-
.
|
| 1358 |
-
|
| 1359 |
-
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
|
| 1365 |
-
|
| 1366 |
-
width: 90%;
|
| 1367 |
-
|
| 1368 |
-
|
| 1369 |
-
|
| 1370 |
-
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
|
| 1394 |
-
|
| 1395 |
-
|
| 1396 |
-
|
| 1397 |
-
|
| 1398 |
-
|
| 1399 |
-
|
| 1400 |
-
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
|
| 1405 |
-
|
| 1406 |
-
|
| 1407 |
-
|
| 1408 |
-
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
-
|
| 1412 |
-
|
| 1413 |
-
|
| 1414 |
-
|
| 1415 |
-
|
| 1416 |
-
|
| 1417 |
-
|
| 1418 |
-
|
| 1419 |
-
|
| 1420 |
-
|
| 1421 |
-
|
| 1422 |
-
|
| 1423 |
-
|
| 1424 |
-
|
| 1425 |
-
|
| 1426 |
-
|
| 1427 |
-
|
| 1428 |
-
|
| 1429 |
-
|
| 1430 |
-
|
| 1431 |
-
|
| 1432 |
-
|
| 1433 |
-
|
| 1434 |
-
|
| 1435 |
-
|
| 1436 |
-
|
| 1437 |
-
|
| 1438 |
-
.records-detail-
|
| 1439 |
-
|
| 1440 |
-
|
| 1441 |
-
}
|
| 1442 |
-
|
| 1443 |
-
.records-detail-
|
| 1444 |
-
|
| 1445 |
-
|
| 1446 |
-
|
| 1447 |
-
}
|
| 1448 |
-
|
| 1449 |
-
|
| 1450 |
-
|
| 1451 |
-
|
| 1452 |
-
|
| 1453 |
-
|
| 1454 |
-
|
| 1455 |
-
|
| 1456 |
-
|
| 1457 |
-
|
| 1458 |
-
|
| 1459 |
-
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
|
| 1463 |
-
|
| 1464 |
-
|
| 1465 |
-
|
| 1466 |
-
|
| 1467 |
-
|
| 1468 |
-
|
| 1469 |
-
|
| 1470 |
-
|
| 1471 |
-
|
| 1472 |
-
|
| 1473 |
-
|
| 1474 |
-
|
| 1475 |
-
|
| 1476 |
-
|
| 1477 |
-
|
| 1478 |
-
|
| 1479 |
-
|
| 1480 |
-
|
| 1481 |
-
|
| 1482 |
-
font-size:
|
| 1483 |
-
|
| 1484 |
-
|
| 1485 |
-
|
| 1486 |
-
|
| 1487 |
-
|
| 1488 |
-
|
| 1489 |
-
|
| 1490 |
-
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
font-
|
| 1495 |
-
|
| 1496 |
-
|
| 1497 |
-
|
| 1498 |
-
|
| 1499 |
-
|
| 1500 |
-
|
| 1501 |
-
|
| 1502 |
-
|
| 1503 |
-
|
| 1504 |
-
.
|
| 1505 |
-
|
| 1506 |
-
|
| 1507 |
-
|
| 1508 |
-
|
| 1509 |
-
|
| 1510 |
-
|
| 1511 |
-
|
| 1512 |
-
}
|
| 1513 |
-
|
| 1514 |
-
|
| 1515 |
-
|
| 1516 |
-
|
| 1517 |
-
|
| 1518 |
-
|
| 1519 |
-
|
| 1520 |
-
|
| 1521 |
-
|
| 1522 |
-
|
| 1523 |
-
|
| 1524 |
-
|
| 1525 |
-
|
| 1526 |
-
|
| 1527 |
-
|
| 1528 |
-
|
| 1529 |
-
|
| 1530 |
-
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
}
|
| 1534 |
-
|
| 1535 |
-
.
|
| 1536 |
-
|
| 1537 |
-
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
-
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 1547 |
-
|
| 1548 |
-
|
| 1549 |
-
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
}
|
| 1560 |
-
|
| 1561 |
-
.
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
| 1570 |
-
|
| 1571 |
-
|
| 1572 |
-
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
|
| 1580 |
-
|
| 1581 |
-
|
| 1582 |
-
|
| 1583 |
-
|
| 1584 |
-
|
| 1585 |
-
|
| 1586 |
-
|
| 1587 |
-
|
| 1588 |
-
|
| 1589 |
-
|
| 1590 |
-
|
| 1591 |
-
|
| 1592 |
-
|
| 1593 |
-
|
| 1594 |
-
|
| 1595 |
-
|
| 1596 |
-
|
| 1597 |
-
|
| 1598 |
-
|
| 1599 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================
|
| 2 |
+
1. REACT layout setting
|
| 3 |
+
========================================= */
|
| 4 |
+
html, body, #root {
|
| 5 |
+
width: 100%;
|
| 6 |
+
height: 100%;
|
| 7 |
+
margin: 0;
|
| 8 |
+
padding: 0;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.app-container {
|
| 12 |
+
width: 100%;
|
| 13 |
+
min-height: 100vh; /* screen height */
|
| 14 |
+
display: flex;
|
| 15 |
+
flex-direction: column;
|
| 16 |
+
background-color: #f9f9f9;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* =========================================
|
| 20 |
+
2. original layout
|
| 21 |
+
========================================= */
|
| 22 |
+
|
| 23 |
+
/* GLOBAL STYLES */
|
| 24 |
+
body {
|
| 25 |
+
font-family: 'Nunito', sans-serif;
|
| 26 |
+
background-color: #f9f9f9;
|
| 27 |
+
overflow-x: hidden;
|
| 28 |
+
overflow-y: auto;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* dynamic class name */
|
| 32 |
+
.hidden {
|
| 33 |
+
display: none !important;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* TOP MENU */
|
| 37 |
+
#top-menu {
|
| 38 |
+
height: 60px;
|
| 39 |
+
background-color: white;
|
| 40 |
+
display: flex;
|
| 41 |
+
align-items: center;
|
| 42 |
+
justify-content: center; /* Center buttons horizontally */
|
| 43 |
+
gap: 0;
|
| 44 |
+
padding: 0 24px 0 76px;
|
| 45 |
+
box-sizing: border-box;
|
| 46 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
| 47 |
+
position: fixed;
|
| 48 |
+
top: 0;
|
| 49 |
+
left: 0;
|
| 50 |
+
right: 0;
|
| 51 |
+
width: 100%;
|
| 52 |
+
z-index: 1000;
|
| 53 |
+
overflow-x: auto;
|
| 54 |
+
overflow-y: hidden;
|
| 55 |
+
white-space: nowrap;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.menu-btn {
|
| 59 |
+
background: none;
|
| 60 |
+
border: none;
|
| 61 |
+
font-family: 'Nunito', sans-serif;
|
| 62 |
+
font-size: 16px;
|
| 63 |
+
color: #333;
|
| 64 |
+
padding: 10px 20px;
|
| 65 |
+
cursor: pointer;
|
| 66 |
+
transition: background-color 0.2s;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.menu-btn:hover {
|
| 70 |
+
background-color: #f0f0f0;
|
| 71 |
+
border-radius: 4px;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* active for React */
|
| 75 |
+
.menu-btn.active {
|
| 76 |
+
font-weight: bold;
|
| 77 |
+
color: #007BFF;
|
| 78 |
+
background-color: #eef7ff;
|
| 79 |
+
border-radius: 4px;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.separator {
|
| 83 |
+
width: 1px;
|
| 84 |
+
height: 20px;
|
| 85 |
+
background-color: #555; /* Dark gray separator */
|
| 86 |
+
margin: 0 5px;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* PAGE CONTAINER */
|
| 90 |
+
.page {
|
| 91 |
+
/* content under menu */
|
| 92 |
+
min-height: calc(100vh - 60px);
|
| 93 |
+
width: 100%;
|
| 94 |
+
padding-top: 60px; /* Space for fixed menu */
|
| 95 |
+
padding-bottom: 40px; /* Space at bottom for scrolling */
|
| 96 |
+
box-sizing: border-box;
|
| 97 |
+
display: flex;
|
| 98 |
+
flex-direction: column;
|
| 99 |
+
align-items: center;
|
| 100 |
+
overflow-y: auto;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* Ensure page titles are black */
|
| 104 |
+
.page h1 {
|
| 105 |
+
color: #000 !important;
|
| 106 |
+
background: transparent !important;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.page-title {
|
| 110 |
+
color: #000 !important;
|
| 111 |
+
background: transparent !important;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* PAGE A SPECIFIC */
|
| 115 |
+
#page-a {
|
| 116 |
+
justify-content: center; /* Center vertically */
|
| 117 |
+
/* Fine-tune this margin if the Home screen sits slightly too low. */
|
| 118 |
+
margin-top: -40px;
|
| 119 |
+
flex: 1; /* Fill the remaining height so vertical centering still works. */
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
#page-a h1 {
|
| 123 |
+
font-size: 80px;
|
| 124 |
+
margin: 0 0 10px 0;
|
| 125 |
+
color: #000;
|
| 126 |
+
text-align: center; /* Keep the heading centered. */
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
#page-a p {
|
| 130 |
+
color: #666;
|
| 131 |
+
font-size: 20px;
|
| 132 |
+
margin-bottom: 40px;
|
| 133 |
+
text-align: center;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.btn-main {
|
| 137 |
+
background-color: #007BFF; /* Blue */
|
| 138 |
+
color: white;
|
| 139 |
+
border: none;
|
| 140 |
+
padding: 15px 50px;
|
| 141 |
+
font-size: 20px;
|
| 142 |
+
font-family: 'Nunito', sans-serif;
|
| 143 |
+
border-radius: 30px; /* Fully rounded corners */
|
| 144 |
+
cursor: pointer;
|
| 145 |
+
transition: transform 0.2s ease;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.btn-main:hover {
|
| 149 |
+
transform: scale(1.1); /* Zoom effect */
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/* PAGE B SPECIFIC */
|
| 153 |
+
#page-b {
|
| 154 |
+
justify-content: space-evenly; /* Distribute vertical space */
|
| 155 |
+
padding-bottom: 20px;
|
| 156 |
+
min-height: calc(100vh - 60px); /* Ensure the page still fills the viewport. */
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/* 1. Display Area */
|
| 160 |
+
#display-area {
|
| 161 |
+
width: 60%;
|
| 162 |
+
height: 50vh; /* Use viewport height to scale more consistently across screens. */
|
| 163 |
+
min-height: 300px;
|
| 164 |
+
border: 2px solid #ddd;
|
| 165 |
+
border-radius: 12px;
|
| 166 |
+
background-color: #fff;
|
| 167 |
+
display: flex;
|
| 168 |
+
align-items: center;
|
| 169 |
+
justify-content: center;
|
| 170 |
+
color: #555;
|
| 171 |
+
font-size: 24px;
|
| 172 |
+
position: relative;
|
| 173 |
+
/* Keep video content centered without overflowing the frame. */
|
| 174 |
+
overflow: hidden;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.focus-display-shell {
|
| 178 |
+
background: linear-gradient(180deg, #f7f5f2 0%, #f1f0ec 100%);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
@keyframes fadeInOverlay {
|
| 182 |
+
from { opacity: 0; }
|
| 183 |
+
to { opacity: 1; }
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
@keyframes slideUpCard {
|
| 187 |
+
from { opacity: 0; transform: translateY(30px); }
|
| 188 |
+
to { opacity: 1; transform: translateY(0); }
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.focus-flow-overlay {
|
| 192 |
+
position: fixed;
|
| 193 |
+
top: 76px;
|
| 194 |
+
right: 20px;
|
| 195 |
+
bottom: 20px;
|
| 196 |
+
left: 20px;
|
| 197 |
+
display: flex;
|
| 198 |
+
align-items: center;
|
| 199 |
+
justify-content: center;
|
| 200 |
+
padding: 0;
|
| 201 |
+
background: rgba(17, 31, 52, 0.18);
|
| 202 |
+
backdrop-filter: blur(10px);
|
| 203 |
+
z-index: 900;
|
| 204 |
+
animation: fadeInOverlay 0.3s ease-out forwards;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.focus-flow-card {
|
| 208 |
+
width: min(1040px, 100%);
|
| 209 |
+
background: #fff;
|
| 210 |
+
border-radius: 24px;
|
| 211 |
+
padding: 30px 34px;
|
| 212 |
+
box-shadow: 0 28px 80px rgba(14, 44, 88, 0.18);
|
| 213 |
+
border: 1px solid rgba(0, 123, 255, 0.12);
|
| 214 |
+
box-sizing: border-box;
|
| 215 |
+
animation: slideUpCard 0.4s ease-out forwards;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.focus-flow-header {
|
| 219 |
+
display: flex;
|
| 220 |
+
align-items: center;
|
| 221 |
+
justify-content: space-between;
|
| 222 |
+
gap: 24px;
|
| 223 |
+
margin-bottom: 18px;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.focus-flow-eyebrow {
|
| 227 |
+
display: inline-block;
|
| 228 |
+
padding: 6px 12px;
|
| 229 |
+
border-radius: 999px;
|
| 230 |
+
background: #e7f3ff;
|
| 231 |
+
color: #007BFF;
|
| 232 |
+
font-size: 0.82rem;
|
| 233 |
+
font-weight: 800;
|
| 234 |
+
letter-spacing: 0.04em;
|
| 235 |
+
text-transform: uppercase;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.focus-flow-header h2 {
|
| 239 |
+
margin: 14px 0 0;
|
| 240 |
+
color: #333;
|
| 241 |
+
font-size: clamp(1.8rem, 2.5vw, 2.5rem);
|
| 242 |
+
line-height: 1.1;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.focus-flow-icon {
|
| 246 |
+
flex: 0 0 auto;
|
| 247 |
+
display: flex;
|
| 248 |
+
align-items: center;
|
| 249 |
+
justify-content: center;
|
| 250 |
+
width: 116px;
|
| 251 |
+
height: 116px;
|
| 252 |
+
border-radius: 24px;
|
| 253 |
+
background: linear-gradient(180deg, #f4f9ff 0%, #edf5ff 100%);
|
| 254 |
+
border: 1px solid rgba(0, 123, 255, 0.12);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.focus-flow-lead {
|
| 258 |
+
margin: 0 0 20px;
|
| 259 |
+
color: #4a4a4a;
|
| 260 |
+
font-size: 1rem;
|
| 261 |
+
line-height: 1.6;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.focus-flow-grid {
|
| 265 |
+
display: grid;
|
| 266 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 267 |
+
gap: 16px;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.focus-flow-panel {
|
| 271 |
+
background: #f8fbff;
|
| 272 |
+
border: 1px solid #d9eaff;
|
| 273 |
+
border-radius: 14px;
|
| 274 |
+
padding: 18px;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.focus-flow-panel h3,
|
| 278 |
+
.focus-flow-step-copy h3 {
|
| 279 |
+
margin: 0 0 8px;
|
| 280 |
+
color: #333;
|
| 281 |
+
font-size: 1rem;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.focus-flow-panel p,
|
| 285 |
+
.focus-flow-step-copy p {
|
| 286 |
+
margin: 0;
|
| 287 |
+
color: #5e6670;
|
| 288 |
+
font-size: 0.95rem;
|
| 289 |
+
line-height: 1.6;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.focus-flow-steps {
|
| 293 |
+
display: grid;
|
| 294 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 295 |
+
gap: 14px;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.focus-flow-step {
|
| 299 |
+
display: flex;
|
| 300 |
+
align-items: flex-start;
|
| 301 |
+
gap: 14px;
|
| 302 |
+
background: #f8fbff;
|
| 303 |
+
border: 1px solid #d9eaff;
|
| 304 |
+
border-radius: 14px;
|
| 305 |
+
padding: 16px 18px;
|
| 306 |
+
min-height: 100px;
|
| 307 |
+
box-sizing: border-box;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.focus-flow-step-number {
|
| 311 |
+
flex: 0 0 auto;
|
| 312 |
+
display: inline-flex;
|
| 313 |
+
align-items: center;
|
| 314 |
+
justify-content: center;
|
| 315 |
+
width: 34px;
|
| 316 |
+
height: 34px;
|
| 317 |
+
border-radius: 50%;
|
| 318 |
+
background: #007BFF;
|
| 319 |
+
color: #fff;
|
| 320 |
+
font-size: 0.95rem;
|
| 321 |
+
font-weight: 800;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.focus-flow-step-copy {
|
| 325 |
+
min-width: 0;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.focus-flow-footer {
|
| 329 |
+
display: flex;
|
| 330 |
+
align-items: center;
|
| 331 |
+
justify-content: space-between;
|
| 332 |
+
gap: 16px;
|
| 333 |
+
margin-top: 20px;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.focus-flow-note {
|
| 337 |
+
color: #667281;
|
| 338 |
+
font-size: 0.94rem;
|
| 339 |
+
line-height: 1.6;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.focus-flow-button,
|
| 343 |
+
.focus-flow-secondary {
|
| 344 |
+
border: none;
|
| 345 |
+
border-radius: 999px;
|
| 346 |
+
padding: 13px 24px;
|
| 347 |
+
font-family: 'Nunito', sans-serif;
|
| 348 |
+
font-size: 0.98rem;
|
| 349 |
+
font-weight: 800;
|
| 350 |
+
cursor: pointer;
|
| 351 |
+
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.focus-flow-button {
|
| 355 |
+
background: #007BFF;
|
| 356 |
+
color: #fff;
|
| 357 |
+
box-shadow: 0 12px 24px rgba(0, 123, 255, 0.18);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.focus-flow-button:hover {
|
| 361 |
+
background: #0069d9;
|
| 362 |
+
border-color: transparent;
|
| 363 |
+
transform: translateY(-1px);
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.focus-flow-secondary {
|
| 367 |
+
background: #eef3f8;
|
| 368 |
+
color: #4b5a6b;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.focus-flow-secondary:hover {
|
| 372 |
+
background: #e2eaf3;
|
| 373 |
+
border-color: transparent;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.focus-state-pill {
|
| 377 |
+
position: absolute;
|
| 378 |
+
top: 18px;
|
| 379 |
+
left: 18px;
|
| 380 |
+
display: inline-flex;
|
| 381 |
+
align-items: center;
|
| 382 |
+
gap: 10px;
|
| 383 |
+
padding: 10px 16px;
|
| 384 |
+
border-radius: 999px;
|
| 385 |
+
color: #fff;
|
| 386 |
+
font-size: 0.88rem;
|
| 387 |
+
font-weight: 800;
|
| 388 |
+
letter-spacing: 0.04em;
|
| 389 |
+
text-transform: uppercase;
|
| 390 |
+
z-index: 2;
|
| 391 |
+
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.focus-state-pill.pending {
|
| 395 |
+
background: rgba(87, 96, 111, 0.92);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.focus-state-pill.focused {
|
| 399 |
+
background: rgba(33, 163, 102, 0.94);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.focus-state-pill.not-focused {
|
| 403 |
+
background: rgba(215, 68, 68, 0.94);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.focus-state-dot {
|
| 407 |
+
width: 10px;
|
| 408 |
+
height: 10px;
|
| 409 |
+
border-radius: 50%;
|
| 410 |
+
background: currentColor;
|
| 411 |
+
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.16);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.focus-idle-overlay {
|
| 415 |
+
position: absolute;
|
| 416 |
+
inset: 0;
|
| 417 |
+
display: flex;
|
| 418 |
+
flex-direction: column;
|
| 419 |
+
align-items: center;
|
| 420 |
+
justify-content: center;
|
| 421 |
+
gap: 10px;
|
| 422 |
+
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.72));
|
| 423 |
+
color: #fff;
|
| 424 |
+
text-align: center;
|
| 425 |
+
z-index: 1;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.focus-idle-overlay p {
|
| 429 |
+
margin: 0;
|
| 430 |
+
font-size: 1.6rem;
|
| 431 |
+
font-weight: 800;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.focus-idle-overlay span {
|
| 435 |
+
max-width: 420px;
|
| 436 |
+
color: rgba(255, 255, 255, 0.82);
|
| 437 |
+
font-size: 0.98rem;
|
| 438 |
+
line-height: 1.5;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
.focus-inline-error {
|
| 442 |
+
margin-top: 18px;
|
| 443 |
+
padding: 12px 16px;
|
| 444 |
+
max-width: 620px;
|
| 445 |
+
border-radius: 12px;
|
| 446 |
+
background: #fff1ee;
|
| 447 |
+
color: #b54028;
|
| 448 |
+
font-size: 0.95rem;
|
| 449 |
+
font-weight: 700;
|
| 450 |
+
box-shadow: 0 10px 20px rgba(181, 64, 40, 0.08);
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.focus-inline-error-standalone {
|
| 454 |
+
width: 60%;
|
| 455 |
+
box-sizing: border-box;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
.focus-debug-panel {
|
| 459 |
+
position: absolute;
|
| 460 |
+
top: 10px;
|
| 461 |
+
right: 10px;
|
| 462 |
+
background: rgba(0,0,0,0.7);
|
| 463 |
+
color: white;
|
| 464 |
+
padding: 10px;
|
| 465 |
+
border-radius: 5px;
|
| 466 |
+
font-size: 12px;
|
| 467 |
+
font-family: monospace;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.focus-model-strip {
|
| 471 |
+
display: flex;
|
| 472 |
+
align-items: center;
|
| 473 |
+
justify-content: center;
|
| 474 |
+
gap: 8px;
|
| 475 |
+
padding: 8px 16px;
|
| 476 |
+
background: #1a1a2e;
|
| 477 |
+
border-radius: 8px;
|
| 478 |
+
margin: 8px auto;
|
| 479 |
+
max-width: 600px;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.focus-model-label {
|
| 483 |
+
color: #aaa;
|
| 484 |
+
font-size: 13px;
|
| 485 |
+
margin-right: 4px;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
.focus-model-button {
|
| 489 |
+
padding: 5px 14px;
|
| 490 |
+
border-radius: 16px;
|
| 491 |
+
border: 1px solid #555;
|
| 492 |
+
background: transparent;
|
| 493 |
+
color: #ccc;
|
| 494 |
+
font-size: 12px;
|
| 495 |
+
font-weight: 600;
|
| 496 |
+
text-transform: uppercase;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
.focus-model-button.active {
|
| 500 |
+
border: 2px solid #007BFF;
|
| 501 |
+
background: #007BFF;
|
| 502 |
+
color: #fff;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
#display-area video {
|
| 506 |
+
width: 100%;
|
| 507 |
+
height: 100%;
|
| 508 |
+
object-fit: cover; /* Behaves similarly to background-size: cover. */
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
/* 2. Timeline Area */
|
| 512 |
+
#timeline-area {
|
| 513 |
+
width: 60%;
|
| 514 |
+
height: 80px;
|
| 515 |
+
position: relative;
|
| 516 |
+
display: flex;
|
| 517 |
+
flex-direction: column;
|
| 518 |
+
justify-content: flex-end;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.timeline-label {
|
| 522 |
+
position: absolute;
|
| 523 |
+
top: 0;
|
| 524 |
+
left: 0;
|
| 525 |
+
color: #888;
|
| 526 |
+
font-size: 14px;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
#timeline-line {
|
| 530 |
+
width: 100%;
|
| 531 |
+
height: 2px;
|
| 532 |
+
background-color: #87CEEB; /* Light blue */
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
/* 3. Control Panel */
|
| 536 |
+
#control-panel {
|
| 537 |
+
display: flex;
|
| 538 |
+
gap: 20px;
|
| 539 |
+
width: 60%;
|
| 540 |
+
justify-content: space-between;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.action-btn {
|
| 544 |
+
flex: 1; /* Evenly distributed width */
|
| 545 |
+
padding: 12px 0;
|
| 546 |
+
border: none;
|
| 547 |
+
border-radius: 12px;
|
| 548 |
+
font-size: 16px;
|
| 549 |
+
font-family: 'Nunito', sans-serif;
|
| 550 |
+
font-weight: 700;
|
| 551 |
+
cursor: pointer;
|
| 552 |
+
color: white;
|
| 553 |
+
transition: opacity 0.2s;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
.action-btn:hover {
|
| 557 |
+
opacity: 0.9;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.action-btn.green { background-color: #28a745; }
|
| 561 |
+
.action-btn.yellow { background-color: #ffce0b; }
|
| 562 |
+
.action-btn.blue { background-color: #326ed6; }
|
| 563 |
+
.action-btn.red { background-color: #dc3545; }
|
| 564 |
+
|
| 565 |
+
/* 4. Frame Control */
|
| 566 |
+
#frame-control {
|
| 567 |
+
display: flex;
|
| 568 |
+
align-items: center;
|
| 569 |
+
gap: 15px;
|
| 570 |
+
color: #333;
|
| 571 |
+
font-weight: bold;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
#frame-slider {
|
| 575 |
+
width: 200px;
|
| 576 |
+
cursor: pointer;
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
#frame-input {
|
| 580 |
+
width: 50px;
|
| 581 |
+
padding: 5px;
|
| 582 |
+
border: 1px solid #ccc;
|
| 583 |
+
border-radius: 5px;
|
| 584 |
+
text-align: center;
|
| 585 |
+
font-family: 'Nunito', sans-serif;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
/* ================ ACHIEVEMENT PAGE ================ */
|
| 589 |
+
|
| 590 |
+
.stats-grid {
|
| 591 |
+
display: grid;
|
| 592 |
+
grid-template-columns: repeat(4, 1fr);
|
| 593 |
+
gap: 20px;
|
| 594 |
+
width: 80%;
|
| 595 |
+
margin: 40px auto;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
.stat-card {
|
| 599 |
+
background: white;
|
| 600 |
+
padding: 30px;
|
| 601 |
+
border-radius: 12px;
|
| 602 |
+
text-align: center;
|
| 603 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.stat-number {
|
| 607 |
+
font-size: 48px;
|
| 608 |
+
font-weight: bold;
|
| 609 |
+
color: #007BFF;
|
| 610 |
+
margin-bottom: 10px;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
.stat-label {
|
| 614 |
+
font-size: 16px;
|
| 615 |
+
color: #666;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.achievements-section {
|
| 619 |
+
width: 80%;
|
| 620 |
+
margin: 0 auto;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.achievements-section h2 {
|
| 624 |
+
color: #333;
|
| 625 |
+
margin-bottom: 20px;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
.badges-grid {
|
| 629 |
+
display: grid;
|
| 630 |
+
grid-template-columns: repeat(3, 1fr);
|
| 631 |
+
gap: 20px;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.badge {
|
| 635 |
+
background: white;
|
| 636 |
+
padding: 30px 20px;
|
| 637 |
+
border-radius: 12px;
|
| 638 |
+
text-align: center;
|
| 639 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 640 |
+
transition: transform 0.2s;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.badge:hover {
|
| 644 |
+
transform: translateY(-5px);
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
.badge.locked {
|
| 648 |
+
opacity: 0.4;
|
| 649 |
+
filter: grayscale(100%);
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.badge-icon {
|
| 653 |
+
font-size: 64px;
|
| 654 |
+
margin-bottom: 15px;
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.badge-name {
|
| 658 |
+
font-size: 16px;
|
| 659 |
+
font-weight: bold;
|
| 660 |
+
color: #333;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
/* ================ RECORDS PAGE ================ */
|
| 664 |
+
|
| 665 |
+
.records-controls {
|
| 666 |
+
display: flex;
|
| 667 |
+
gap: 10px;
|
| 668 |
+
margin: 20px auto;
|
| 669 |
+
width: 80%;
|
| 670 |
+
justify-content: center;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
.filter-btn {
|
| 674 |
+
padding: 10px 20px;
|
| 675 |
+
border: 2px solid #007BFF;
|
| 676 |
+
background: white;
|
| 677 |
+
color: #007BFF;
|
| 678 |
+
border-radius: 8px;
|
| 679 |
+
cursor: pointer;
|
| 680 |
+
font-family: 'Nunito', sans-serif;
|
| 681 |
+
font-weight: 600;
|
| 682 |
+
transition: all 0.2s;
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
.filter-btn:hover {
|
| 686 |
+
background: #e7f3ff;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.filter-btn.active {
|
| 690 |
+
background: #007BFF;
|
| 691 |
+
color: white;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
.chart-container {
|
| 695 |
+
width: 80%;
|
| 696 |
+
background: white;
|
| 697 |
+
padding: 30px;
|
| 698 |
+
border-radius: 12px;
|
| 699 |
+
margin: 20px auto;
|
| 700 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
#focus-chart {
|
| 704 |
+
display: block;
|
| 705 |
+
margin: 0 auto;
|
| 706 |
+
/* Make sure the chart scales within its container. */
|
| 707 |
+
max-width: 100%;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.sessions-list {
|
| 711 |
+
width: 80%;
|
| 712 |
+
margin: 20px auto;
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
.sessions-list h2 {
|
| 716 |
+
color: #333;
|
| 717 |
+
margin-bottom: 15px;
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
#sessions-table {
|
| 721 |
+
width: 100%;
|
| 722 |
+
background: white;
|
| 723 |
+
border-collapse: collapse;
|
| 724 |
+
border-radius: 12px;
|
| 725 |
+
overflow: hidden;
|
| 726 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
#sessions-table th {
|
| 730 |
+
background: #007BFF;
|
| 731 |
+
color: white;
|
| 732 |
+
padding: 15px;
|
| 733 |
+
text-align: left;
|
| 734 |
+
font-weight: 600;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
#sessions-table td {
|
| 738 |
+
padding: 12px 15px;
|
| 739 |
+
border-bottom: 1px solid #eee;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
#sessions-table tr:last-child td {
|
| 743 |
+
border-bottom: none;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
#sessions-table tbody tr:hover {
|
| 747 |
+
background: #f8f9fa;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
.btn-view {
|
| 751 |
+
padding: 6px 18px;
|
| 752 |
+
background: #007BFF;
|
| 753 |
+
color: white;
|
| 754 |
+
border: none;
|
| 755 |
+
border-radius: 999px;
|
| 756 |
+
cursor: pointer;
|
| 757 |
+
font-family: 'Nunito', sans-serif;
|
| 758 |
+
font-size: 12px;
|
| 759 |
+
font-weight: 700;
|
| 760 |
+
transition: background 0.2s;
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
.btn-view:hover {
|
| 764 |
+
background: #0056b3;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
.records-detail-modal {
|
| 768 |
+
width: min(960px, 92vw);
|
| 769 |
+
max-width: 960px;
|
| 770 |
+
max-height: 86vh;
|
| 771 |
+
overflow-y: auto;
|
| 772 |
+
padding: 30px;
|
| 773 |
+
box-sizing: border-box;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
.records-detail-header {
|
| 777 |
+
display: flex;
|
| 778 |
+
justify-content: space-between;
|
| 779 |
+
align-items: flex-start;
|
| 780 |
+
gap: 20px;
|
| 781 |
+
margin-bottom: 24px;
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
.records-detail-kicker {
|
| 785 |
+
color: #007BFF;
|
| 786 |
+
font-size: 12px;
|
| 787 |
+
font-weight: 800;
|
| 788 |
+
letter-spacing: 0.08em;
|
| 789 |
+
text-transform: uppercase;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
.records-detail-header h2 {
|
| 793 |
+
margin: 10px 0 8px;
|
| 794 |
+
color: #333;
|
| 795 |
+
text-align: left;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
.records-detail-subtitle {
|
| 799 |
+
margin: 0;
|
| 800 |
+
color: #667281;
|
| 801 |
+
line-height: 1.6;
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
.records-detail-close {
|
| 805 |
+
border: 1px solid #d6e6fa;
|
| 806 |
+
background: #f4f9ff;
|
| 807 |
+
color: #3569a8;
|
| 808 |
+
border-radius: 999px;
|
| 809 |
+
padding: 10px 18px;
|
| 810 |
+
font-family: 'Nunito', sans-serif;
|
| 811 |
+
font-weight: 700;
|
| 812 |
+
cursor: pointer;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
.records-detail-close:hover {
|
| 816 |
+
border-color: #bfd9f7;
|
| 817 |
+
background: #e9f4ff;
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
.records-detail-feedback {
|
| 821 |
+
padding: 18px 20px;
|
| 822 |
+
border-radius: 14px;
|
| 823 |
+
background: #f7f9fc;
|
| 824 |
+
color: #516173;
|
| 825 |
+
font-weight: 700;
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
.records-detail-feedback-error {
|
| 829 |
+
background: #fff1ee;
|
| 830 |
+
color: #b54028;
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
.records-detail-summary {
|
| 834 |
+
display: grid;
|
| 835 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 836 |
+
gap: 14px;
|
| 837 |
+
margin-bottom: 18px;
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
.records-detail-stat {
|
| 841 |
+
padding: 18px;
|
| 842 |
+
border-radius: 14px;
|
| 843 |
+
background: #f8fbff;
|
| 844 |
+
border: 1px solid #d9eaff;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
.records-detail-stat.excellent {
|
| 848 |
+
background: #eef9f0;
|
| 849 |
+
border-color: #cdebd3;
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
.records-detail-stat.good {
|
| 853 |
+
background: #fff9eb;
|
| 854 |
+
border-color: #f8e3a8;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.records-detail-stat.fair {
|
| 858 |
+
background: #fff4eb;
|
| 859 |
+
border-color: #ffd6af;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
.records-detail-stat.low {
|
| 863 |
+
background: #fff0f0;
|
| 864 |
+
border-color: #f3c7c7;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.records-detail-stat-label {
|
| 868 |
+
display: block;
|
| 869 |
+
margin-bottom: 8px;
|
| 870 |
+
color: #667281;
|
| 871 |
+
font-size: 13px;
|
| 872 |
+
font-weight: 700;
|
| 873 |
+
text-transform: uppercase;
|
| 874 |
+
letter-spacing: 0.04em;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
.records-detail-stat-value {
|
| 878 |
+
display: block;
|
| 879 |
+
color: #1f2d3d;
|
| 880 |
+
font-size: 28px;
|
| 881 |
+
line-height: 1.1;
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
.records-detail-grid {
|
| 885 |
+
display: grid;
|
| 886 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 887 |
+
gap: 16px;
|
| 888 |
+
margin-bottom: 16px;
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
.records-detail-card {
|
| 892 |
+
background: white;
|
| 893 |
+
border: 1px solid #e8eef5;
|
| 894 |
+
border-radius: 16px;
|
| 895 |
+
padding: 20px;
|
| 896 |
+
box-shadow: 0 8px 24px rgba(20, 44, 74, 0.06);
|
| 897 |
+
margin-bottom: 16px;
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
.records-detail-card:last-child {
|
| 901 |
+
margin-bottom: 0;
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
.records-detail-card h3 {
|
| 905 |
+
margin: 0 0 16px;
|
| 906 |
+
color: #333;
|
| 907 |
+
font-size: 18px;
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
.records-detail-list {
|
| 911 |
+
display: grid;
|
| 912 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 913 |
+
gap: 14px 18px;
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
.records-detail-item {
|
| 917 |
+
display: flex;
|
| 918 |
+
flex-direction: column;
|
| 919 |
+
gap: 6px;
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
.records-detail-item-label {
|
| 923 |
+
color: #7a8795;
|
| 924 |
+
font-size: 12px;
|
| 925 |
+
font-weight: 700;
|
| 926 |
+
text-transform: uppercase;
|
| 927 |
+
letter-spacing: 0.05em;
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
.records-detail-item-value {
|
| 931 |
+
color: #263445;
|
| 932 |
+
font-size: 15px;
|
| 933 |
+
font-weight: 700;
|
| 934 |
+
line-height: 1.5;
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
.records-detail-section-head {
|
| 938 |
+
display: flex;
|
| 939 |
+
align-items: center;
|
| 940 |
+
justify-content: space-between;
|
| 941 |
+
gap: 12px;
|
| 942 |
+
margin-bottom: 16px;
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
.records-detail-section-head span {
|
| 946 |
+
color: #7a8795;
|
| 947 |
+
font-size: 13px;
|
| 948 |
+
font-weight: 700;
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
.records-detail-timeline {
|
| 952 |
+
display: grid;
|
| 953 |
+
grid-template-columns: repeat(auto-fit, minmax(10px, 1fr));
|
| 954 |
+
gap: 5px;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
.records-detail-segment {
|
| 958 |
+
height: 48px;
|
| 959 |
+
border-radius: 999px;
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
.records-detail-segment.focused {
|
| 963 |
+
background: linear-gradient(180deg, #3ab86a 0%, #23a057 100%);
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
.records-detail-segment.mixed {
|
| 967 |
+
background: linear-gradient(180deg, #f1b447 0%, #df9a1e 100%);
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
.records-detail-segment.distracted {
|
| 971 |
+
background: linear-gradient(180deg, #ec7d7d 0%, #d9534f 100%);
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
.records-detail-legend {
|
| 975 |
+
display: flex;
|
| 976 |
+
flex-wrap: wrap;
|
| 977 |
+
gap: 16px;
|
| 978 |
+
margin-top: 14px;
|
| 979 |
+
color: #667281;
|
| 980 |
+
font-size: 13px;
|
| 981 |
+
font-weight: 700;
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
.records-detail-legend span {
|
| 985 |
+
display: inline-flex;
|
| 986 |
+
align-items: center;
|
| 987 |
+
gap: 8px;
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
.records-detail-dot {
|
| 991 |
+
width: 10px;
|
| 992 |
+
height: 10px;
|
| 993 |
+
border-radius: 50%;
|
| 994 |
+
display: inline-block;
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
.records-detail-dot.focused {
|
| 998 |
+
background: #23a057;
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
.records-detail-dot.mixed {
|
| 1002 |
+
background: #df9a1e;
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
.records-detail-dot.distracted {
|
| 1006 |
+
background: #d9534f;
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
.records-detail-events {
|
| 1010 |
+
display: grid;
|
| 1011 |
+
gap: 10px;
|
| 1012 |
+
max-height: 280px;
|
| 1013 |
+
overflow-y: auto;
|
| 1014 |
+
}
|
| 1015 |
+
|
| 1016 |
+
.records-detail-event {
|
| 1017 |
+
display: grid;
|
| 1018 |
+
grid-template-columns: auto 1fr auto;
|
| 1019 |
+
align-items: center;
|
| 1020 |
+
gap: 12px;
|
| 1021 |
+
padding: 12px 14px;
|
| 1022 |
+
background: #f8fbff;
|
| 1023 |
+
border: 1px solid #e1edf9;
|
| 1024 |
+
border-radius: 14px;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
.records-detail-event-time {
|
| 1028 |
+
min-width: 52px;
|
| 1029 |
+
color: #3569a8;
|
| 1030 |
+
font-size: 13px;
|
| 1031 |
+
font-weight: 800;
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
.records-detail-event-copy {
|
| 1035 |
+
min-width: 0;
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
.records-detail-event-status {
|
| 1039 |
+
color: #243345;
|
| 1040 |
+
font-size: 14px;
|
| 1041 |
+
font-weight: 800;
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
.records-detail-event-meta {
|
| 1045 |
+
margin-top: 4px;
|
| 1046 |
+
color: #6f7d8c;
|
| 1047 |
+
font-size: 12px;
|
| 1048 |
+
line-height: 1.5;
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
+
.records-detail-event-badge {
|
| 1052 |
+
padding: 7px 12px;
|
| 1053 |
+
border-radius: 999px;
|
| 1054 |
+
font-size: 11px;
|
| 1055 |
+
font-weight: 800;
|
| 1056 |
+
letter-spacing: 0.04em;
|
| 1057 |
+
text-transform: uppercase;
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
.records-detail-event-badge.focused {
|
| 1061 |
+
background: #eaf8ef;
|
| 1062 |
+
color: #1f8a4c;
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
.records-detail-event-badge.distracted {
|
| 1066 |
+
background: #fff1f1;
|
| 1067 |
+
color: #c24c49;
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
.records-detail-empty {
|
| 1071 |
+
padding: 16px 18px;
|
| 1072 |
+
border-radius: 14px;
|
| 1073 |
+
background: #f7f9fc;
|
| 1074 |
+
color: #708090;
|
| 1075 |
+
font-weight: 700;
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
/* ================ SETTINGS PAGE ================ */
|
| 1079 |
+
|
| 1080 |
+
.settings-container {
|
| 1081 |
+
width: 60%;
|
| 1082 |
+
max-width: 800px;
|
| 1083 |
+
margin: 20px auto;
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
+
.setting-group {
|
| 1087 |
+
background: white;
|
| 1088 |
+
padding: 30px;
|
| 1089 |
+
border-radius: 12px;
|
| 1090 |
+
margin-bottom: 20px;
|
| 1091 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 1092 |
+
}
|
| 1093 |
+
|
| 1094 |
+
.setting-group h2 {
|
| 1095 |
+
margin-top: 0;
|
| 1096 |
+
color: #333;
|
| 1097 |
+
font-size: 20px;
|
| 1098 |
+
margin-bottom: 20px;
|
| 1099 |
+
border-bottom: 2px solid #007BFF;
|
| 1100 |
+
padding-bottom: 10px;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
.setting-item {
|
| 1104 |
+
margin-bottom: 25px;
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
.setting-item:last-child {
|
| 1108 |
+
margin-bottom: 0;
|
| 1109 |
+
}
|
| 1110 |
+
|
| 1111 |
+
.setting-item label {
|
| 1112 |
+
display: block;
|
| 1113 |
+
margin-bottom: 8px;
|
| 1114 |
+
color: #333;
|
| 1115 |
+
font-weight: 600;
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
.slider-group {
|
| 1119 |
+
display: flex;
|
| 1120 |
+
align-items: center;
|
| 1121 |
+
gap: 15px;
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
.slider-group input[type="range"] {
|
| 1125 |
+
flex: 1;
|
| 1126 |
+
}
|
| 1127 |
+
|
| 1128 |
+
.slider-group span {
|
| 1129 |
+
min-width: 40px;
|
| 1130 |
+
text-align: center;
|
| 1131 |
+
font-weight: bold;
|
| 1132 |
+
color: #007BFF;
|
| 1133 |
+
font-size: 18px;
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
.setting-description {
|
| 1137 |
+
font-size: 14px;
|
| 1138 |
+
color: #666;
|
| 1139 |
+
margin-top: 5px;
|
| 1140 |
+
font-style: italic;
|
| 1141 |
+
}
|
| 1142 |
+
|
| 1143 |
+
input[type="checkbox"] {
|
| 1144 |
+
margin-right: 10px;
|
| 1145 |
+
cursor: pointer;
|
| 1146 |
+
}
|
| 1147 |
+
|
| 1148 |
+
input[type="number"] {
|
| 1149 |
+
width: 100px;
|
| 1150 |
+
padding: 8px;
|
| 1151 |
+
border: 1px solid #ccc;
|
| 1152 |
+
border-radius: 5px;
|
| 1153 |
+
font-family: 'Nunito', sans-serif;
|
| 1154 |
+
}
|
| 1155 |
+
|
| 1156 |
+
/* Center the settings buttons and give them more width. */
|
| 1157 |
+
.setting-group .action-btn {
|
| 1158 |
+
display: inline-block; /* Allow buttons to sit side by side. */
|
| 1159 |
+
width: 48%; /* Roughly half-width each, with a small gutter. */
|
| 1160 |
+
margin: 15px 1%; /* Vertical spacing plus horizontal separation. */
|
| 1161 |
+
text-align: center; /* Center the label text. */
|
| 1162 |
+
box-sizing: border-box; /* Prevent borders from forcing an early wrap. */
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
#save-settings {
|
| 1166 |
+
display: block;
|
| 1167 |
+
margin: 20px auto;
|
| 1168 |
+
}
|
| 1169 |
+
|
| 1170 |
+
/* ================ HELP PAGE ================ */
|
| 1171 |
+
|
| 1172 |
+
.help-container {
|
| 1173 |
+
width: 70%;
|
| 1174 |
+
max-width: 900px;
|
| 1175 |
+
margin: 20px auto;
|
| 1176 |
+
}
|
| 1177 |
+
|
| 1178 |
+
/* Fake ad block (Help page) */
|
| 1179 |
+
.fake-ad {
|
| 1180 |
+
position: relative;
|
| 1181 |
+
display: block;
|
| 1182 |
+
width: min(600px, 90%);
|
| 1183 |
+
margin: 10px auto 30px auto;
|
| 1184 |
+
border: 1px solid #e5e5e5;
|
| 1185 |
+
border-radius: 12px;
|
| 1186 |
+
overflow: hidden;
|
| 1187 |
+
background: #fff;
|
| 1188 |
+
text-decoration: none;
|
| 1189 |
+
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
| 1190 |
+
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
.fake-ad:hover {
|
| 1194 |
+
transform: translateY(-2px);
|
| 1195 |
+
box-shadow: 0 12px 30px rgba(0,0,0,0.16);
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
.fake-ad img {
|
| 1199 |
+
display: block;
|
| 1200 |
+
width: 100%;
|
| 1201 |
+
height: auto;
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
+
.fake-ad-badge {
|
| 1205 |
+
position: absolute;
|
| 1206 |
+
top: 12px;
|
| 1207 |
+
left: 12px;
|
| 1208 |
+
background: rgba(0,0,0,0.75);
|
| 1209 |
+
color: #fff;
|
| 1210 |
+
font-size: 12px;
|
| 1211 |
+
padding: 4px 8px;
|
| 1212 |
+
border-radius: 6px;
|
| 1213 |
+
letter-spacing: 0.5px;
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
.fake-ad-cta {
|
| 1217 |
+
position: absolute;
|
| 1218 |
+
right: 12px;
|
| 1219 |
+
bottom: 12px;
|
| 1220 |
+
background: #111;
|
| 1221 |
+
color: #fff;
|
| 1222 |
+
font-size: 14px;
|
| 1223 |
+
padding: 8px 12px;
|
| 1224 |
+
border-radius: 8px;
|
| 1225 |
+
}
|
| 1226 |
+
|
| 1227 |
+
.help-section {
|
| 1228 |
+
background: white;
|
| 1229 |
+
padding: 30px;
|
| 1230 |
+
border-radius: 12px;
|
| 1231 |
+
margin-bottom: 20px;
|
| 1232 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 1233 |
+
}
|
| 1234 |
+
|
| 1235 |
+
.help-section h2 {
|
| 1236 |
+
color: #007BFF;
|
| 1237 |
+
margin-top: 0;
|
| 1238 |
+
margin-bottom: 15px;
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
.help-section ol,
|
| 1242 |
+
.help-section ul {
|
| 1243 |
+
line-height: 1.8;
|
| 1244 |
+
color: #333;
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
.help-section p {
|
| 1248 |
+
line-height: 1.6;
|
| 1249 |
+
color: #333;
|
| 1250 |
+
}
|
| 1251 |
+
|
| 1252 |
+
details {
|
| 1253 |
+
margin: 15px 0;
|
| 1254 |
+
cursor: pointer;
|
| 1255 |
+
padding: 10px;
|
| 1256 |
+
background: #f8f9fa;
|
| 1257 |
+
border-radius: 5px;
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
summary {
|
| 1261 |
+
font-weight: bold;
|
| 1262 |
+
padding: 5px;
|
| 1263 |
+
color: #007BFF;
|
| 1264 |
+
}
|
| 1265 |
+
|
| 1266 |
+
details[open] summary {
|
| 1267 |
+
margin-bottom: 10px;
|
| 1268 |
+
border-bottom: 1px solid #ddd;
|
| 1269 |
+
padding-bottom: 10px;
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
details p {
|
| 1273 |
+
margin: 10px 0 0 0;
|
| 1274 |
+
}
|
| 1275 |
+
|
| 1276 |
+
/* ================ SESSION SUMMARY MODAL ================ */
|
| 1277 |
+
/* These modal styles can be reused for future overlays. */
|
| 1278 |
+
.modal-overlay {
|
| 1279 |
+
position: fixed;
|
| 1280 |
+
top: 0;
|
| 1281 |
+
left: 0;
|
| 1282 |
+
width: 100%;
|
| 1283 |
+
height: 100%;
|
| 1284 |
+
background: rgba(0, 0, 0, 0.7);
|
| 1285 |
+
display: flex;
|
| 1286 |
+
align-items: center;
|
| 1287 |
+
justify-content: center;
|
| 1288 |
+
z-index: 2000;
|
| 1289 |
+
}
|
| 1290 |
+
|
| 1291 |
+
.modal-content {
|
| 1292 |
+
background: white;
|
| 1293 |
+
padding: 40px;
|
| 1294 |
+
border-radius: 16px;
|
| 1295 |
+
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
| 1296 |
+
max-width: 500px;
|
| 1297 |
+
width: 90%;
|
| 1298 |
+
}
|
| 1299 |
+
|
| 1300 |
+
.modal-content h2 {
|
| 1301 |
+
margin-top: 0;
|
| 1302 |
+
color: #333;
|
| 1303 |
+
text-align: center;
|
| 1304 |
+
margin-bottom: 30px;
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
.summary-stats {
|
| 1308 |
+
margin-bottom: 30px;
|
| 1309 |
+
}
|
| 1310 |
+
|
| 1311 |
+
.summary-item {
|
| 1312 |
+
display: flex;
|
| 1313 |
+
justify-content: space-between;
|
| 1314 |
+
padding: 15px 0;
|
| 1315 |
+
border-bottom: 1px solid #eee;
|
| 1316 |
+
}
|
| 1317 |
+
|
| 1318 |
+
.summary-item:last-child {
|
| 1319 |
+
border-bottom: none;
|
| 1320 |
+
}
|
| 1321 |
+
|
| 1322 |
+
.summary-label {
|
| 1323 |
+
font-weight: 600;
|
| 1324 |
+
color: #666;
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
.summary-value {
|
| 1328 |
+
font-weight: bold;
|
| 1329 |
+
color: #007BFF;
|
| 1330 |
+
font-size: 18px;
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
.modal-content .btn-main {
|
| 1334 |
+
display: block;
|
| 1335 |
+
margin: 0 auto;
|
| 1336 |
+
padding: 12px 40px;
|
| 1337 |
+
}
|
| 1338 |
+
|
| 1339 |
+
/* ================ TIMELINE BLOCKS ================ */
|
| 1340 |
+
|
| 1341 |
+
.timeline-block {
|
| 1342 |
+
transition: opacity 0.2s;
|
| 1343 |
+
border-radius: 2px;
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
.timeline-block:hover {
|
| 1347 |
+
opacity: 0.7;
|
| 1348 |
+
}
|
| 1349 |
+
|
| 1350 |
+
/* ================ RESPONSIVE DESIGN ================ */
|
| 1351 |
+
|
| 1352 |
+
@media (max-width: 1200px) {
|
| 1353 |
+
.stats-grid {
|
| 1354 |
+
grid-template-columns: repeat(2, 1fr);
|
| 1355 |
+
}
|
| 1356 |
+
|
| 1357 |
+
.badges-grid {
|
| 1358 |
+
grid-template-columns: repeat(2, 1fr);
|
| 1359 |
+
}
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
@media (max-width: 768px) {
|
| 1363 |
+
.stats-grid,
|
| 1364 |
+
.badges-grid {
|
| 1365 |
+
grid-template-columns: 1fr;
|
| 1366 |
+
width: 90%;
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
.settings-container,
|
| 1370 |
+
.help-container,
|
| 1371 |
+
.chart-container,
|
| 1372 |
+
.sessions-list,
|
| 1373 |
+
.records-controls {
|
| 1374 |
+
width: 90%;
|
| 1375 |
+
}
|
| 1376 |
+
|
| 1377 |
+
#control-panel {
|
| 1378 |
+
width: 90%;
|
| 1379 |
+
flex-wrap: wrap;
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
#display-area {
|
| 1383 |
+
width: 90%;
|
| 1384 |
+
}
|
| 1385 |
+
|
| 1386 |
+
#timeline-area {
|
| 1387 |
+
width: 90%;
|
| 1388 |
+
}
|
| 1389 |
+
|
| 1390 |
+
#frame-control {
|
| 1391 |
+
width: 90%;
|
| 1392 |
+
flex-direction: column;
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
.focus-inline-error-standalone {
|
| 1396 |
+
width: 90%;
|
| 1397 |
+
}
|
| 1398 |
+
|
| 1399 |
+
.focus-flow-overlay {
|
| 1400 |
+
top: 70px;
|
| 1401 |
+
right: 10px;
|
| 1402 |
+
bottom: 10px;
|
| 1403 |
+
left: 10px;
|
| 1404 |
+
}
|
| 1405 |
+
|
| 1406 |
+
.focus-flow-card {
|
| 1407 |
+
padding: 22px 20px;
|
| 1408 |
+
}
|
| 1409 |
+
|
| 1410 |
+
.focus-flow-header {
|
| 1411 |
+
flex-direction: column;
|
| 1412 |
+
align-items: flex-start;
|
| 1413 |
+
}
|
| 1414 |
+
|
| 1415 |
+
.focus-flow-icon {
|
| 1416 |
+
width: 92px;
|
| 1417 |
+
height: 92px;
|
| 1418 |
+
}
|
| 1419 |
+
|
| 1420 |
+
.focus-flow-grid {
|
| 1421 |
+
grid-template-columns: 1fr;
|
| 1422 |
+
}
|
| 1423 |
+
|
| 1424 |
+
.focus-flow-steps {
|
| 1425 |
+
grid-template-columns: 1fr;
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
.focus-flow-footer {
|
| 1429 |
+
flex-direction: column;
|
| 1430 |
+
align-items: stretch;
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
.focus-flow-button,
|
| 1434 |
+
.focus-flow-secondary {
|
| 1435 |
+
width: 100%;
|
| 1436 |
+
}
|
| 1437 |
+
|
| 1438 |
+
.records-detail-modal {
|
| 1439 |
+
width: 94vw;
|
| 1440 |
+
padding: 22px 18px;
|
| 1441 |
+
}
|
| 1442 |
+
|
| 1443 |
+
.records-detail-header,
|
| 1444 |
+
.records-detail-section-head {
|
| 1445 |
+
flex-direction: column;
|
| 1446 |
+
align-items: flex-start;
|
| 1447 |
+
}
|
| 1448 |
+
|
| 1449 |
+
.records-detail-summary,
|
| 1450 |
+
.records-detail-grid,
|
| 1451 |
+
.records-detail-list {
|
| 1452 |
+
grid-template-columns: 1fr;
|
| 1453 |
+
}
|
| 1454 |
+
|
| 1455 |
+
.records-detail-event {
|
| 1456 |
+
grid-template-columns: 1fr;
|
| 1457 |
+
align-items: flex-start;
|
| 1458 |
+
}
|
| 1459 |
+
}
|
| 1460 |
+
/* =========================================
|
| 1461 |
+
SESSION RESULT OVERLAY
|
| 1462 |
+
========================================= */
|
| 1463 |
+
|
| 1464 |
+
.session-result-overlay {
|
| 1465 |
+
position: absolute;
|
| 1466 |
+
top: 0;
|
| 1467 |
+
left: 0;
|
| 1468 |
+
width: 100%;
|
| 1469 |
+
height: 100%;
|
| 1470 |
+
background-color: rgba(0, 0, 0, 0.85); /* Dark semi-transparent backdrop. */
|
| 1471 |
+
display: flex;
|
| 1472 |
+
flex-direction: column;
|
| 1473 |
+
justify-content: center;
|
| 1474 |
+
align-items: center;
|
| 1475 |
+
color: white;
|
| 1476 |
+
z-index: 10;
|
| 1477 |
+
animation: fadeIn 0.5s ease;
|
| 1478 |
+
backdrop-filter: blur(5px); /* Optional background blur. */
|
| 1479 |
+
}
|
| 1480 |
+
|
| 1481 |
+
.session-result-overlay h3 {
|
| 1482 |
+
font-size: 32px;
|
| 1483 |
+
margin-bottom: 30px;
|
| 1484 |
+
color: #4cd137; /* Green title accent. */
|
| 1485 |
+
text-transform: uppercase;
|
| 1486 |
+
letter-spacing: 2px;
|
| 1487 |
+
}
|
| 1488 |
+
|
| 1489 |
+
.session-result-overlay .result-item {
|
| 1490 |
+
display: flex;
|
| 1491 |
+
justify-content: space-between;
|
| 1492 |
+
width: 200px; /* Keep the stat row compact. */
|
| 1493 |
+
margin-bottom: 15px;
|
| 1494 |
+
font-size: 20px;
|
| 1495 |
+
border-bottom: 1px solid rgba(255,255,255,0.2);
|
| 1496 |
+
padding-bottom: 5px;
|
| 1497 |
+
}
|
| 1498 |
+
|
| 1499 |
+
.session-result-overlay .label {
|
| 1500 |
+
color: #ccc;
|
| 1501 |
+
font-weight: normal;
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
.session-result-overlay .value {
|
| 1505 |
+
color: #fff;
|
| 1506 |
+
font-weight: bold;
|
| 1507 |
+
font-family: 'Courier New', monospace; /* Give the values a data-like look. */
|
| 1508 |
+
}
|
| 1509 |
+
|
| 1510 |
+
@keyframes fadeIn {
|
| 1511 |
+
from { opacity: 0; transform: scale(0.95); }
|
| 1512 |
+
to { opacity: 1; transform: scale(1); }
|
| 1513 |
+
}
|
| 1514 |
+
|
| 1515 |
+
/* ================= Welcome modal styles ================= */
|
| 1516 |
+
.welcome-modal-overlay {
|
| 1517 |
+
position: fixed;
|
| 1518 |
+
top: 0; left: 0; right: 0; bottom: 0;
|
| 1519 |
+
background-color: rgba(0, 0, 0, 0.7);
|
| 1520 |
+
display: flex;
|
| 1521 |
+
justify-content: center;
|
| 1522 |
+
align-items: center;
|
| 1523 |
+
z-index: 9999;
|
| 1524 |
+
}
|
| 1525 |
+
|
| 1526 |
+
.welcome-modal {
|
| 1527 |
+
background-color: #1e1e24;
|
| 1528 |
+
padding: 40px;
|
| 1529 |
+
border-radius: 15px;
|
| 1530 |
+
text-align: center;
|
| 1531 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
| 1532 |
+
border: 1px solid #333;
|
| 1533 |
+
}
|
| 1534 |
+
|
| 1535 |
+
.welcome-modal h2 { margin-top: 0; color: #fff; }
|
| 1536 |
+
.welcome-modal p { margin-bottom: 30px; color: #ccc; }
|
| 1537 |
+
.welcome-buttons { display: flex; gap: 20px; justify-content: center; }
|
| 1538 |
+
|
| 1539 |
+
/* ================= Top-left avatar styles ================= */
|
| 1540 |
+
.avatar-container {
|
| 1541 |
+
position: absolute;
|
| 1542 |
+
left: 20px;
|
| 1543 |
+
cursor: pointer;
|
| 1544 |
+
z-index: 1;
|
| 1545 |
+
}
|
| 1546 |
+
|
| 1547 |
+
.avatar-circle {
|
| 1548 |
+
width: 40px;
|
| 1549 |
+
height: 40px;
|
| 1550 |
+
border-radius: 50%;
|
| 1551 |
+
display: flex;
|
| 1552 |
+
justify-content: center;
|
| 1553 |
+
align-items: center;
|
| 1554 |
+
font-weight: bold;
|
| 1555 |
+
font-size: 1.2rem;
|
| 1556 |
+
color: white;
|
| 1557 |
+
transition: all 0.3s ease;
|
| 1558 |
+
border: 2px solid transparent;
|
| 1559 |
+
}
|
| 1560 |
+
|
| 1561 |
+
avatar-squ.user { background-color: #ffaa00; border-color: #fff; box-shadow: 0 0 10px rgba(255, 170, 0, 0.5); }
|
| 1562 |
+
|
| 1563 |
+
/* ================= Home page 2x2 responsive button grid ================= */
|
| 1564 |
+
.home-button-grid {
|
| 1565 |
+
display: grid;
|
| 1566 |
+
grid-template-columns: 1fr 1fr; /* Force a clean two-column split. */
|
| 1567 |
+
gap: 20px; /* Spacing between buttons. */
|
| 1568 |
+
width: 100%;
|
| 1569 |
+
max-width: 500px; /* Cap the width so the grid does not feel oversized. */
|
| 1570 |
+
margin: 40px auto 0 auto; /* Add top spacing and keep the grid centered. */
|
| 1571 |
+
}
|
| 1572 |
+
|
| 1573 |
+
.home-button-grid .btn-main {
|
| 1574 |
+
width: 100%;
|
| 1575 |
+
height: 60px; /* Keep all tiles at the same height. */
|
| 1576 |
+
margin: 0; /* Remove default outer spacing. */
|
| 1577 |
+
padding: 10px;
|
| 1578 |
+
font-size: 1rem;
|
| 1579 |
+
display: flex;
|
| 1580 |
+
justify-content: center;
|
| 1581 |
+
align-items: center;
|
| 1582 |
+
text-align: center;
|
| 1583 |
+
box-sizing: border-box; /* Prevent padding and borders from breaking the grid. */
|
| 1584 |
+
}
|
| 1585 |
+
|
| 1586 |
+
/* Mobile-only scaling for screens below 600px. */
|
| 1587 |
+
@media (max-width: 600px) {
|
| 1588 |
+
#top-menu {
|
| 1589 |
+
justify-content: flex-start;
|
| 1590 |
+
padding: 0 12px 0 68px;
|
| 1591 |
+
}
|
| 1592 |
+
|
| 1593 |
+
.menu-btn {
|
| 1594 |
+
padding: 10px 14px;
|
| 1595 |
+
font-size: 0.92rem;
|
| 1596 |
+
}
|
| 1597 |
+
|
| 1598 |
+
.separator {
|
| 1599 |
+
margin: 0 2px;
|
| 1600 |
+
}
|
| 1601 |
+
|
| 1602 |
+
.home-button-grid {
|
| 1603 |
+
gap: 15px;
|
| 1604 |
+
max-width: 90%;
|
| 1605 |
+
}
|
| 1606 |
+
|
| 1607 |
+
.home-button-grid .btn-main {
|
| 1608 |
+
height: 50px;
|
| 1609 |
+
font-size: 0.85rem;
|
| 1610 |
+
}
|
| 1611 |
+
}
|
src/App.jsx
CHANGED
|
@@ -1,92 +1,121 @@
|
|
| 1 |
-
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
-
import './
|
| 3 |
-
import
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
import
|
| 7 |
-
import
|
| 8 |
-
import
|
| 9 |
-
import
|
| 10 |
-
import Help from './components/Help';
|
| 11 |
-
|
| 12 |
-
function App() {
|
| 13 |
-
const [activeTab, setActiveTab] = useState('home');
|
| 14 |
-
const videoManagerRef = useRef(null);
|
| 15 |
-
const [isSessionActive, setIsSessionActive] = useState(false);
|
| 16 |
-
const [sessionResult, setSessionResult] = useState(null);
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import logo from './assets/logo.jpg';
|
| 3 |
+
import './App.css';
|
| 4 |
+
import { VideoManagerLocal } from './utils/VideoManagerLocal';
|
| 5 |
+
|
| 6 |
+
import Home from './components/Home';
|
| 7 |
+
import FocusPageLocal from './components/FocusPageLocal';
|
| 8 |
+
import Achievement from './components/Achievement';
|
| 9 |
+
import Records from './components/Records';
|
| 10 |
+
import Help from './components/Help';
|
| 11 |
+
|
| 12 |
+
function App() {
|
| 13 |
+
const [activeTab, setActiveTab] = useState('home');
|
| 14 |
+
const videoManagerRef = useRef(null);
|
| 15 |
+
const [isSessionActive, setIsSessionActive] = useState(false);
|
| 16 |
+
const [sessionResult, setSessionResult] = useState(null);
|
| 17 |
+
|
| 18 |
+
const [isTutorialActive, setIsTutorialActive] = useState(false);
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
fetch('/api/history', { method: 'DELETE' })
|
| 22 |
+
.then(() => {
|
| 23 |
+
const backup = localStorage.getItem('focus_magic_backup');
|
| 24 |
+
if (backup) {
|
| 25 |
+
try {
|
| 26 |
+
const sessions = JSON.parse(backup);
|
| 27 |
+
fetch('/api/import', {
|
| 28 |
+
method: 'POST',
|
| 29 |
+
headers: { 'Content-Type': 'application/json' },
|
| 30 |
+
body: JSON.stringify(sessions)
|
| 31 |
+
});
|
| 32 |
+
console.log("History auto-recovered from browser cache.");
|
| 33 |
+
} catch (err) {
|
| 34 |
+
console.error("Failed to read magic backup", err);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
})
|
| 38 |
+
.catch(err => console.error(err));
|
| 39 |
+
|
| 40 |
+
const callbacks = {
|
| 41 |
+
onSessionStart: () => {
|
| 42 |
+
setIsSessionActive(true);
|
| 43 |
+
setSessionResult(null);
|
| 44 |
+
},
|
| 45 |
+
onSessionEnd: (summary) => {
|
| 46 |
+
setIsSessionActive(false);
|
| 47 |
+
if (summary) setSessionResult(summary);
|
| 48 |
+
|
| 49 |
+
fetch('/api/sessions?filter=all')
|
| 50 |
+
.then(res => res.json())
|
| 51 |
+
.then(data => {
|
| 52 |
+
if (data && Array.isArray(data)) {
|
| 53 |
+
localStorage.setItem('focus_magic_backup', JSON.stringify(data));
|
| 54 |
+
console.log("Session auto-saved to browser cache.");
|
| 55 |
+
}
|
| 56 |
+
})
|
| 57 |
+
.catch(err => console.error("Auto-save failed", err));
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
videoManagerRef.current = new VideoManagerLocal(callbacks);
|
| 61 |
+
|
| 62 |
+
return () => {
|
| 63 |
+
if (videoManagerRef.current) videoManagerRef.current.stopStreaming();
|
| 64 |
+
};
|
| 65 |
+
}, []);
|
| 66 |
+
|
| 67 |
+
const handleAvatarClick = () => {
|
| 68 |
+
setActiveTab('home');
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<div className="app-container">
|
| 73 |
+
<nav id="top-menu">
|
| 74 |
+
<div className="avatar-container" onClick={handleAvatarClick} title="Back to Home">
|
| 75 |
+
<div className="avatar-circle user" style={{ backgroundColor: 'transparent', border: 'none' }}>
|
| 76 |
+
<img
|
| 77 |
+
src={logo}
|
| 78 |
+
alt="FocusGuard Logo"
|
| 79 |
+
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
| 80 |
+
/>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<button className={`menu-btn ${activeTab === 'focus' ? 'active' : ''}`} onClick={() => setActiveTab('focus')}>
|
| 85 |
+
Start Focus {isSessionActive && <span style={{ marginLeft: '8px', color: '#00FF00' }}>●</span>}
|
| 86 |
+
</button>
|
| 87 |
+
<div className="separator"></div>
|
| 88 |
+
|
| 89 |
+
<button className={`menu-btn ${activeTab === 'achievement' ? 'active' : ''}`} onClick={() => setActiveTab('achievement')}>
|
| 90 |
+
My Achievement
|
| 91 |
+
</button>
|
| 92 |
+
<div className="separator"></div>
|
| 93 |
+
|
| 94 |
+
<button className={`menu-btn ${activeTab === 'records' ? 'active' : ''}`} onClick={() => setActiveTab('records')}>
|
| 95 |
+
My Records
|
| 96 |
+
</button>
|
| 97 |
+
<div className="separator"></div>
|
| 98 |
+
|
| 99 |
+
<button className={`menu-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => setActiveTab('help')}>
|
| 100 |
+
Help
|
| 101 |
+
</button>
|
| 102 |
+
</nav>
|
| 103 |
+
|
| 104 |
+
{activeTab === 'home' && <Home setActiveTab={setActiveTab} setIsTutorialActive={setIsTutorialActive} />}
|
| 105 |
+
|
| 106 |
+
<FocusPageLocal
|
| 107 |
+
videoManager={videoManagerRef.current}
|
| 108 |
+
sessionResult={sessionResult}
|
| 109 |
+
setSessionResult={setSessionResult}
|
| 110 |
+
isActive={activeTab === 'focus'}
|
| 111 |
+
isTutorialActive={isTutorialActive}
|
| 112 |
+
setIsTutorialActive={setIsTutorialActive}
|
| 113 |
+
/>
|
| 114 |
+
{activeTab === 'achievement' && <Achievement />}
|
| 115 |
+
{activeTab === 'records' && <Records />}
|
| 116 |
+
{activeTab === 'help' && <Help />}
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export default App;
|
src/assets/logo.jpg
ADDED
|
src/components/Achievement.jsx
CHANGED
|
@@ -1,274 +1,274 @@
|
|
| 1 |
-
import React, { useState, useEffect } from 'react';
|
| 2 |
-
|
| 3 |
-
function Achievement() {
|
| 4 |
-
const [stats, setStats] = useState({
|
| 5 |
-
total_sessions: 0,
|
| 6 |
-
total_focus_time: 0,
|
| 7 |
-
avg_focus_score: 0,
|
| 8 |
-
streak_days: 0
|
| 9 |
-
});
|
| 10 |
-
const [systemStats, setSystemStats] = useState(null);
|
| 11 |
-
const [badges, setBadges] = useState([]);
|
| 12 |
-
const [loading, setLoading] = useState(true);
|
| 13 |
-
|
| 14 |
-
// Format total focus time for display.
|
| 15 |
-
const formatTime = (seconds) => {
|
| 16 |
-
const hours = Math.floor(seconds / 3600);
|
| 17 |
-
const minutes = Math.floor((seconds % 3600) / 60);
|
| 18 |
-
if (hours > 0) return `${hours}h ${minutes}m`;
|
| 19 |
-
return `${minutes}m`;
|
| 20 |
-
};
|
| 21 |
-
|
| 22 |
-
// Load summary statistics.
|
| 23 |
-
useEffect(() => {
|
| 24 |
-
fetch('/api/stats/summary')
|
| 25 |
-
.then(res => res.json())
|
| 26 |
-
.then(data => {
|
| 27 |
-
setStats(data);
|
| 28 |
-
calculateBadges(data);
|
| 29 |
-
setLoading(false);
|
| 30 |
-
})
|
| 31 |
-
.catch(err => {
|
| 32 |
-
console.error('Failed to load stats:', err);
|
| 33 |
-
setLoading(false);
|
| 34 |
-
});
|
| 35 |
-
}, []);
|
| 36 |
-
|
| 37 |
-
// Derive unlocked badges from summary statistics.
|
| 38 |
-
useEffect(() => {
|
| 39 |
-
const fetchSystem = () => {
|
| 40 |
-
fetch('/api/stats/system')
|
| 41 |
-
.then(res => res.json())
|
| 42 |
-
.then(data => setSystemStats(data))
|
| 43 |
-
.catch(() => setSystemStats(null));
|
| 44 |
-
};
|
| 45 |
-
fetchSystem();
|
| 46 |
-
const interval = setInterval(fetchSystem, 3000);
|
| 47 |
-
return () => clearInterval(interval);
|
| 48 |
-
}, []);
|
| 49 |
-
|
| 50 |
-
const calculateBadges = (data) => {
|
| 51 |
-
const earnedBadges = [];
|
| 52 |
-
|
| 53 |
-
// First-session badge
|
| 54 |
-
if (data.total_sessions >= 1) {
|
| 55 |
-
earnedBadges.push({
|
| 56 |
-
id: 'first-session',
|
| 57 |
-
name: 'First Step',
|
| 58 |
-
description: 'Complete your first focus session',
|
| 59 |
-
icon: '🎯',
|
| 60 |
-
unlocked: true
|
| 61 |
-
});
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
// 10-session badge
|
| 65 |
-
if (data.total_sessions >= 10) {
|
| 66 |
-
earnedBadges.push({
|
| 67 |
-
id: 'ten-sessions',
|
| 68 |
-
name: 'Getting Started',
|
| 69 |
-
description: 'Complete 10 focus sessions',
|
| 70 |
-
icon: '⭐',
|
| 71 |
-
unlocked: true
|
| 72 |
-
});
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
// 50-session badge
|
| 76 |
-
if (data.total_sessions >= 50) {
|
| 77 |
-
earnedBadges.push({
|
| 78 |
-
id: 'fifty-sessions',
|
| 79 |
-
name: 'Dedicated',
|
| 80 |
-
description: 'Complete 50 focus sessions',
|
| 81 |
-
icon: '🏆',
|
| 82 |
-
unlocked: true
|
| 83 |
-
});
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
// Focus Master badge (average focus score > 80%)
|
| 87 |
-
if (data.avg_focus_score >= 0.8 && data.total_sessions >= 5) {
|
| 88 |
-
earnedBadges.push({
|
| 89 |
-
id: 'focus-master',
|
| 90 |
-
name: 'Focus Master',
|
| 91 |
-
description: 'Maintain 80%+ average focus score',
|
| 92 |
-
icon: '🧠',
|
| 93 |
-
unlocked: true
|
| 94 |
-
});
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
// Streak badges
|
| 98 |
-
if (data.streak_days >= 7) {
|
| 99 |
-
earnedBadges.push({
|
| 100 |
-
id: 'week-streak',
|
| 101 |
-
name: 'Week Warrior',
|
| 102 |
-
description: '7 day streak',
|
| 103 |
-
icon: '🔥',
|
| 104 |
-
unlocked: true
|
| 105 |
-
});
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
if (data.streak_days >= 30) {
|
| 109 |
-
earnedBadges.push({
|
| 110 |
-
id: 'month-streak',
|
| 111 |
-
name: 'Month Master',
|
| 112 |
-
description: '30 day streak',
|
| 113 |
-
icon: '💎',
|
| 114 |
-
unlocked: true
|
| 115 |
-
});
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
// Total focus time badge (10+ hours)
|
| 119 |
-
if (data.total_focus_time >= 36000) {
|
| 120 |
-
earnedBadges.push({
|
| 121 |
-
id: 'ten-hours',
|
| 122 |
-
name: 'Endurance',
|
| 123 |
-
description: '10+ hours total focus time',
|
| 124 |
-
icon: '⏱️',
|
| 125 |
-
unlocked: true
|
| 126 |
-
});
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
// Full badge catalog, including locked examples
|
| 130 |
-
const allBadges = [
|
| 131 |
-
{
|
| 132 |
-
id: 'first-session',
|
| 133 |
-
name: 'First Step',
|
| 134 |
-
description: 'Complete your first focus session',
|
| 135 |
-
icon: '🎯',
|
| 136 |
-
unlocked: data.total_sessions >= 1
|
| 137 |
-
},
|
| 138 |
-
{
|
| 139 |
-
id: 'ten-sessions',
|
| 140 |
-
name: 'Getting Started',
|
| 141 |
-
description: 'Complete 10 focus sessions',
|
| 142 |
-
icon: '⭐',
|
| 143 |
-
unlocked: data.total_sessions >= 10
|
| 144 |
-
},
|
| 145 |
-
{
|
| 146 |
-
id: 'fifty-sessions',
|
| 147 |
-
name: 'Dedicated',
|
| 148 |
-
description: 'Complete 50 focus sessions',
|
| 149 |
-
icon: '🏆',
|
| 150 |
-
unlocked: data.total_sessions >= 50
|
| 151 |
-
},
|
| 152 |
-
{
|
| 153 |
-
id: 'focus-master',
|
| 154 |
-
name: 'Focus Master',
|
| 155 |
-
description: 'Maintain 80%+ average focus score',
|
| 156 |
-
icon: '🧠',
|
| 157 |
-
unlocked: data.avg_focus_score >= 0.8 && data.total_sessions >= 5
|
| 158 |
-
},
|
| 159 |
-
{
|
| 160 |
-
id: 'week-streak',
|
| 161 |
-
name: 'Week Warrior',
|
| 162 |
-
description: '7 day streak',
|
| 163 |
-
icon: '🔥',
|
| 164 |
-
unlocked: data.streak_days >= 7
|
| 165 |
-
},
|
| 166 |
-
{
|
| 167 |
-
id: 'month-streak',
|
| 168 |
-
name: 'Month Master',
|
| 169 |
-
description: '30 day streak',
|
| 170 |
-
icon: '💎',
|
| 171 |
-
unlocked: data.streak_days >= 30
|
| 172 |
-
},
|
| 173 |
-
{
|
| 174 |
-
id: 'ten-hours',
|
| 175 |
-
name: 'Endurance',
|
| 176 |
-
description: '10+ hours total focus time',
|
| 177 |
-
icon: '⏱️',
|
| 178 |
-
unlocked: data.total_focus_time >= 36000
|
| 179 |
-
},
|
| 180 |
-
{
|
| 181 |
-
id: 'hundred-sessions',
|
| 182 |
-
name: 'Centurion',
|
| 183 |
-
description: 'Complete 100 focus sessions',
|
| 184 |
-
icon: '👑',
|
| 185 |
-
unlocked: data.total_sessions >= 100
|
| 186 |
-
}
|
| 187 |
-
];
|
| 188 |
-
|
| 189 |
-
setBadges(allBadges);
|
| 190 |
-
};
|
| 191 |
-
|
| 192 |
-
return (
|
| 193 |
-
<main id="page-c" className="page">
|
| 194 |
-
<h1 className="page-title">My Achievement</h1>
|
| 195 |
-
|
| 196 |
-
{loading ? (
|
| 197 |
-
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
|
| 198 |
-
Loading stats...
|
| 199 |
-
</div>
|
| 200 |
-
) : (
|
| 201 |
-
<>
|
| 202 |
-
{systemStats && systemStats.cpu_percent != null && (
|
| 203 |
-
<div style={{
|
| 204 |
-
textAlign: 'center',
|
| 205 |
-
marginBottom: '12px',
|
| 206 |
-
padding: '8px 12px',
|
| 207 |
-
background: 'rgba(0,0,0,0.2)',
|
| 208 |
-
borderRadius: '8px',
|
| 209 |
-
fontSize: '13px',
|
| 210 |
-
color: '#aaa'
|
| 211 |
-
}}>
|
| 212 |
-
Server: CPU <strong style={{ color: '#8f8' }}>{systemStats.cpu_percent}%</strong>
|
| 213 |
-
{' · '}
|
| 214 |
-
RAM <strong style={{ color: '#8af' }}>{systemStats.memory_percent}%</strong>
|
| 215 |
-
{systemStats.memory_used_mb != null && ` (${systemStats.memory_used_mb}/${systemStats.memory_total_mb} MB)`}
|
| 216 |
-
</div>
|
| 217 |
-
)}
|
| 218 |
-
<div className="stats-grid">
|
| 219 |
-
<div className="stat-card">
|
| 220 |
-
<div className="stat-number" id="total-sessions">{stats.total_sessions}</div>
|
| 221 |
-
<div className="stat-label">Total Sessions</div>
|
| 222 |
-
</div>
|
| 223 |
-
<div className="stat-card">
|
| 224 |
-
<div className="stat-number" id="total-hours">{formatTime(stats.total_focus_time)}</div>
|
| 225 |
-
<div className="stat-label">Total Focus Time</div>
|
| 226 |
-
</div>
|
| 227 |
-
<div className="stat-card">
|
| 228 |
-
<div className="stat-number" id="avg-focus">{(stats.avg_focus_score * 100).toFixed(1)}%</div>
|
| 229 |
-
<div className="stat-label">Average Focus</div>
|
| 230 |
-
</div>
|
| 231 |
-
<div className="stat-card">
|
| 232 |
-
<div className="stat-number" id="current-streak">{stats.streak_days}</div>
|
| 233 |
-
<div className="stat-label">Day Streak</div>
|
| 234 |
-
</div>
|
| 235 |
-
</div>
|
| 236 |
-
|
| 237 |
-
<div className="achievements-section">
|
| 238 |
-
<h2>Badges</h2>
|
| 239 |
-
<div id="badges-container" className="badges-grid">
|
| 240 |
-
{badges.map(badge => (
|
| 241 |
-
<div
|
| 242 |
-
key={badge.id}
|
| 243 |
-
className={`badge ${badge.unlocked ? 'unlocked' : 'locked'}`}
|
| 244 |
-
style={{
|
| 245 |
-
padding: '20px',
|
| 246 |
-
textAlign: 'center',
|
| 247 |
-
border: '2px solid',
|
| 248 |
-
borderColor: badge.unlocked ? '#00FF00' : '#444',
|
| 249 |
-
borderRadius: '10px',
|
| 250 |
-
backgroundColor: badge.unlocked ? 'rgba(0, 255, 0, 0.1)' : 'rgba(68, 68, 68, 0.1)',
|
| 251 |
-
opacity: badge.unlocked ? 1 : 0.5,
|
| 252 |
-
transition: 'all 0.3s'
|
| 253 |
-
}}
|
| 254 |
-
>
|
| 255 |
-
<div style={{ fontSize: '48px', marginBottom: '10px' }}>
|
| 256 |
-
{badge.unlocked ? badge.icon : '🔒'}
|
| 257 |
-
</div>
|
| 258 |
-
<div style={{ fontWeight: 'bold', marginBottom: '5px' }}>
|
| 259 |
-
{badge.name}
|
| 260 |
-
</div>
|
| 261 |
-
<div style={{ fontSize: '12px', color: '#888' }}>
|
| 262 |
-
{badge.description}
|
| 263 |
-
</div>
|
| 264 |
-
</div>
|
| 265 |
-
))}
|
| 266 |
-
</div>
|
| 267 |
-
</div>
|
| 268 |
-
</>
|
| 269 |
-
)}
|
| 270 |
-
</main>
|
| 271 |
-
);
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
-
export default Achievement;
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
function Achievement() {
|
| 4 |
+
const [stats, setStats] = useState({
|
| 5 |
+
total_sessions: 0,
|
| 6 |
+
total_focus_time: 0,
|
| 7 |
+
avg_focus_score: 0,
|
| 8 |
+
streak_days: 0
|
| 9 |
+
});
|
| 10 |
+
const [systemStats, setSystemStats] = useState(null);
|
| 11 |
+
const [badges, setBadges] = useState([]);
|
| 12 |
+
const [loading, setLoading] = useState(true);
|
| 13 |
+
|
| 14 |
+
// Format total focus time for display.
|
| 15 |
+
const formatTime = (seconds) => {
|
| 16 |
+
const hours = Math.floor(seconds / 3600);
|
| 17 |
+
const minutes = Math.floor((seconds % 3600) / 60);
|
| 18 |
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
| 19 |
+
return `${minutes}m`;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
// Load summary statistics.
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
fetch('/api/stats/summary')
|
| 25 |
+
.then(res => res.json())
|
| 26 |
+
.then(data => {
|
| 27 |
+
setStats(data);
|
| 28 |
+
calculateBadges(data);
|
| 29 |
+
setLoading(false);
|
| 30 |
+
})
|
| 31 |
+
.catch(err => {
|
| 32 |
+
console.error('Failed to load stats:', err);
|
| 33 |
+
setLoading(false);
|
| 34 |
+
});
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
// Derive unlocked badges from summary statistics.
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
const fetchSystem = () => {
|
| 40 |
+
fetch('/api/stats/system')
|
| 41 |
+
.then(res => res.json())
|
| 42 |
+
.then(data => setSystemStats(data))
|
| 43 |
+
.catch(() => setSystemStats(null));
|
| 44 |
+
};
|
| 45 |
+
fetchSystem();
|
| 46 |
+
const interval = setInterval(fetchSystem, 3000);
|
| 47 |
+
return () => clearInterval(interval);
|
| 48 |
+
}, []);
|
| 49 |
+
|
| 50 |
+
const calculateBadges = (data) => {
|
| 51 |
+
const earnedBadges = [];
|
| 52 |
+
|
| 53 |
+
// First-session badge
|
| 54 |
+
if (data.total_sessions >= 1) {
|
| 55 |
+
earnedBadges.push({
|
| 56 |
+
id: 'first-session',
|
| 57 |
+
name: 'First Step',
|
| 58 |
+
description: 'Complete your first focus session',
|
| 59 |
+
icon: '🎯',
|
| 60 |
+
unlocked: true
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// 10-session badge
|
| 65 |
+
if (data.total_sessions >= 10) {
|
| 66 |
+
earnedBadges.push({
|
| 67 |
+
id: 'ten-sessions',
|
| 68 |
+
name: 'Getting Started',
|
| 69 |
+
description: 'Complete 10 focus sessions',
|
| 70 |
+
icon: '⭐',
|
| 71 |
+
unlocked: true
|
| 72 |
+
});
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// 50-session badge
|
| 76 |
+
if (data.total_sessions >= 50) {
|
| 77 |
+
earnedBadges.push({
|
| 78 |
+
id: 'fifty-sessions',
|
| 79 |
+
name: 'Dedicated',
|
| 80 |
+
description: 'Complete 50 focus sessions',
|
| 81 |
+
icon: '🏆',
|
| 82 |
+
unlocked: true
|
| 83 |
+
});
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Focus Master badge (average focus score > 80%)
|
| 87 |
+
if (data.avg_focus_score >= 0.8 && data.total_sessions >= 5) {
|
| 88 |
+
earnedBadges.push({
|
| 89 |
+
id: 'focus-master',
|
| 90 |
+
name: 'Focus Master',
|
| 91 |
+
description: 'Maintain 80%+ average focus score',
|
| 92 |
+
icon: '🧠',
|
| 93 |
+
unlocked: true
|
| 94 |
+
});
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Streak badges
|
| 98 |
+
if (data.streak_days >= 7) {
|
| 99 |
+
earnedBadges.push({
|
| 100 |
+
id: 'week-streak',
|
| 101 |
+
name: 'Week Warrior',
|
| 102 |
+
description: '7 day streak',
|
| 103 |
+
icon: '🔥',
|
| 104 |
+
unlocked: true
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
if (data.streak_days >= 30) {
|
| 109 |
+
earnedBadges.push({
|
| 110 |
+
id: 'month-streak',
|
| 111 |
+
name: 'Month Master',
|
| 112 |
+
description: '30 day streak',
|
| 113 |
+
icon: '💎',
|
| 114 |
+
unlocked: true
|
| 115 |
+
});
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Total focus time badge (10+ hours)
|
| 119 |
+
if (data.total_focus_time >= 36000) {
|
| 120 |
+
earnedBadges.push({
|
| 121 |
+
id: 'ten-hours',
|
| 122 |
+
name: 'Endurance',
|
| 123 |
+
description: '10+ hours total focus time',
|
| 124 |
+
icon: '⏱️',
|
| 125 |
+
unlocked: true
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// Full badge catalog, including locked examples
|
| 130 |
+
const allBadges = [
|
| 131 |
+
{
|
| 132 |
+
id: 'first-session',
|
| 133 |
+
name: 'First Step',
|
| 134 |
+
description: 'Complete your first focus session',
|
| 135 |
+
icon: '🎯',
|
| 136 |
+
unlocked: data.total_sessions >= 1
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
id: 'ten-sessions',
|
| 140 |
+
name: 'Getting Started',
|
| 141 |
+
description: 'Complete 10 focus sessions',
|
| 142 |
+
icon: '⭐',
|
| 143 |
+
unlocked: data.total_sessions >= 10
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
id: 'fifty-sessions',
|
| 147 |
+
name: 'Dedicated',
|
| 148 |
+
description: 'Complete 50 focus sessions',
|
| 149 |
+
icon: '🏆',
|
| 150 |
+
unlocked: data.total_sessions >= 50
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
id: 'focus-master',
|
| 154 |
+
name: 'Focus Master',
|
| 155 |
+
description: 'Maintain 80%+ average focus score',
|
| 156 |
+
icon: '🧠',
|
| 157 |
+
unlocked: data.avg_focus_score >= 0.8 && data.total_sessions >= 5
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
id: 'week-streak',
|
| 161 |
+
name: 'Week Warrior',
|
| 162 |
+
description: '7 day streak',
|
| 163 |
+
icon: '🔥',
|
| 164 |
+
unlocked: data.streak_days >= 7
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
id: 'month-streak',
|
| 168 |
+
name: 'Month Master',
|
| 169 |
+
description: '30 day streak',
|
| 170 |
+
icon: '💎',
|
| 171 |
+
unlocked: data.streak_days >= 30
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
id: 'ten-hours',
|
| 175 |
+
name: 'Endurance',
|
| 176 |
+
description: '10+ hours total focus time',
|
| 177 |
+
icon: '⏱️',
|
| 178 |
+
unlocked: data.total_focus_time >= 36000
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
id: 'hundred-sessions',
|
| 182 |
+
name: 'Centurion',
|
| 183 |
+
description: 'Complete 100 focus sessions',
|
| 184 |
+
icon: '👑',
|
| 185 |
+
unlocked: data.total_sessions >= 100
|
| 186 |
+
}
|
| 187 |
+
];
|
| 188 |
+
|
| 189 |
+
setBadges(allBadges);
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
return (
|
| 193 |
+
<main id="page-c" className="page">
|
| 194 |
+
<h1 className="page-title">My Achievement</h1>
|
| 195 |
+
|
| 196 |
+
{loading ? (
|
| 197 |
+
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
|
| 198 |
+
Loading stats...
|
| 199 |
+
</div>
|
| 200 |
+
) : (
|
| 201 |
+
<>
|
| 202 |
+
{systemStats && systemStats.cpu_percent != null && (
|
| 203 |
+
<div style={{
|
| 204 |
+
textAlign: 'center',
|
| 205 |
+
marginBottom: '12px',
|
| 206 |
+
padding: '8px 12px',
|
| 207 |
+
background: 'rgba(0,0,0,0.2)',
|
| 208 |
+
borderRadius: '8px',
|
| 209 |
+
fontSize: '13px',
|
| 210 |
+
color: '#aaa'
|
| 211 |
+
}}>
|
| 212 |
+
Server: CPU <strong style={{ color: '#8f8' }}>{systemStats.cpu_percent}%</strong>
|
| 213 |
+
{' · '}
|
| 214 |
+
RAM <strong style={{ color: '#8af' }}>{systemStats.memory_percent}%</strong>
|
| 215 |
+
{systemStats.memory_used_mb != null && ` (${systemStats.memory_used_mb}/${systemStats.memory_total_mb} MB)`}
|
| 216 |
+
</div>
|
| 217 |
+
)}
|
| 218 |
+
<div className="stats-grid">
|
| 219 |
+
<div className="stat-card">
|
| 220 |
+
<div className="stat-number" id="total-sessions">{stats.total_sessions}</div>
|
| 221 |
+
<div className="stat-label">Total Sessions</div>
|
| 222 |
+
</div>
|
| 223 |
+
<div className="stat-card">
|
| 224 |
+
<div className="stat-number" id="total-hours">{formatTime(stats.total_focus_time)}</div>
|
| 225 |
+
<div className="stat-label">Total Focus Time</div>
|
| 226 |
+
</div>
|
| 227 |
+
<div className="stat-card">
|
| 228 |
+
<div className="stat-number" id="avg-focus">{(stats.avg_focus_score * 100).toFixed(1)}%</div>
|
| 229 |
+
<div className="stat-label">Average Focus</div>
|
| 230 |
+
</div>
|
| 231 |
+
<div className="stat-card">
|
| 232 |
+
<div className="stat-number" id="current-streak">{stats.streak_days}</div>
|
| 233 |
+
<div className="stat-label">Day Streak</div>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div className="achievements-section">
|
| 238 |
+
<h2>Badges</h2>
|
| 239 |
+
<div id="badges-container" className="badges-grid">
|
| 240 |
+
{badges.map(badge => (
|
| 241 |
+
<div
|
| 242 |
+
key={badge.id}
|
| 243 |
+
className={`badge ${badge.unlocked ? 'unlocked' : 'locked'}`}
|
| 244 |
+
style={{
|
| 245 |
+
padding: '20px',
|
| 246 |
+
textAlign: 'center',
|
| 247 |
+
border: '2px solid',
|
| 248 |
+
borderColor: badge.unlocked ? '#00FF00' : '#444',
|
| 249 |
+
borderRadius: '10px',
|
| 250 |
+
backgroundColor: badge.unlocked ? 'rgba(0, 255, 0, 0.1)' : 'rgba(68, 68, 68, 0.1)',
|
| 251 |
+
opacity: badge.unlocked ? 1 : 0.5,
|
| 252 |
+
transition: 'all 0.3s'
|
| 253 |
+
}}
|
| 254 |
+
>
|
| 255 |
+
<div style={{ fontSize: '48px', marginBottom: '10px' }}>
|
| 256 |
+
{badge.unlocked ? badge.icon : '🔒'}
|
| 257 |
+
</div>
|
| 258 |
+
<div style={{ fontWeight: 'bold', marginBottom: '5px' }}>
|
| 259 |
+
{badge.name}
|
| 260 |
+
</div>
|
| 261 |
+
<div style={{ fontSize: '12px', color: '#888' }}>
|
| 262 |
+
{badge.description}
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
))}
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
</>
|
| 269 |
+
)}
|
| 270 |
+
</main>
|
| 271 |
+
);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
export default Achievement;
|
src/components/FocusPage.jsx
CHANGED
|
@@ -1,264 +1,264 @@
|
|
| 1 |
-
import React, { useState, useEffect } from 'react';
|
| 2 |
-
|
| 3 |
-
function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, displayVideoRef }) {
|
| 4 |
-
const [currentFrame, setCurrentFrame] = useState(30);
|
| 5 |
-
const [timelineEvents, setTimelineEvents] = useState([]);
|
| 6 |
-
|
| 7 |
-
const videoRef = displayVideoRef;
|
| 8 |
-
|
| 9 |
-
// Helper for formatting a duration in seconds.
|
| 10 |
-
const formatDuration = (seconds) => {
|
| 11 |
-
// Show a compact zero state instead of "0m 0s".
|
| 12 |
-
if (seconds === 0) return "0s";
|
| 13 |
-
|
| 14 |
-
const mins = Math.floor(seconds / 60);
|
| 15 |
-
const secs = Math.floor(seconds % 60);
|
| 16 |
-
return `${mins}m ${secs}s`;
|
| 17 |
-
};
|
| 18 |
-
|
| 19 |
-
useEffect(() => {
|
| 20 |
-
if (!videoManager) return;
|
| 21 |
-
|
| 22 |
-
// Override the status callback so the timeline updates live.
|
| 23 |
-
const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
|
| 24 |
-
videoManager.callbacks.onStatusUpdate = (isFocused) => {
|
| 25 |
-
setTimelineEvents(prev => {
|
| 26 |
-
const newEvents = [...prev, { isFocused, timestamp: Date.now() }];
|
| 27 |
-
if (newEvents.length > 60) newEvents.shift();
|
| 28 |
-
return newEvents;
|
| 29 |
-
});
|
| 30 |
-
// Preserve the original callback if one was already registered.
|
| 31 |
-
if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
|
| 32 |
-
};
|
| 33 |
-
|
| 34 |
-
// Cleanup only restores callbacks and does not force-stop the session.
|
| 35 |
-
return () => {
|
| 36 |
-
if (videoManager) {
|
| 37 |
-
videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
|
| 38 |
-
}
|
| 39 |
-
};
|
| 40 |
-
}, [videoManager]);
|
| 41 |
-
|
| 42 |
-
const handleStart = async () => {
|
| 43 |
-
try {
|
| 44 |
-
if (videoManager) {
|
| 45 |
-
setSessionResult(null); // Clear any previous summary overlay before starting.
|
| 46 |
-
setTimelineEvents([]);
|
| 47 |
-
|
| 48 |
-
console.log('🎬 Initializing camera...');
|
| 49 |
-
await videoManager.initCamera(videoRef.current);
|
| 50 |
-
console.log('✅ Camera initialized');
|
| 51 |
-
|
| 52 |
-
console.log('🚀 Starting streaming...');
|
| 53 |
-
await videoManager.startStreaming();
|
| 54 |
-
console.log('✅ Streaming started successfully');
|
| 55 |
-
}
|
| 56 |
-
} catch (err) {
|
| 57 |
-
console.error('❌ Start error:', err);
|
| 58 |
-
let errorMessage = "Failed to start: ";
|
| 59 |
-
|
| 60 |
-
if (err.name === 'NotAllowedError') {
|
| 61 |
-
errorMessage += "Camera permission denied. Please allow camera access.";
|
| 62 |
-
} else if (err.name === 'NotFoundError') {
|
| 63 |
-
errorMessage += "No camera found. Please connect a camera.";
|
| 64 |
-
} else if (err.name === 'NotReadableError') {
|
| 65 |
-
errorMessage += "Camera is already in use by another application.";
|
| 66 |
-
} else if (err.message && err.message.includes('HTTPS')) {
|
| 67 |
-
errorMessage += "Camera requires HTTPS. Please use a secure connection.";
|
| 68 |
-
} else {
|
| 69 |
-
errorMessage += err.message || "Unknown error occurred.";
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
alert(errorMessage + "\n\nCheck browser console for details.");
|
| 73 |
-
}
|
| 74 |
-
};
|
| 75 |
-
|
| 76 |
-
const handleStop = () => {
|
| 77 |
-
if (videoManager) {
|
| 78 |
-
videoManager.stopStreaming();
|
| 79 |
-
}
|
| 80 |
-
};
|
| 81 |
-
|
| 82 |
-
const handlePiP = async () => {
|
| 83 |
-
try {
|
| 84 |
-
const sourceVideoEl = videoRef.current;
|
| 85 |
-
if (!sourceVideoEl) {
|
| 86 |
-
alert('Video not ready. Please click Start first.');
|
| 87 |
-
return;
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
if (document.pictureInPictureElement) {
|
| 91 |
-
await document.exitPictureInPicture();
|
| 92 |
-
return;
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
sourceVideoEl.disablePictureInPicture = false;
|
| 96 |
-
|
| 97 |
-
if (typeof sourceVideoEl.webkitSetPresentationMode === 'function') {
|
| 98 |
-
sourceVideoEl.play().catch(() => {});
|
| 99 |
-
sourceVideoEl.webkitSetPresentationMode('picture-in-picture');
|
| 100 |
-
return;
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
if (!document.pictureInPictureEnabled || typeof sourceVideoEl.requestPictureInPicture !== 'function') {
|
| 104 |
-
alert('Picture-in-Picture is not supported in this browser.');
|
| 105 |
-
return;
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
const pipPromise = sourceVideoEl.requestPictureInPicture();
|
| 109 |
-
sourceVideoEl.play().catch(() => {});
|
| 110 |
-
await pipPromise;
|
| 111 |
-
} catch (err) {
|
| 112 |
-
console.error('PiP error:', err);
|
| 113 |
-
alert('Failed to enter Picture-in-Picture.');
|
| 114 |
-
}
|
| 115 |
-
};
|
| 116 |
-
|
| 117 |
-
// Floating window helper.
|
| 118 |
-
const handleFloatingWindow = () => {
|
| 119 |
-
handlePiP();
|
| 120 |
-
};
|
| 121 |
-
|
| 122 |
-
// ==========================================
|
| 123 |
-
// Preview button handler
|
| 124 |
-
// ==========================================
|
| 125 |
-
const handlePreview = () => {
|
| 126 |
-
// Inject placeholder data so the overlay can be previewed on demand.
|
| 127 |
-
setSessionResult({
|
| 128 |
-
duration_seconds: 0,
|
| 129 |
-
focus_score: 0
|
| 130 |
-
});
|
| 131 |
-
};
|
| 132 |
-
|
| 133 |
-
const handleCloseOverlay = () => {
|
| 134 |
-
setSessionResult(null);
|
| 135 |
-
};
|
| 136 |
-
// ==========================================
|
| 137 |
-
|
| 138 |
-
const handleFrameChange = (val) => {
|
| 139 |
-
setCurrentFrame(val);
|
| 140 |
-
if (videoManager) {
|
| 141 |
-
videoManager.setFrameRate(val);
|
| 142 |
-
}
|
| 143 |
-
};
|
| 144 |
-
|
| 145 |
-
const pageStyle = isActive
|
| 146 |
-
? undefined
|
| 147 |
-
: {
|
| 148 |
-
position: 'absolute',
|
| 149 |
-
width: '1px',
|
| 150 |
-
height: '1px',
|
| 151 |
-
overflow: 'hidden',
|
| 152 |
-
opacity: 0,
|
| 153 |
-
pointerEvents: 'none'
|
| 154 |
-
};
|
| 155 |
-
|
| 156 |
-
return (
|
| 157 |
-
<main id="page-b" className="page" style={pageStyle}>
|
| 158 |
-
{/* 1. Camera / display area */}
|
| 159 |
-
<section id="display-area" style={{ position: 'relative', overflow: 'hidden' }}>
|
| 160 |
-
<video
|
| 161 |
-
ref={videoRef}
|
| 162 |
-
muted
|
| 163 |
-
playsInline
|
| 164 |
-
autoPlay
|
| 165 |
-
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
| 166 |
-
/>
|
| 167 |
-
|
| 168 |
-
{/* Session result overlay */}
|
| 169 |
-
{sessionResult && (
|
| 170 |
-
<div className="session-result-overlay">
|
| 171 |
-
<h3>Session Complete!</h3>
|
| 172 |
-
<div className="result-item">
|
| 173 |
-
<span className="label">Duration:</span>
|
| 174 |
-
<span className="value">{formatDuration(sessionResult.duration_seconds)}</span>
|
| 175 |
-
</div>
|
| 176 |
-
<div className="result-item">
|
| 177 |
-
<span className="label">Focus Score:</span>
|
| 178 |
-
<span className="value">{(sessionResult.focus_score * 100).toFixed(1)}%</span>
|
| 179 |
-
</div>
|
| 180 |
-
|
| 181 |
-
{/* Add a lightweight close button for preview mode. */}
|
| 182 |
-
<button
|
| 183 |
-
onClick={handleCloseOverlay}
|
| 184 |
-
style={{
|
| 185 |
-
marginTop: '20px',
|
| 186 |
-
padding: '8px 20px',
|
| 187 |
-
background: 'transparent',
|
| 188 |
-
border: '1px solid white',
|
| 189 |
-
color: 'white',
|
| 190 |
-
borderRadius: '20px',
|
| 191 |
-
cursor: 'pointer'
|
| 192 |
-
}}
|
| 193 |
-
>
|
| 194 |
-
Close
|
| 195 |
-
</button>
|
| 196 |
-
</div>
|
| 197 |
-
)}
|
| 198 |
-
|
| 199 |
-
</section>
|
| 200 |
-
|
| 201 |
-
{/* 2. Timeline area */}
|
| 202 |
-
<section id="timeline-area">
|
| 203 |
-
<div className="timeline-label">Timeline</div>
|
| 204 |
-
<div id="timeline-visuals">
|
| 205 |
-
{timelineEvents.map((event, index) => (
|
| 206 |
-
<div
|
| 207 |
-
key={index}
|
| 208 |
-
className="timeline-block"
|
| 209 |
-
style={{
|
| 210 |
-
backgroundColor: event.isFocused ? '#00FF00' : '#FF0000',
|
| 211 |
-
width: '10px',
|
| 212 |
-
height: '20px',
|
| 213 |
-
display: 'inline-block',
|
| 214 |
-
marginRight: '2px',
|
| 215 |
-
borderRadius: '2px'
|
| 216 |
-
}}
|
| 217 |
-
title={event.isFocused ? 'Focused' : 'Distracted'}
|
| 218 |
-
/>
|
| 219 |
-
))}
|
| 220 |
-
</div>
|
| 221 |
-
<div id="timeline-line"></div>
|
| 222 |
-
</section>
|
| 223 |
-
|
| 224 |
-
{/* 3. Control buttons */}
|
| 225 |
-
<section id="control-panel">
|
| 226 |
-
<button id="btn-cam-start" className="action-btn green" onClick={handleStart}>Start</button>
|
| 227 |
-
<button id="btn-floating" className="action-btn yellow" onClick={handleFloatingWindow}>Floating Window</button>
|
| 228 |
-
|
| 229 |
-
{/* Temporarily repurpose the Models button as a preview action. */}
|
| 230 |
-
<button
|
| 231 |
-
id="btn-preview"
|
| 232 |
-
className="action-btn"
|
| 233 |
-
style={{ backgroundColor: '#6c5ce7' }} // Use purple so the preview action stands out.
|
| 234 |
-
onClick={handlePreview}
|
| 235 |
-
>
|
| 236 |
-
Preview Result
|
| 237 |
-
</button>
|
| 238 |
-
|
| 239 |
-
<button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>Stop</button>
|
| 240 |
-
</section>
|
| 241 |
-
|
| 242 |
-
{/* 4. Frame control */}
|
| 243 |
-
<section id="frame-control">
|
| 244 |
-
<label htmlFor="frame-slider">Frame</label>
|
| 245 |
-
<input
|
| 246 |
-
type="range"
|
| 247 |
-
id="frame-slider"
|
| 248 |
-
min="1"
|
| 249 |
-
max="60"
|
| 250 |
-
value={currentFrame}
|
| 251 |
-
onChange={(e) => handleFrameChange(e.target.value)}
|
| 252 |
-
/>
|
| 253 |
-
<input
|
| 254 |
-
type="number"
|
| 255 |
-
id="frame-input"
|
| 256 |
-
value={currentFrame}
|
| 257 |
-
onChange={(e) => handleFrameChange(e.target.value)}
|
| 258 |
-
/>
|
| 259 |
-
</section>
|
| 260 |
-
</main>
|
| 261 |
-
);
|
| 262 |
-
}
|
| 263 |
-
|
| 264 |
-
export default FocusPage;
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, displayVideoRef }) {
|
| 4 |
+
const [currentFrame, setCurrentFrame] = useState(30);
|
| 5 |
+
const [timelineEvents, setTimelineEvents] = useState([]);
|
| 6 |
+
|
| 7 |
+
const videoRef = displayVideoRef;
|
| 8 |
+
|
| 9 |
+
// Helper for formatting a duration in seconds.
|
| 10 |
+
const formatDuration = (seconds) => {
|
| 11 |
+
// Show a compact zero state instead of "0m 0s".
|
| 12 |
+
if (seconds === 0) return "0s";
|
| 13 |
+
|
| 14 |
+
const mins = Math.floor(seconds / 60);
|
| 15 |
+
const secs = Math.floor(seconds % 60);
|
| 16 |
+
return `${mins}m ${secs}s`;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
if (!videoManager) return;
|
| 21 |
+
|
| 22 |
+
// Override the status callback so the timeline updates live.
|
| 23 |
+
const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
|
| 24 |
+
videoManager.callbacks.onStatusUpdate = (isFocused) => {
|
| 25 |
+
setTimelineEvents(prev => {
|
| 26 |
+
const newEvents = [...prev, { isFocused, timestamp: Date.now() }];
|
| 27 |
+
if (newEvents.length > 60) newEvents.shift();
|
| 28 |
+
return newEvents;
|
| 29 |
+
});
|
| 30 |
+
// Preserve the original callback if one was already registered.
|
| 31 |
+
if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
// Cleanup only restores callbacks and does not force-stop the session.
|
| 35 |
+
return () => {
|
| 36 |
+
if (videoManager) {
|
| 37 |
+
videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
}, [videoManager]);
|
| 41 |
+
|
| 42 |
+
const handleStart = async () => {
|
| 43 |
+
try {
|
| 44 |
+
if (videoManager) {
|
| 45 |
+
setSessionResult(null); // Clear any previous summary overlay before starting.
|
| 46 |
+
setTimelineEvents([]);
|
| 47 |
+
|
| 48 |
+
console.log('🎬 Initializing camera...');
|
| 49 |
+
await videoManager.initCamera(videoRef.current);
|
| 50 |
+
console.log('✅ Camera initialized');
|
| 51 |
+
|
| 52 |
+
console.log('🚀 Starting streaming...');
|
| 53 |
+
await videoManager.startStreaming();
|
| 54 |
+
console.log('✅ Streaming started successfully');
|
| 55 |
+
}
|
| 56 |
+
} catch (err) {
|
| 57 |
+
console.error('❌ Start error:', err);
|
| 58 |
+
let errorMessage = "Failed to start: ";
|
| 59 |
+
|
| 60 |
+
if (err.name === 'NotAllowedError') {
|
| 61 |
+
errorMessage += "Camera permission denied. Please allow camera access.";
|
| 62 |
+
} else if (err.name === 'NotFoundError') {
|
| 63 |
+
errorMessage += "No camera found. Please connect a camera.";
|
| 64 |
+
} else if (err.name === 'NotReadableError') {
|
| 65 |
+
errorMessage += "Camera is already in use by another application.";
|
| 66 |
+
} else if (err.message && err.message.includes('HTTPS')) {
|
| 67 |
+
errorMessage += "Camera requires HTTPS. Please use a secure connection.";
|
| 68 |
+
} else {
|
| 69 |
+
errorMessage += err.message || "Unknown error occurred.";
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
alert(errorMessage + "\n\nCheck browser console for details.");
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const handleStop = () => {
|
| 77 |
+
if (videoManager) {
|
| 78 |
+
videoManager.stopStreaming();
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
const handlePiP = async () => {
|
| 83 |
+
try {
|
| 84 |
+
const sourceVideoEl = videoRef.current;
|
| 85 |
+
if (!sourceVideoEl) {
|
| 86 |
+
alert('Video not ready. Please click Start first.');
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if (document.pictureInPictureElement) {
|
| 91 |
+
await document.exitPictureInPicture();
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
sourceVideoEl.disablePictureInPicture = false;
|
| 96 |
+
|
| 97 |
+
if (typeof sourceVideoEl.webkitSetPresentationMode === 'function') {
|
| 98 |
+
sourceVideoEl.play().catch(() => {});
|
| 99 |
+
sourceVideoEl.webkitSetPresentationMode('picture-in-picture');
|
| 100 |
+
return;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
if (!document.pictureInPictureEnabled || typeof sourceVideoEl.requestPictureInPicture !== 'function') {
|
| 104 |
+
alert('Picture-in-Picture is not supported in this browser.');
|
| 105 |
+
return;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const pipPromise = sourceVideoEl.requestPictureInPicture();
|
| 109 |
+
sourceVideoEl.play().catch(() => {});
|
| 110 |
+
await pipPromise;
|
| 111 |
+
} catch (err) {
|
| 112 |
+
console.error('PiP error:', err);
|
| 113 |
+
alert('Failed to enter Picture-in-Picture.');
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
// Floating window helper.
|
| 118 |
+
const handleFloatingWindow = () => {
|
| 119 |
+
handlePiP();
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
// ==========================================
|
| 123 |
+
// Preview button handler
|
| 124 |
+
// ==========================================
|
| 125 |
+
const handlePreview = () => {
|
| 126 |
+
// Inject placeholder data so the overlay can be previewed on demand.
|
| 127 |
+
setSessionResult({
|
| 128 |
+
duration_seconds: 0,
|
| 129 |
+
focus_score: 0
|
| 130 |
+
});
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const handleCloseOverlay = () => {
|
| 134 |
+
setSessionResult(null);
|
| 135 |
+
};
|
| 136 |
+
// ==========================================
|
| 137 |
+
|
| 138 |
+
const handleFrameChange = (val) => {
|
| 139 |
+
setCurrentFrame(val);
|
| 140 |
+
if (videoManager) {
|
| 141 |
+
videoManager.setFrameRate(val);
|
| 142 |
+
}
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
const pageStyle = isActive
|
| 146 |
+
? undefined
|
| 147 |
+
: {
|
| 148 |
+
position: 'absolute',
|
| 149 |
+
width: '1px',
|
| 150 |
+
height: '1px',
|
| 151 |
+
overflow: 'hidden',
|
| 152 |
+
opacity: 0,
|
| 153 |
+
pointerEvents: 'none'
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
return (
|
| 157 |
+
<main id="page-b" className="page" style={pageStyle}>
|
| 158 |
+
{/* 1. Camera / display area */}
|
| 159 |
+
<section id="display-area" style={{ position: 'relative', overflow: 'hidden' }}>
|
| 160 |
+
<video
|
| 161 |
+
ref={videoRef}
|
| 162 |
+
muted
|
| 163 |
+
playsInline
|
| 164 |
+
autoPlay
|
| 165 |
+
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
| 166 |
+
/>
|
| 167 |
+
|
| 168 |
+
{/* Session result overlay */}
|
| 169 |
+
{sessionResult && (
|
| 170 |
+
<div className="session-result-overlay">
|
| 171 |
+
<h3>Session Complete!</h3>
|
| 172 |
+
<div className="result-item">
|
| 173 |
+
<span className="label">Duration:</span>
|
| 174 |
+
<span className="value">{formatDuration(sessionResult.duration_seconds)}</span>
|
| 175 |
+
</div>
|
| 176 |
+
<div className="result-item">
|
| 177 |
+
<span className="label">Focus Score:</span>
|
| 178 |
+
<span className="value">{(sessionResult.focus_score * 100).toFixed(1)}%</span>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
{/* Add a lightweight close button for preview mode. */}
|
| 182 |
+
<button
|
| 183 |
+
onClick={handleCloseOverlay}
|
| 184 |
+
style={{
|
| 185 |
+
marginTop: '20px',
|
| 186 |
+
padding: '8px 20px',
|
| 187 |
+
background: 'transparent',
|
| 188 |
+
border: '1px solid white',
|
| 189 |
+
color: 'white',
|
| 190 |
+
borderRadius: '20px',
|
| 191 |
+
cursor: 'pointer'
|
| 192 |
+
}}
|
| 193 |
+
>
|
| 194 |
+
Close
|
| 195 |
+
</button>
|
| 196 |
+
</div>
|
| 197 |
+
)}
|
| 198 |
+
|
| 199 |
+
</section>
|
| 200 |
+
|
| 201 |
+
{/* 2. Timeline area */}
|
| 202 |
+
<section id="timeline-area">
|
| 203 |
+
<div className="timeline-label">Timeline</div>
|
| 204 |
+
<div id="timeline-visuals">
|
| 205 |
+
{timelineEvents.map((event, index) => (
|
| 206 |
+
<div
|
| 207 |
+
key={index}
|
| 208 |
+
className="timeline-block"
|
| 209 |
+
style={{
|
| 210 |
+
backgroundColor: event.isFocused ? '#00FF00' : '#FF0000',
|
| 211 |
+
width: '10px',
|
| 212 |
+
height: '20px',
|
| 213 |
+
display: 'inline-block',
|
| 214 |
+
marginRight: '2px',
|
| 215 |
+
borderRadius: '2px'
|
| 216 |
+
}}
|
| 217 |
+
title={event.isFocused ? 'Focused' : 'Distracted'}
|
| 218 |
+
/>
|
| 219 |
+
))}
|
| 220 |
+
</div>
|
| 221 |
+
<div id="timeline-line"></div>
|
| 222 |
+
</section>
|
| 223 |
+
|
| 224 |
+
{/* 3. Control buttons */}
|
| 225 |
+
<section id="control-panel">
|
| 226 |
+
<button id="btn-cam-start" className="action-btn green" onClick={handleStart}>Start</button>
|
| 227 |
+
<button id="btn-floating" className="action-btn yellow" onClick={handleFloatingWindow}>Floating Window</button>
|
| 228 |
+
|
| 229 |
+
{/* Temporarily repurpose the Models button as a preview action. */}
|
| 230 |
+
<button
|
| 231 |
+
id="btn-preview"
|
| 232 |
+
className="action-btn"
|
| 233 |
+
style={{ backgroundColor: '#6c5ce7' }} // Use purple so the preview action stands out.
|
| 234 |
+
onClick={handlePreview}
|
| 235 |
+
>
|
| 236 |
+
Preview Result
|
| 237 |
+
</button>
|
| 238 |
+
|
| 239 |
+
<button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>Stop</button>
|
| 240 |
+
</section>
|
| 241 |
+
|
| 242 |
+
{/* 4. Frame control */}
|
| 243 |
+
<section id="frame-control">
|
| 244 |
+
<label htmlFor="frame-slider">Frame</label>
|
| 245 |
+
<input
|
| 246 |
+
type="range"
|
| 247 |
+
id="frame-slider"
|
| 248 |
+
min="1"
|
| 249 |
+
max="60"
|
| 250 |
+
value={currentFrame}
|
| 251 |
+
onChange={(e) => handleFrameChange(e.target.value)}
|
| 252 |
+
/>
|
| 253 |
+
<input
|
| 254 |
+
type="number"
|
| 255 |
+
id="frame-input"
|
| 256 |
+
value={currentFrame}
|
| 257 |
+
onChange={(e) => handleFrameChange(e.target.value)}
|
| 258 |
+
/>
|
| 259 |
+
</section>
|
| 260 |
+
</main>
|
| 261 |
+
);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
export default FocusPage;
|
src/components/FocusPageLocal.jsx
CHANGED
|
@@ -1,586 +1,768 @@
|
|
| 1 |
-
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
-
import CalibrationOverlay from './CalibrationOverlay';
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
const
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
)
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import CalibrationOverlay from './CalibrationOverlay';
|
| 3 |
+
|
| 4 |
+
const FLOW_STEPS = {
|
| 5 |
+
intro: 'intro',
|
| 6 |
+
permission: 'permission',
|
| 7 |
+
ready: 'ready'
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const FOCUS_STATES = {
|
| 11 |
+
pending: 'pending',
|
| 12 |
+
focused: 'focused',
|
| 13 |
+
notFocused: 'not-focused'
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
function HelloIcon() {
|
| 17 |
+
return (
|
| 18 |
+
<svg width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">
|
| 19 |
+
<circle cx="48" cy="48" r="40" fill="#007BFF" />
|
| 20 |
+
<path d="M30 38c0-4 2.7-7 6-7s6 3 6 7" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
|
| 21 |
+
<path d="M54 38c0-4 2.7-7 6-7s6 3 6 7" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
|
| 22 |
+
<path d="M30 52c3 11 10 17 18 17s15-6 18-17" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
|
| 23 |
+
</svg>
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function CameraIcon() {
|
| 28 |
+
return (
|
| 29 |
+
<svg width="110" height="110" viewBox="0 0 110 110" aria-hidden="true">
|
| 30 |
+
<rect x="30" y="36" width="50" height="34" rx="5" fill="none" stroke="#007BFF" strokeWidth="6" />
|
| 31 |
+
<path d="M24 72h62c0 9-7 16-16 16H40c-9 0-16-7-16-16Z" fill="none" stroke="#007BFF" strokeWidth="6" />
|
| 32 |
+
<path d="M55 28v8" stroke="#007BFF" strokeWidth="6" strokeLinecap="round" />
|
| 33 |
+
<circle cx="55" cy="36" r="14" fill="none" stroke="#007BFF" strokeWidth="6" />
|
| 34 |
+
<circle cx="55" cy="36" r="4" fill="#007BFF" />
|
| 35 |
+
<path d="M46 83h18" stroke="#007BFF" strokeWidth="6" strokeLinecap="round" />
|
| 36 |
+
</svg>
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function GazeMiniMap({ gazeData }) {
|
| 41 |
+
const canvasRef = useRef(null);
|
| 42 |
+
const screenAspect = typeof window !== 'undefined'
|
| 43 |
+
? window.screen.width / window.screen.height
|
| 44 |
+
: 16 / 9;
|
| 45 |
+
|
| 46 |
+
const MAP_H = 100;
|
| 47 |
+
const MAP_W = Math.round(MAP_H * screenAspect);
|
| 48 |
+
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
const cvs = canvasRef.current;
|
| 51 |
+
if (!cvs) return;
|
| 52 |
+
const ctx = cvs.getContext('2d');
|
| 53 |
+
const w = cvs.width;
|
| 54 |
+
const h = cvs.height;
|
| 55 |
+
|
| 56 |
+
ctx.clearRect(0, 0, w, h);
|
| 57 |
+
|
| 58 |
+
// Screen background
|
| 59 |
+
ctx.fillStyle = 'rgba(20, 20, 30, 0.85)';
|
| 60 |
+
ctx.fillRect(0, 0, w, h);
|
| 61 |
+
|
| 62 |
+
// Screen border
|
| 63 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
|
| 64 |
+
ctx.lineWidth = 1;
|
| 65 |
+
ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
|
| 66 |
+
|
| 67 |
+
// Grid lines
|
| 68 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
| 69 |
+
ctx.lineWidth = 0.5;
|
| 70 |
+
for (let i = 1; i < 4; i++) {
|
| 71 |
+
ctx.beginPath();
|
| 72 |
+
ctx.moveTo((w * i) / 4, 0);
|
| 73 |
+
ctx.lineTo((w * i) / 4, h);
|
| 74 |
+
ctx.stroke();
|
| 75 |
+
}
|
| 76 |
+
for (let i = 1; i < 3; i++) {
|
| 77 |
+
ctx.beginPath();
|
| 78 |
+
ctx.moveTo(0, (h * i) / 3);
|
| 79 |
+
ctx.lineTo(w, (h * i) / 3);
|
| 80 |
+
ctx.stroke();
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Center crosshair
|
| 84 |
+
const cx = w / 2;
|
| 85 |
+
const cy = h / 2;
|
| 86 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
| 87 |
+
ctx.lineWidth = 1;
|
| 88 |
+
ctx.beginPath();
|
| 89 |
+
ctx.moveTo(cx - 6, cy);
|
| 90 |
+
ctx.lineTo(cx + 6, cy);
|
| 91 |
+
ctx.moveTo(cx, cy - 6);
|
| 92 |
+
ctx.lineTo(cx, cy + 6);
|
| 93 |
+
ctx.stroke();
|
| 94 |
+
|
| 95 |
+
if (!gazeData || gazeData.gaze_x == null || gazeData.gaze_y == null) {
|
| 96 |
+
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
| 97 |
+
ctx.font = '10px Arial';
|
| 98 |
+
ctx.textAlign = 'center';
|
| 99 |
+
ctx.fillText('No gaze data', cx, cy + 3);
|
| 100 |
+
ctx.textAlign = 'left';
|
| 101 |
+
return;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const gx = gazeData.gaze_x;
|
| 105 |
+
const gy = gazeData.gaze_y;
|
| 106 |
+
const onScreen = gazeData.on_screen;
|
| 107 |
+
|
| 108 |
+
const dotX = gx * w;
|
| 109 |
+
const dotY = gy * h;
|
| 110 |
+
|
| 111 |
+
const gradient = ctx.createRadialGradient(dotX, dotY, 0, dotX, dotY, 14);
|
| 112 |
+
gradient.addColorStop(0, onScreen ? 'rgba(74, 222, 128, 0.5)' : 'rgba(248, 113, 113, 0.5)');
|
| 113 |
+
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
| 114 |
+
ctx.fillStyle = gradient;
|
| 115 |
+
ctx.fillRect(dotX - 14, dotY - 14, 28, 28);
|
| 116 |
+
|
| 117 |
+
ctx.beginPath();
|
| 118 |
+
ctx.arc(dotX, dotY, 5, 0, 2 * Math.PI);
|
| 119 |
+
ctx.fillStyle = onScreen ? '#4ade80' : '#f87171';
|
| 120 |
+
ctx.fill();
|
| 121 |
+
ctx.strokeStyle = '#fff';
|
| 122 |
+
ctx.lineWidth = 1.5;
|
| 123 |
+
ctx.stroke();
|
| 124 |
+
|
| 125 |
+
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
| 126 |
+
ctx.font = '9px Arial';
|
| 127 |
+
ctx.textAlign = 'right';
|
| 128 |
+
ctx.fillText(`${(gx * 100).toFixed(0)}%, ${(gy * 100).toFixed(0)}%`, w - 4, h - 4);
|
| 129 |
+
ctx.textAlign = 'left';
|
| 130 |
+
}, [gazeData]);
|
| 131 |
+
|
| 132 |
+
return (
|
| 133 |
+
<canvas
|
| 134 |
+
ref={canvasRef}
|
| 135 |
+
width={MAP_W}
|
| 136 |
+
height={MAP_H}
|
| 137 |
+
style={{ borderRadius: '8px', border: '1px solid rgba(255,255,255,0.1)', display: 'block' }}
|
| 138 |
+
/>
|
| 139 |
+
);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActive, isTutorialActive, setIsTutorialActive }) {
|
| 143 |
+
const [currentFrame, setCurrentFrame] = useState(15);
|
| 144 |
+
const [timelineEvents, setTimelineEvents] = useState([]);
|
| 145 |
+
const [stats, setStats] = useState(null);
|
| 146 |
+
const [systemStats, setSystemStats] = useState(null);
|
| 147 |
+
const [availableModels, setAvailableModels] = useState([]);
|
| 148 |
+
const [currentModel, setCurrentModel] = useState('mlp');
|
| 149 |
+
const [flowStep, setFlowStep] = useState(FLOW_STEPS.ready);
|
| 150 |
+
const [cameraReady, setCameraReady] = useState(false);
|
| 151 |
+
const [isStarting, setIsStarting] = useState(false);
|
| 152 |
+
const [focusState, setFocusState] = useState(FOCUS_STATES.pending);
|
| 153 |
+
const [cameraError, setCameraError] = useState('');
|
| 154 |
+
const [calibrationState, setCalibrationState] = useState(null);
|
| 155 |
+
const [l2csBoost, setL2csBoost] = useState(false);
|
| 156 |
+
const [l2csBoostAvailable, setL2csBoostAvailable] = useState(false);
|
| 157 |
+
const [eyeGazeEnabled, setEyeGazeEnabled] = useState(false);
|
| 158 |
+
const [prevModel, setPrevModel] = useState('mlp');
|
| 159 |
+
const [isCalibrated, setIsCalibrated] = useState(false);
|
| 160 |
+
const [gazeData, setGazeData] = useState(null);
|
| 161 |
+
|
| 162 |
+
const localVideoRef = useRef(null);
|
| 163 |
+
const displayCanvasRef = useRef(null);
|
| 164 |
+
const pipVideoRef = useRef(null);
|
| 165 |
+
const pipStreamRef = useRef(null);
|
| 166 |
+
const previewFrameRef = useRef(null);
|
| 167 |
+
|
| 168 |
+
// Sync flowStep with isTutorialActive from props
|
| 169 |
+
useEffect(() => {
|
| 170 |
+
if (isTutorialActive) {
|
| 171 |
+
setFlowStep(FLOW_STEPS.intro);
|
| 172 |
+
} else {
|
| 173 |
+
setFlowStep(FLOW_STEPS.ready);
|
| 174 |
+
}
|
| 175 |
+
}, [isTutorialActive]);
|
| 176 |
+
|
| 177 |
+
const formatDuration = (seconds) => {
|
| 178 |
+
if (seconds === 0) return '0s';
|
| 179 |
+
const mins = Math.floor(seconds / 60);
|
| 180 |
+
const secs = Math.floor(seconds % 60);
|
| 181 |
+
return `${mins}m ${secs}s`;
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
const stopPreviewLoop = () => {
|
| 185 |
+
if (previewFrameRef.current) {
|
| 186 |
+
cancelAnimationFrame(previewFrameRef.current);
|
| 187 |
+
previewFrameRef.current = null;
|
| 188 |
+
}
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const startPreviewLoop = () => {
|
| 192 |
+
stopPreviewLoop();
|
| 193 |
+
const renderPreview = () => {
|
| 194 |
+
const canvas = displayCanvasRef.current;
|
| 195 |
+
const video = localVideoRef.current;
|
| 196 |
+
|
| 197 |
+
if (!canvas || !video || !cameraReady || videoManager?.isStreaming) {
|
| 198 |
+
previewFrameRef.current = null;
|
| 199 |
+
return;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
if (video.readyState >= 2) {
|
| 203 |
+
const ctx = canvas.getContext('2d');
|
| 204 |
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
previewFrameRef.current = requestAnimationFrame(renderPreview);
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
previewFrameRef.current = requestAnimationFrame(renderPreview);
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
const getErrorMessage = (err) => {
|
| 214 |
+
if (err?.name === 'NotAllowedError') return 'Camera permission denied. Please allow camera access.';
|
| 215 |
+
if (err?.name === 'NotFoundError') return 'No camera found. Please connect a camera.';
|
| 216 |
+
if (err?.name === 'NotReadableError') return 'Camera is already in use by another application.';
|
| 217 |
+
if (err?.target?.url) return `WebSocket connection failed: ${err.target.url}. Check backend.`;
|
| 218 |
+
return err?.message || 'Failed to start focus session.';
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
useEffect(() => {
|
| 222 |
+
if (!videoManager) return;
|
| 223 |
+
|
| 224 |
+
const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
|
| 225 |
+
const originalOnSessionEnd = videoManager.callbacks.onSessionEnd;
|
| 226 |
+
|
| 227 |
+
videoManager.callbacks.onStatusUpdate = (isFocused) => {
|
| 228 |
+
setTimelineEvents((prev) => {
|
| 229 |
+
const newEvents = [...prev, { isFocused, timestamp: Date.now() }];
|
| 230 |
+
if (newEvents.length > 60) newEvents.shift();
|
| 231 |
+
return newEvents;
|
| 232 |
+
});
|
| 233 |
+
setFocusState(isFocused ? FOCUS_STATES.focused : FOCUS_STATES.notFocused);
|
| 234 |
+
if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
|
| 235 |
+
};
|
| 236 |
+
|
| 237 |
+
videoManager.callbacks.onSessionEnd = (summary) => {
|
| 238 |
+
setFocusState(FOCUS_STATES.pending);
|
| 239 |
+
setCameraReady(false);
|
| 240 |
+
if (originalOnSessionEnd) originalOnSessionEnd(summary);
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
videoManager.callbacks.onCalibrationUpdate = (state) => {
|
| 244 |
+
setCalibrationState(state && state.active ? state : null);
|
| 245 |
+
if (state && state.done && state.success) setIsCalibrated(true);
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
videoManager.callbacks.onGazeData = (data) => setGazeData(data);
|
| 249 |
+
|
| 250 |
+
const statsInterval = setInterval(() => {
|
| 251 |
+
if (videoManager && videoManager.getStats) setStats(videoManager.getStats());
|
| 252 |
+
}, 1000);
|
| 253 |
+
|
| 254 |
+
return () => {
|
| 255 |
+
videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
|
| 256 |
+
videoManager.callbacks.onSessionEnd = originalOnSessionEnd;
|
| 257 |
+
videoManager.callbacks.onCalibrationUpdate = undefined;
|
| 258 |
+
videoManager.callbacks.onGazeData = undefined;
|
| 259 |
+
clearInterval(statsInterval);
|
| 260 |
+
};
|
| 261 |
+
}, [videoManager]);
|
| 262 |
+
|
| 263 |
+
useEffect(() => {
|
| 264 |
+
fetch('/api/models')
|
| 265 |
+
.then((res) => res.json())
|
| 266 |
+
.then((data) => {
|
| 267 |
+
if (data.available) setAvailableModels(data.available);
|
| 268 |
+
if (data.current) {
|
| 269 |
+
setCurrentModel(data.current);
|
| 270 |
+
if (data.current === 'l2cs') setEyeGazeEnabled(true);
|
| 271 |
+
}
|
| 272 |
+
})
|
| 273 |
+
.catch((err) => console.error('Failed to fetch models:', err));
|
| 274 |
+
|
| 275 |
+
fetch('/api/settings')
|
| 276 |
+
.then((res) => res.json())
|
| 277 |
+
.then((data) => {
|
| 278 |
+
if (data && data.l2cs_boost !== undefined) setL2csBoost(data.l2cs_boost);
|
| 279 |
+
if (data && data.l2cs_boost_available !== undefined) setL2csBoostAvailable(data.l2cs_boost_available);
|
| 280 |
+
})
|
| 281 |
+
.catch((err) => console.error('Failed to fetch settings:', err));
|
| 282 |
+
}, []);
|
| 283 |
+
|
| 284 |
+
useEffect(() => {
|
| 285 |
+
if (flowStep === FLOW_STEPS.ready && cameraReady && !videoManager?.isStreaming) {
|
| 286 |
+
startPreviewLoop();
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
+
stopPreviewLoop();
|
| 290 |
+
}, [cameraReady, flowStep, videoManager?.isStreaming]);
|
| 291 |
+
|
| 292 |
+
useEffect(() => {
|
| 293 |
+
if (!isActive) stopPreviewLoop();
|
| 294 |
+
}, [isActive]);
|
| 295 |
+
|
| 296 |
+
useEffect(() => {
|
| 297 |
+
return () => {
|
| 298 |
+
stopPreviewLoop();
|
| 299 |
+
if (pipVideoRef.current) {
|
| 300 |
+
pipVideoRef.current.pause();
|
| 301 |
+
pipVideoRef.current.srcObject = null;
|
| 302 |
+
}
|
| 303 |
+
if (pipStreamRef.current) {
|
| 304 |
+
pipStreamRef.current.getTracks().forEach((t) => t.stop());
|
| 305 |
+
pipStreamRef.current = null;
|
| 306 |
+
}
|
| 307 |
+
};
|
| 308 |
+
}, []);
|
| 309 |
+
|
| 310 |
+
useEffect(() => {
|
| 311 |
+
const fetchSystem = () => {
|
| 312 |
+
fetch('/api/stats/system')
|
| 313 |
+
.then(res => res.json())
|
| 314 |
+
.then(data => setSystemStats(data))
|
| 315 |
+
.catch(() => setSystemStats(null));
|
| 316 |
+
};
|
| 317 |
+
fetchSystem();
|
| 318 |
+
const interval = setInterval(fetchSystem, 3000);
|
| 319 |
+
return () => clearInterval(interval);
|
| 320 |
+
}, []);
|
| 321 |
+
|
| 322 |
+
const handleModelChange = async (modelName) => {
|
| 323 |
+
try {
|
| 324 |
+
const res = await fetch('/api/settings', {
|
| 325 |
+
method: 'PUT',
|
| 326 |
+
headers: { 'Content-Type': 'application/json' },
|
| 327 |
+
body: JSON.stringify({ model_name: modelName })
|
| 328 |
+
});
|
| 329 |
+
const result = await res.json();
|
| 330 |
+
if (result.updated) {
|
| 331 |
+
setCurrentModel(modelName);
|
| 332 |
+
if (modelName === 'l2cs') {
|
| 333 |
+
setEyeGazeEnabled(true);
|
| 334 |
+
} else if (eyeGazeEnabled) {
|
| 335 |
+
setEyeGazeEnabled(false);
|
| 336 |
+
setIsCalibrated(false);
|
| 337 |
+
setGazeData(null);
|
| 338 |
+
}
|
| 339 |
+
}
|
| 340 |
+
} catch (err) {
|
| 341 |
+
console.error('Failed to switch model:', err);
|
| 342 |
+
}
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
const handleL2csBoostToggle = async () => {
|
| 346 |
+
if (!l2csBoostAvailable) return;
|
| 347 |
+
const next = !l2csBoost;
|
| 348 |
+
try {
|
| 349 |
+
const res = await fetch('/api/settings', {
|
| 350 |
+
method: 'PUT',
|
| 351 |
+
headers: { 'Content-Type': 'application/json' },
|
| 352 |
+
body: JSON.stringify({ l2cs_boost: next })
|
| 353 |
+
});
|
| 354 |
+
if (res.ok) setL2csBoost(next);
|
| 355 |
+
else alert((await res.json().catch(() => ({}))).detail || 'Could not enable L2CS boost');
|
| 356 |
+
} catch (err) {
|
| 357 |
+
console.error('Failed to toggle L2CS boost:', err);
|
| 358 |
+
}
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
const handleEyeGazeToggle = async () => {
|
| 362 |
+
const next = !eyeGazeEnabled;
|
| 363 |
+
if (next) {
|
| 364 |
+
setPrevModel(currentModel);
|
| 365 |
+
await handleModelChange('l2cs');
|
| 366 |
+
setEyeGazeEnabled(true);
|
| 367 |
+
} else {
|
| 368 |
+
const restoreTo = prevModel === 'l2cs' ? 'mlp' : prevModel;
|
| 369 |
+
await handleModelChange(restoreTo);
|
| 370 |
+
setEyeGazeEnabled(false);
|
| 371 |
+
setIsCalibrated(false);
|
| 372 |
+
setGazeData(null);
|
| 373 |
+
}
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
const [calibrationSetupOpen, setCalibrationSetupOpen] = useState(false);
|
| 377 |
+
|
| 378 |
+
const handleCalibrate = () => setCalibrationSetupOpen(true);
|
| 379 |
+
const handleCalibrationServerStart = () => { if (videoManager) videoManager.startCalibration(); };
|
| 380 |
+
|
| 381 |
+
const handleEnableCamera = async () => {
|
| 382 |
+
if (!videoManager) return;
|
| 383 |
+
try {
|
| 384 |
+
setCameraError('');
|
| 385 |
+
await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
|
| 386 |
+
setCameraReady(true);
|
| 387 |
+
setFlowStep(FLOW_STEPS.ready);
|
| 388 |
+
setFocusState(FOCUS_STATES.pending);
|
| 389 |
+
if (setIsTutorialActive) setIsTutorialActive(false); // Close tutorial flag
|
| 390 |
+
} catch (err) {
|
| 391 |
+
setCameraError(getErrorMessage(err));
|
| 392 |
+
console.error('Camera init error:', err);
|
| 393 |
+
}
|
| 394 |
+
};
|
| 395 |
+
|
| 396 |
+
const handleSkipTutorial = () => {
|
| 397 |
+
setFlowStep(FLOW_STEPS.ready);
|
| 398 |
+
if (setIsTutorialActive) setIsTutorialActive(false);
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
const handleStart = async () => {
|
| 402 |
+
try {
|
| 403 |
+
setIsStarting(true);
|
| 404 |
+
setSessionResult(null);
|
| 405 |
+
setTimelineEvents([]);
|
| 406 |
+
setFocusState(FOCUS_STATES.pending);
|
| 407 |
+
setCameraError('');
|
| 408 |
+
|
| 409 |
+
if (!cameraReady) {
|
| 410 |
+
await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
|
| 411 |
+
setCameraReady(true);
|
| 412 |
+
setFlowStep(FLOW_STEPS.ready);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
await videoManager.startStreaming();
|
| 416 |
+
} catch (err) {
|
| 417 |
+
const errorMessage = getErrorMessage(err);
|
| 418 |
+
setCameraError(errorMessage);
|
| 419 |
+
setFocusState(FOCUS_STATES.pending);
|
| 420 |
+
console.error('Start error:', err);
|
| 421 |
+
alert(`Failed to start: ${errorMessage}\n\nCheck browser console for details.`);
|
| 422 |
+
} finally {
|
| 423 |
+
setIsStarting(false);
|
| 424 |
+
}
|
| 425 |
+
};
|
| 426 |
+
|
| 427 |
+
const handleStop = async () => {
|
| 428 |
+
if (videoManager) await videoManager.stopStreaming();
|
| 429 |
+
try {
|
| 430 |
+
if (document.pictureInPictureElement === pipVideoRef.current) {
|
| 431 |
+
await document.exitPictureInPicture();
|
| 432 |
+
}
|
| 433 |
+
} catch (_) {}
|
| 434 |
+
if (pipVideoRef.current) {
|
| 435 |
+
pipVideoRef.current.pause();
|
| 436 |
+
pipVideoRef.current.srcObject = null;
|
| 437 |
+
}
|
| 438 |
+
if (pipStreamRef.current) {
|
| 439 |
+
pipStreamRef.current.getTracks().forEach((t) => t.stop());
|
| 440 |
+
pipStreamRef.current = null;
|
| 441 |
+
}
|
| 442 |
+
stopPreviewLoop();
|
| 443 |
+
setFocusState(FOCUS_STATES.pending);
|
| 444 |
+
setCameraReady(false);
|
| 445 |
+
};
|
| 446 |
+
|
| 447 |
+
const handlePiP = async () => {
|
| 448 |
+
try {
|
| 449 |
+
if (!videoManager || !videoManager.isStreaming) return alert('Please start the video first.');
|
| 450 |
+
if (!displayCanvasRef.current) return alert('Video not ready.');
|
| 451 |
+
if (document.pictureInPictureElement === pipVideoRef.current) {
|
| 452 |
+
await document.exitPictureInPicture();
|
| 453 |
+
return;
|
| 454 |
+
}
|
| 455 |
+
if (!document.pictureInPictureEnabled) return alert('Picture-in-Picture is not supported.');
|
| 456 |
+
|
| 457 |
+
const pipVideo = pipVideoRef.current;
|
| 458 |
+
if (!pipVideo) return alert('PiP video element not ready.');
|
| 459 |
+
|
| 460 |
+
const isSafariPiP = typeof pipVideo.webkitSetPresentationMode === 'function';
|
| 461 |
+
let stream = pipStreamRef.current;
|
| 462 |
+
if (!stream) {
|
| 463 |
+
const capture = displayCanvasRef.current.captureStream;
|
| 464 |
+
if (typeof capture === 'function') stream = capture.call(displayCanvasRef.current, 30);
|
| 465 |
+
if (!stream || stream.getTracks().length === 0) {
|
| 466 |
+
const cameraStream = localVideoRef.current?.srcObject;
|
| 467 |
+
if (!cameraStream) return alert('Camera stream not ready.');
|
| 468 |
+
stream = cameraStream;
|
| 469 |
+
}
|
| 470 |
+
pipStreamRef.current = stream;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
pipVideo.srcObject = stream;
|
| 474 |
+
if (pipVideo.readyState < 2) {
|
| 475 |
+
await new Promise((resolve) => {
|
| 476 |
+
const onReady = () => {
|
| 477 |
+
pipVideo.removeEventListener('loadeddata', onReady);
|
| 478 |
+
pipVideo.removeEventListener('canplay', onReady);
|
| 479 |
+
resolve();
|
| 480 |
+
};
|
| 481 |
+
pipVideo.addEventListener('loadeddata', onReady);
|
| 482 |
+
pipVideo.addEventListener('canplay', onReady);
|
| 483 |
+
setTimeout(resolve, 600);
|
| 484 |
+
});
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
try { await pipVideo.play(); } catch (_) {}
|
| 488 |
+
|
| 489 |
+
if (isSafariPiP) {
|
| 490 |
+
try {
|
| 491 |
+
pipVideo.webkitSetPresentationMode('picture-in-picture');
|
| 492 |
+
return;
|
| 493 |
+
} catch (e) {
|
| 494 |
+
const cameraStream = localVideoRef.current?.srcObject;
|
| 495 |
+
if (cameraStream && cameraStream !== pipVideo.srcObject) {
|
| 496 |
+
pipVideo.srcObject = cameraStream;
|
| 497 |
+
try { await pipVideo.play(); } catch (_) {}
|
| 498 |
+
pipVideo.webkitSetPresentationMode('picture-in-picture');
|
| 499 |
+
return;
|
| 500 |
+
}
|
| 501 |
+
throw e;
|
| 502 |
+
}
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
if (typeof pipVideo.requestPictureInPicture === 'function') {
|
| 506 |
+
await pipVideo.requestPictureInPicture();
|
| 507 |
+
} else {
|
| 508 |
+
alert('Picture-in-Picture is not supported in this browser.');
|
| 509 |
+
}
|
| 510 |
+
} catch (err) {
|
| 511 |
+
console.error('PiP error:', err);
|
| 512 |
+
alert(`Failed to enter Picture-in-Picture: ${err.message}`);
|
| 513 |
+
}
|
| 514 |
+
};
|
| 515 |
+
|
| 516 |
+
const handleFrameChange = (val) => {
|
| 517 |
+
const rate = parseInt(val, 10);
|
| 518 |
+
setCurrentFrame(rate);
|
| 519 |
+
if (videoManager) videoManager.setFrameRate(rate);
|
| 520 |
+
};
|
| 521 |
+
|
| 522 |
+
const handlePreview = () => {
|
| 523 |
+
if (!videoManager || !videoManager.isStreaming) return alert('Please start a session first.');
|
| 524 |
+
const currentStats = videoManager.getStats();
|
| 525 |
+
if (!currentStats.sessionId) return alert('No active session.');
|
| 526 |
+
|
| 527 |
+
const sessionDuration = Math.floor((Date.now() - (videoManager.sessionStartTime || Date.now())) / 1000);
|
| 528 |
+
const focusScore = currentStats.framesProcessed > 0
|
| 529 |
+
? (currentStats.framesProcessed * (currentStats.currentStatus ? 1 : 0)) / currentStats.framesProcessed
|
| 530 |
+
: 0;
|
| 531 |
+
|
| 532 |
+
setSessionResult({
|
| 533 |
+
duration_seconds: sessionDuration,
|
| 534 |
+
focus_score: focusScore,
|
| 535 |
+
total_frames: currentStats.framesProcessed,
|
| 536 |
+
focused_frames: Math.floor(currentStats.framesProcessed * focusScore)
|
| 537 |
+
});
|
| 538 |
+
};
|
| 539 |
+
|
| 540 |
+
const handleCloseOverlay = () => setSessionResult(null);
|
| 541 |
+
|
| 542 |
+
const pageStyle = isActive
|
| 543 |
+
? undefined
|
| 544 |
+
: { position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', opacity: 0, pointerEvents: 'none' };
|
| 545 |
+
|
| 546 |
+
const focusStateLabel = {
|
| 547 |
+
[FOCUS_STATES.pending]: 'Pending',
|
| 548 |
+
[FOCUS_STATES.focused]: 'Focused',
|
| 549 |
+
[FOCUS_STATES.notFocused]: 'Not Focused'
|
| 550 |
+
}[focusState];
|
| 551 |
+
|
| 552 |
+
const introHighlights = [
|
| 553 |
+
{
|
| 554 |
+
title: 'Live focus tracking',
|
| 555 |
+
text: 'Head pose, gaze, and eye openness are read continuously during the session.'
|
| 556 |
+
},
|
| 557 |
+
{
|
| 558 |
+
title: 'Quick setup',
|
| 559 |
+
text: 'Front-facing light and a stable camera angle give the cleanest preview.'
|
| 560 |
+
},
|
| 561 |
+
{
|
| 562 |
+
title: 'Private by default',
|
| 563 |
+
text: 'Only session metadata is stored locally, not the raw camera footage.'
|
| 564 |
+
},
|
| 565 |
+
{
|
| 566 |
+
title: 'Sync across devices',
|
| 567 |
+
text: 'Your progress is automatically saved to this browser. You can migrate your data anytime via the Data Management section at the bottom of My Records.'
|
| 568 |
+
}
|
| 569 |
+
];
|
| 570 |
+
|
| 571 |
+
const permissionSteps = [
|
| 572 |
+
{ title: 'Allow browser access', text: 'Approve the camera prompt so the preview can appear immediately.' },
|
| 573 |
+
{ title: 'Check your framing', text: 'Keep your face visible and centered for more stable landmark detection.' },
|
| 574 |
+
{ title: 'Start when ready', text: 'After the preview appears, use the page controls to begin or stop.' }
|
| 575 |
+
];
|
| 576 |
+
|
| 577 |
+
const renderIntroCard = () => {
|
| 578 |
+
if (flowStep === FLOW_STEPS.intro) {
|
| 579 |
+
return (
|
| 580 |
+
<div className="focus-flow-overlay">
|
| 581 |
+
<div className="focus-flow-card">
|
| 582 |
+
<div className="focus-flow-header">
|
| 583 |
+
<div>
|
| 584 |
+
<div className="focus-flow-eyebrow">Focus Session</div>
|
| 585 |
+
<h2>Before you begin</h2>
|
| 586 |
+
</div>
|
| 587 |
+
<div className="focus-flow-icon"><HelloIcon /></div>
|
| 588 |
+
</div>
|
| 589 |
+
|
| 590 |
+
<p className="focus-flow-lead">
|
| 591 |
+
The focus page uses your live camera preview to estimate attention in real time.
|
| 592 |
+
Review the setup notes below, then continue to camera access.
|
| 593 |
+
</p>
|
| 594 |
+
|
| 595 |
+
<div className="focus-flow-grid">
|
| 596 |
+
{introHighlights.map((item) => (
|
| 597 |
+
<article key={item.title} className="focus-flow-panel">
|
| 598 |
+
<h3>{item.title}</h3>
|
| 599 |
+
<p>{item.text}</p>
|
| 600 |
+
</article>
|
| 601 |
+
))}
|
| 602 |
+
</div>
|
| 603 |
+
|
| 604 |
+
<div className="focus-flow-footer">
|
| 605 |
+
<div className="focus-flow-note">
|
| 606 |
+
You can still change frame rate and available model options after the preview loads.
|
| 607 |
+
</div>
|
| 608 |
+
<div style={{ display: 'flex', gap: '10px' }}>
|
| 609 |
+
<button className="focus-flow-secondary" onClick={handleSkipTutorial}>
|
| 610 |
+
Skip
|
| 611 |
+
</button>
|
| 612 |
+
<button className="focus-flow-button" onClick={() => setFlowStep(FLOW_STEPS.permission)}>
|
| 613 |
+
Continue
|
| 614 |
+
</button>
|
| 615 |
+
</div>
|
| 616 |
+
</div>
|
| 617 |
+
</div>
|
| 618 |
+
</div>
|
| 619 |
+
);
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
if (flowStep === FLOW_STEPS.permission && !cameraReady) {
|
| 623 |
+
return (
|
| 624 |
+
<div className="focus-flow-overlay">
|
| 625 |
+
<div className="focus-flow-card">
|
| 626 |
+
<div className="focus-flow-header">
|
| 627 |
+
<div>
|
| 628 |
+
<div className="focus-flow-eyebrow">Camera Setup</div>
|
| 629 |
+
<h2>Enable camera access</h2>
|
| 630 |
+
</div>
|
| 631 |
+
<div className="focus-flow-icon"><CameraIcon /></div>
|
| 632 |
+
</div>
|
| 633 |
+
|
| 634 |
+
<p className="focus-flow-lead">
|
| 635 |
+
Once access is granted, your preview appears here and the rest of the Focus page behaves like the other dashboard screens.
|
| 636 |
+
</p>
|
| 637 |
+
|
| 638 |
+
<div className="focus-flow-steps">
|
| 639 |
+
{permissionSteps.map((item, index) => (
|
| 640 |
+
<div key={item.title} className="focus-flow-step">
|
| 641 |
+
<div className="focus-flow-step-number">{index + 1}</div>
|
| 642 |
+
<div className="focus-flow-step-copy">
|
| 643 |
+
<h3>{item.title}</h3>
|
| 644 |
+
<p>{item.text}</p>
|
| 645 |
+
</div>
|
| 646 |
+
</div>
|
| 647 |
+
))}
|
| 648 |
+
</div>
|
| 649 |
+
|
| 650 |
+
{cameraError ? <div className="focus-inline-error">{cameraError}</div> : null}
|
| 651 |
+
|
| 652 |
+
<div className="focus-flow-footer">
|
| 653 |
+
<button type="button" className="focus-flow-secondary" onClick={() => setFlowStep(FLOW_STEPS.intro)}>Back</button>
|
| 654 |
+
<button className="focus-flow-button" onClick={handleEnableCamera}>Enable Camera</button>
|
| 655 |
+
</div>
|
| 656 |
+
</div>
|
| 657 |
+
</div>
|
| 658 |
+
);
|
| 659 |
+
}
|
| 660 |
+
return null;
|
| 661 |
+
};
|
| 662 |
+
|
| 663 |
+
return (
|
| 664 |
+
<main id="page-b" className="page" style={pageStyle}>
|
| 665 |
+
<CalibrationOverlay
|
| 666 |
+
calibration={calibrationState}
|
| 667 |
+
videoManager={videoManager}
|
| 668 |
+
localVideoRef={localVideoRef}
|
| 669 |
+
onRequestStart={handleCalibrationServerStart}
|
| 670 |
+
setupOpen={calibrationSetupOpen}
|
| 671 |
+
setSetupOpen={setCalibrationSetupOpen}
|
| 672 |
+
/>
|
| 673 |
+
{renderIntroCard()}
|
| 674 |
+
|
| 675 |
+
<section id="display-area" className="focus-display-shell">
|
| 676 |
+
<video ref={pipVideoRef} muted playsInline autoPlay style={{ position: 'absolute', width: '1px', height: '1px', opacity: 0, pointerEvents: 'none' }} />
|
| 677 |
+
<video ref={localVideoRef} muted playsInline autoPlay style={{ display: 'none' }} />
|
| 678 |
+
|
| 679 |
+
<canvas ref={displayCanvasRef} width={640} height={480} style={{ width: '100%', height: '100%', objectFit: 'contain', backgroundColor: '#101010' }} />
|
| 680 |
+
|
| 681 |
+
{flowStep === FLOW_STEPS.ready ? (
|
| 682 |
+
<>
|
| 683 |
+
<div className={`focus-state-pill ${focusState}`}>
|
| 684 |
+
<span className="focus-state-dot" />
|
| 685 |
+
{focusStateLabel}
|
| 686 |
+
</div>
|
| 687 |
+
{!cameraReady && !videoManager?.isStreaming ? (
|
| 688 |
+
<div className="focus-idle-overlay">
|
| 689 |
+
<p>Camera is paused.</p>
|
| 690 |
+
<span>Use Start to enable the camera and begin detection.</span>
|
| 691 |
+
</div>
|
| 692 |
+
) : null}
|
| 693 |
+
</>
|
| 694 |
+
) : null}
|
| 695 |
+
|
| 696 |
+
{sessionResult && (
|
| 697 |
+
<div className="session-result-overlay">
|
| 698 |
+
<h3>Session Complete!</h3>
|
| 699 |
+
<div className="result-item"><span className="label">Duration:</span><span className="value">{formatDuration(sessionResult.duration_seconds)}</span></div>
|
| 700 |
+
<div className="result-item"><span className="label">Focus Score:</span><span className="value">{(sessionResult.focus_score * 100).toFixed(1)}%</span></div>
|
| 701 |
+
<button onClick={handleCloseOverlay} style={{ marginTop: '20px', padding: '8px 20px', background: 'transparent', border: '1px solid white', color: 'white', borderRadius: '20px', cursor: 'pointer' }}>Close</button>
|
| 702 |
+
</div>
|
| 703 |
+
)}
|
| 704 |
+
</section>
|
| 705 |
+
|
| 706 |
+
{systemStats && (systemStats.cpu_percent != null || systemStats.memory_percent != null) && (
|
| 707 |
+
<section style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '16px', padding: '6px 12px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', margin: '6px auto', maxWidth: '400px', fontSize: '13px', color: '#aaa' }}>
|
| 708 |
+
<span title="Server CPU">CPU: <strong style={{ color: systemStats.cpu_percent > 80 ? '#ff6b6b' : systemStats.cpu_percent > 50 ? '#ffc168' : '#66d9a0' }}>{systemStats.cpu_percent}%</strong></span>
|
| 709 |
+
<span title="Server memory">RAM: <strong style={{ color: systemStats.memory_percent > 85 ? '#ff6b6b' : systemStats.memory_percent > 60 ? '#ffc168' : '#66d9a0' }}>{systemStats.memory_percent}%</strong> ({systemStats.memory_used_mb}/{systemStats.memory_total_mb} MB)</span>
|
| 710 |
+
</section>
|
| 711 |
+
)}
|
| 712 |
+
|
| 713 |
+
{flowStep === FLOW_STEPS.ready ? (
|
| 714 |
+
<>
|
| 715 |
+
{availableModels.length > 0 ? (
|
| 716 |
+
<section className="focus-model-strip">
|
| 717 |
+
<span className="focus-model-label">Model:</span>
|
| 718 |
+
{availableModels.map((name) => (
|
| 719 |
+
<button key={name} onClick={() => handleModelChange(name)} className={`focus-model-button ${currentModel === name ? 'active' : ''}`}>{name}</button>
|
| 720 |
+
))}
|
| 721 |
+
</section>
|
| 722 |
+
) : null}
|
| 723 |
+
|
| 724 |
+
<section id="timeline-area">
|
| 725 |
+
<div className="timeline-label">Timeline</div>
|
| 726 |
+
<div id="timeline-visuals">
|
| 727 |
+
{timelineEvents.map((event, index) => (
|
| 728 |
+
<div key={index} className="timeline-block" style={{ backgroundColor: event.isFocused ? '#00FF00' : '#FF0000', width: '10px', height: '20px', display: 'inline-block', marginRight: '2px', borderRadius: '2px' }} title={event.isFocused ? 'Focused' : 'Distracted'} />
|
| 729 |
+
))}
|
| 730 |
+
</div>
|
| 731 |
+
<div id="timeline-line" />
|
| 732 |
+
</section>
|
| 733 |
+
|
| 734 |
+
<section id="control-panel">
|
| 735 |
+
<button id="btn-cam-start" className="action-btn green" onClick={handleStart} disabled={isStarting}>{isStarting ? 'Starting...' : 'Start'}</button>
|
| 736 |
+
<button type="button" className="action-btn" style={{ backgroundColor: eyeGazeEnabled ? '#8b5cf6' : '#475569', position: 'relative' }} onClick={handleEyeGazeToggle} title={eyeGazeEnabled ? (isCalibrated ? 'Eye Gaze ON (Calibrated)' : 'Eye Gaze ON (Uncalibrated)') : 'Enable L2CS eye gaze tracking'}>
|
| 737 |
+
Eye Gaze {eyeGazeEnabled ? 'ON' : 'OFF'}
|
| 738 |
+
{eyeGazeEnabled && <span style={{ position: 'absolute', top: '-4px', right: '-4px', width: '10px', height: '10px', borderRadius: '50%', backgroundColor: isCalibrated ? '#4ade80' : '#fbbf24', border: '2px solid #1e1e2e' }} title={isCalibrated ? 'Calibrated' : 'Not calibrated'} />}
|
| 739 |
+
</button>
|
| 740 |
+
{eyeGazeEnabled ? <button type="button" className="action-btn" style={{ backgroundColor: isCalibrated ? '#22c55e' : '#8b5cf6' }} onClick={handleCalibrate} disabled={!videoManager?.isStreaming} title="9-point gaze calibration for accurate tracking">{isCalibrated ? 'Re-Calibrate' : 'Calibrate'}</button> : null}
|
| 741 |
+
<button id="btn-floating" className="action-btn yellow" onClick={handlePiP}>Floating Window</button>
|
| 742 |
+
<button id="btn-preview" className="action-btn" style={{ backgroundColor: '#ff7a52' }} onClick={handlePreview}>Preview Result</button>
|
| 743 |
+
<button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>Stop</button>
|
| 744 |
+
</section>
|
| 745 |
+
|
| 746 |
+
{eyeGazeEnabled && videoManager?.isStreaming ? (
|
| 747 |
+
<section style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '14px', padding: '8px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '10px', margin: '6px auto', maxWidth: '400px' }}>
|
| 748 |
+
<div style={{ textAlign: 'center' }}>
|
| 749 |
+
<div style={{ fontSize: '11px', color: '#888', marginBottom: '4px', letterSpacing: '0.5px' }}>GAZE MAP {isCalibrated ? '(calibrated)' : '(raw)'}</div>
|
| 750 |
+
<GazeMiniMap gazeData={gazeData} />
|
| 751 |
+
</div>
|
| 752 |
+
</section>
|
| 753 |
+
) : null}
|
| 754 |
+
|
| 755 |
+
{cameraError ? <div className="focus-inline-error focus-inline-error-standalone">{cameraError}</div> : null}
|
| 756 |
+
|
| 757 |
+
<section id="frame-control">
|
| 758 |
+
<label htmlFor="frame-slider">Frame Rate (FPS)</label>
|
| 759 |
+
<input type="range" id="frame-slider" min="10" max="30" value={currentFrame} onChange={(e) => handleFrameChange(e.target.value)} />
|
| 760 |
+
<input type="number" id="frame-input" min="10" max="30" value={currentFrame} onChange={(e) => handleFrameChange(e.target.value)} />
|
| 761 |
+
</section>
|
| 762 |
+
</>
|
| 763 |
+
) : null}
|
| 764 |
+
</main>
|
| 765 |
+
);
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
export default FocusPageLocal;
|
src/components/Help.jsx
CHANGED
|
@@ -1,90 +1,90 @@
|
|
| 1 |
-
import React from 'react';
|
| 2 |
-
|
| 3 |
-
function Help() {
|
| 4 |
-
return (
|
| 5 |
-
<main id="page-f" className="page">
|
| 6 |
-
<h1 className="page-title">Help</h1>
|
| 7 |
-
|
| 8 |
-
<div className="help-container">
|
| 9 |
-
<section className="help-section">
|
| 10 |
-
<h2>How to Use Focus Guard</h2>
|
| 11 |
-
<ol>
|
| 12 |
-
<li>Navigate to the Focus page from the menu</li>
|
| 13 |
-
<li>Allow camera access when prompted</li>
|
| 14 |
-
<li>Click the green "Start" button to begin monitoring</li>
|
| 15 |
-
<li>Position yourself in front of the camera</li>
|
| 16 |
-
<li>The system will track your focus in real-time using face mesh analysis</li>
|
| 17 |
-
<li>Use the model selector to switch between detection models (MLP, XGBoost, Geometric, Hybrid)</li>
|
| 18 |
-
<li>Click "Stop" when you're done to save the session</li>
|
| 19 |
-
</ol>
|
| 20 |
-
</section>
|
| 21 |
-
|
| 22 |
-
<section className="help-section">
|
| 23 |
-
<h2>What is "Focused"?</h2>
|
| 24 |
-
<p>The system considers you focused when:</p>
|
| 25 |
-
<ul>
|
| 26 |
-
<li>Your face is detected and visible in the camera frame</li>
|
| 27 |
-
<li>Your head is oriented toward the screen (low yaw/pitch deviation)</li>
|
| 28 |
-
<li>Your eyes are open and gaze is directed forward</li>
|
| 29 |
-
<li>You are not yawning</li>
|
| 30 |
-
</ul>
|
| 31 |
-
<p>The system uses MediaPipe Face Mesh to extract 478 facial landmarks, then computes features like head pose, eye aspect ratio (EAR), gaze offset, PERCLOS, and blink rate to determine focus.</p>
|
| 32 |
-
</section>
|
| 33 |
-
|
| 34 |
-
<section className="help-section">
|
| 35 |
-
<h2>Available Models</h2>
|
| 36 |
-
<p><strong>MLP:</strong> Neural network trained on extracted facial features. Good balance of speed and accuracy.</p>
|
| 37 |
-
<p><strong>XGBoost:</strong> Gradient-boosted tree model using 10 selected features. Strong on tabular data with fast inference.</p>
|
| 38 |
-
<p><strong>Geometric:</strong> Rule-based scoring using head pose and eye openness. No ML model needed, lightweight.</p>
|
| 39 |
-
<p><strong>Hybrid:</strong> Combines MLP predictions with geometric scoring for robust results.</p>
|
| 40 |
-
</section>
|
| 41 |
-
|
| 42 |
-
<section className="help-section">
|
| 43 |
-
<h2>Adjusting Settings</h2>
|
| 44 |
-
<p><strong>Frame Rate:</strong> Controls how many frames per second are sent for analysis. Recommended: 15-30 FPS. Minimum is 10 FPS to ensure temporal features (blink rate, PERCLOS) remain accurate.</p>
|
| 45 |
-
<p><strong>Model Selection:</strong> Switch models in real-time using the pill buttons above the timeline. Different models may perform better depending on your lighting and setup.</p>
|
| 46 |
-
</section>
|
| 47 |
-
|
| 48 |
-
<section className="help-section">
|
| 49 |
-
<h2>Privacy & Data</h2>
|
| 50 |
-
<p>Video frames are processed in real-time on the server and are never stored. Only focus status metadata (timestamps, confidence scores) is saved to the session database. Sessions can be viewed in History and exported or cleared at any time.</p>
|
| 51 |
-
</section>
|
| 52 |
-
|
| 53 |
-
<section className="help-section">
|
| 54 |
-
<h2>FAQ</h2>
|
| 55 |
-
<details>
|
| 56 |
-
<summary>Why is my focus score low?</summary>
|
| 57 |
-
<p>Ensure good lighting so the face mesh can detect your landmarks clearly. Face the camera directly and avoid large head movements. Try switching to a different model if one isn't working well for your setup.</p>
|
| 58 |
-
</details>
|
| 59 |
-
<details>
|
| 60 |
-
<summary>Can I use this without a camera?</summary>
|
| 61 |
-
<p>No, camera access is required. The system relies on real-time face landmark detection to determine focus.</p>
|
| 62 |
-
</details>
|
| 63 |
-
<details>
|
| 64 |
-
<summary>Does this work on mobile?</summary>
|
| 65 |
-
<p>Yes, it works on mobile browsers that support camera access and WebSocket connections. Performance depends on your device and network speed.</p>
|
| 66 |
-
</details>
|
| 67 |
-
<details>
|
| 68 |
-
<summary>Is my data private?</summary>
|
| 69 |
-
<p>Yes. No video frames are stored. Processing happens in real-time and only metadata (focus/unfocused status, confidence, timestamps) is saved.</p>
|
| 70 |
-
</details>
|
| 71 |
-
<details>
|
| 72 |
-
<summary>Why does the face mesh lag behind my movements?</summary>
|
| 73 |
-
<p>The face mesh overlay updates each time the server returns a detection result. The camera feed itself renders at 60fps locally. Any visible lag depends on network latency and server processing time.</p>
|
| 74 |
-
</details>
|
| 75 |
-
</section>
|
| 76 |
-
|
| 77 |
-
<section className="help-section">
|
| 78 |
-
<h2>Technical Info</h2>
|
| 79 |
-
<p><strong>Face Detection:</strong> MediaPipe Face Mesh (478 landmarks)</p>
|
| 80 |
-
<p><strong>Feature Extraction:</strong> Head pose (yaw/pitch/roll), EAR, MAR, gaze offset, PERCLOS, blink rate</p>
|
| 81 |
-
<p><strong>ML Models:</strong> MLP (scikit-learn), XGBoost, Geometric, Hybrid</p>
|
| 82 |
-
<p><strong>Storage:</strong> SQLite database</p>
|
| 83 |
-
<p><strong>Framework:</strong> FastAPI + React (Vite) + WebSocket</p>
|
| 84 |
-
</section>
|
| 85 |
-
</div>
|
| 86 |
-
</main>
|
| 87 |
-
);
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
export default Help;
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
function Help() {
|
| 4 |
+
return (
|
| 5 |
+
<main id="page-f" className="page">
|
| 6 |
+
<h1 className="page-title">Help</h1>
|
| 7 |
+
|
| 8 |
+
<div className="help-container">
|
| 9 |
+
<section className="help-section">
|
| 10 |
+
<h2>How to Use Focus Guard</h2>
|
| 11 |
+
<ol>
|
| 12 |
+
<li>Navigate to the Focus page from the menu</li>
|
| 13 |
+
<li>Allow camera access when prompted</li>
|
| 14 |
+
<li>Click the green "Start" button to begin monitoring</li>
|
| 15 |
+
<li>Position yourself in front of the camera</li>
|
| 16 |
+
<li>The system will track your focus in real-time using face mesh analysis</li>
|
| 17 |
+
<li>Use the model selector to switch between detection models (MLP, XGBoost, Geometric, Hybrid)</li>
|
| 18 |
+
<li>Click "Stop" when you're done to save the session</li>
|
| 19 |
+
</ol>
|
| 20 |
+
</section>
|
| 21 |
+
|
| 22 |
+
<section className="help-section">
|
| 23 |
+
<h2>What is "Focused"?</h2>
|
| 24 |
+
<p>The system considers you focused when:</p>
|
| 25 |
+
<ul>
|
| 26 |
+
<li>Your face is detected and visible in the camera frame</li>
|
| 27 |
+
<li>Your head is oriented toward the screen (low yaw/pitch deviation)</li>
|
| 28 |
+
<li>Your eyes are open and gaze is directed forward</li>
|
| 29 |
+
<li>You are not yawning</li>
|
| 30 |
+
</ul>
|
| 31 |
+
<p>The system uses MediaPipe Face Mesh to extract 478 facial landmarks, then computes features like head pose, eye aspect ratio (EAR), gaze offset, PERCLOS, and blink rate to determine focus.</p>
|
| 32 |
+
</section>
|
| 33 |
+
|
| 34 |
+
<section className="help-section">
|
| 35 |
+
<h2>Available Models</h2>
|
| 36 |
+
<p><strong>MLP:</strong> Neural network trained on extracted facial features. Good balance of speed and accuracy.</p>
|
| 37 |
+
<p><strong>XGBoost:</strong> Gradient-boosted tree model using 10 selected features. Strong on tabular data with fast inference.</p>
|
| 38 |
+
<p><strong>Geometric:</strong> Rule-based scoring using head pose and eye openness. No ML model needed, lightweight.</p>
|
| 39 |
+
<p><strong>Hybrid:</strong> Combines MLP predictions with geometric scoring for robust results.</p>
|
| 40 |
+
</section>
|
| 41 |
+
|
| 42 |
+
<section className="help-section">
|
| 43 |
+
<h2>Adjusting Settings</h2>
|
| 44 |
+
<p><strong>Frame Rate:</strong> Controls how many frames per second are sent for analysis. Recommended: 15-30 FPS. Minimum is 10 FPS to ensure temporal features (blink rate, PERCLOS) remain accurate.</p>
|
| 45 |
+
<p><strong>Model Selection:</strong> Switch models in real-time using the pill buttons above the timeline. Different models may perform better depending on your lighting and setup.</p>
|
| 46 |
+
</section>
|
| 47 |
+
|
| 48 |
+
<section className="help-section">
|
| 49 |
+
<h2>Privacy & Data</h2>
|
| 50 |
+
<p>Video frames are processed in real-time on the server and are never stored. Only focus status metadata (timestamps, confidence scores) is saved to the session database. Sessions can be viewed in History and exported or cleared at any time.</p>
|
| 51 |
+
</section>
|
| 52 |
+
|
| 53 |
+
<section className="help-section">
|
| 54 |
+
<h2>FAQ</h2>
|
| 55 |
+
<details>
|
| 56 |
+
<summary>Why is my focus score low?</summary>
|
| 57 |
+
<p>Ensure good lighting so the face mesh can detect your landmarks clearly. Face the camera directly and avoid large head movements. Try switching to a different model if one isn't working well for your setup.</p>
|
| 58 |
+
</details>
|
| 59 |
+
<details>
|
| 60 |
+
<summary>Can I use this without a camera?</summary>
|
| 61 |
+
<p>No, camera access is required. The system relies on real-time face landmark detection to determine focus.</p>
|
| 62 |
+
</details>
|
| 63 |
+
<details>
|
| 64 |
+
<summary>Does this work on mobile?</summary>
|
| 65 |
+
<p>Yes, it works on mobile browsers that support camera access and WebSocket connections. Performance depends on your device and network speed.</p>
|
| 66 |
+
</details>
|
| 67 |
+
<details>
|
| 68 |
+
<summary>Is my data private?</summary>
|
| 69 |
+
<p>Yes. No video frames are stored. Processing happens in real-time and only metadata (focus/unfocused status, confidence, timestamps) is saved.</p>
|
| 70 |
+
</details>
|
| 71 |
+
<details>
|
| 72 |
+
<summary>Why does the face mesh lag behind my movements?</summary>
|
| 73 |
+
<p>The face mesh overlay updates each time the server returns a detection result. The camera feed itself renders at 60fps locally. Any visible lag depends on network latency and server processing time.</p>
|
| 74 |
+
</details>
|
| 75 |
+
</section>
|
| 76 |
+
|
| 77 |
+
<section className="help-section">
|
| 78 |
+
<h2>Technical Info</h2>
|
| 79 |
+
<p><strong>Face Detection:</strong> MediaPipe Face Mesh (478 landmarks)</p>
|
| 80 |
+
<p><strong>Feature Extraction:</strong> Head pose (yaw/pitch/roll), EAR, MAR, gaze offset, PERCLOS, blink rate</p>
|
| 81 |
+
<p><strong>ML Models:</strong> MLP (scikit-learn), XGBoost, Geometric, Hybrid</p>
|
| 82 |
+
<p><strong>Storage:</strong> SQLite database</p>
|
| 83 |
+
<p><strong>Framework:</strong> FastAPI + React (Vite) + WebSocket</p>
|
| 84 |
+
</section>
|
| 85 |
+
</div>
|
| 86 |
+
</main>
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export default Help;
|
src/components/Home.jsx
CHANGED
|
@@ -1,87 +1,34 @@
|
|
| 1 |
-
import React
|
| 2 |
-
|
| 3 |
-
function Home({ setActiveTab }) {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
// 3. Import a backup file manually.
|
| 37 |
-
const handleFileChange = async (event) => {
|
| 38 |
-
const file = event.target.files[0];
|
| 39 |
-
if (!file) return;
|
| 40 |
-
const reader = new FileReader();
|
| 41 |
-
reader.onload = async (e) => {
|
| 42 |
-
try {
|
| 43 |
-
const sessions = JSON.parse(e.target.result);
|
| 44 |
-
const response = await fetch('/api/import', {
|
| 45 |
-
method: 'POST',
|
| 46 |
-
headers: { 'Content-Type': 'application/json' },
|
| 47 |
-
body: JSON.stringify(sessions)
|
| 48 |
-
});
|
| 49 |
-
if (response.ok) {
|
| 50 |
-
alert("Import successful!");
|
| 51 |
-
}
|
| 52 |
-
} catch (err) {
|
| 53 |
-
alert("Error: " + err.message);
|
| 54 |
-
}
|
| 55 |
-
event.target.value = '';
|
| 56 |
-
};
|
| 57 |
-
reader.readAsText(file);
|
| 58 |
-
};
|
| 59 |
-
|
| 60 |
-
return (
|
| 61 |
-
<main id="page-a" className="page">
|
| 62 |
-
<h1>FocusGuard</h1>
|
| 63 |
-
<p>Your productivity monitor assistant.</p>
|
| 64 |
-
|
| 65 |
-
{/* Keep the hidden file input outside the button grid so it never affects layout. */}
|
| 66 |
-
<input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".json" onChange={handleFileChange} />
|
| 67 |
-
|
| 68 |
-
{/* Render the four main actions inside a clean 2x2 grid. */}
|
| 69 |
-
<div className="home-button-grid">
|
| 70 |
-
|
| 71 |
-
<button className="btn-main" onClick={handleNewStart}>
|
| 72 |
-
Start Focus
|
| 73 |
-
</button>
|
| 74 |
-
|
| 75 |
-
<button className="btn-main" onClick={handleAutoImport}>
|
| 76 |
-
Auto Import History
|
| 77 |
-
</button>
|
| 78 |
-
|
| 79 |
-
<button className="btn-main" onClick={() => fileInputRef.current.click()}>
|
| 80 |
-
Manual Import History
|
| 81 |
-
</button>
|
| 82 |
-
</div>
|
| 83 |
-
</main>
|
| 84 |
-
);
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
export default Home;
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
function Home({ setActiveTab, setIsTutorialActive }) {
|
| 4 |
+
|
| 5 |
+
const handleStartFocus = () => {
|
| 6 |
+
setActiveTab('focus');
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
const handleStartTutorial = () => {
|
| 10 |
+
setIsTutorialActive(true);
|
| 11 |
+
setActiveTab('focus');
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<main id="page-a" className="page">
|
| 16 |
+
<h1>FocusGuard</h1>
|
| 17 |
+
<p>Your productivity monitor assistant.</p>
|
| 18 |
+
|
| 19 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', alignItems: 'center', marginTop: '30px' }}>
|
| 20 |
+
|
| 21 |
+
<button className="btn-main" onClick={handleStartFocus} style={{ width: '250px' }}>
|
| 22 |
+
Start Focus
|
| 23 |
+
</button>
|
| 24 |
+
|
| 25 |
+
<button className="btn-main" onClick={handleStartTutorial} style={{ width: '250px' }}>
|
| 26 |
+
Tutorial
|
| 27 |
+
</button>
|
| 28 |
+
|
| 29 |
+
</div>
|
| 30 |
+
</main>
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export default Home;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/Records.jsx
CHANGED
|
@@ -1,645 +1,665 @@
|
|
| 1 |
-
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
-
|
| 3 |
-
function Records() {
|
| 4 |
-
const [filter, setFilter] = useState('all');
|
| 5 |
-
const [sessions, setSessions] = useState([]);
|
| 6 |
-
const [loading, setLoading] = useState(false);
|
| 7 |
-
const [detailState, setDetailState] = useState({
|
| 8 |
-
open: false,
|
| 9 |
-
loading: false,
|
| 10 |
-
error: '',
|
| 11 |
-
session: null
|
| 12 |
-
});
|
| 13 |
-
const chartRef = useRef(null);
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
const
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
return
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
const
|
| 72 |
-
const
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
?
|
| 106 |
-
:
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
const
|
| 117 |
-
const
|
| 118 |
-
const
|
| 119 |
-
|
| 120 |
-
const
|
| 121 |
-
|
| 122 |
-
const
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
const
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
if (score >= 0.
|
| 148 |
-
return '
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
const
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
ctx.
|
| 191 |
-
ctx.
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
const
|
| 199 |
-
const
|
| 200 |
-
const
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
ctx.
|
| 209 |
-
ctx.
|
| 210 |
-
ctx.
|
| 211 |
-
ctx.
|
| 212 |
-
ctx.
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
ctx.
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
ctx.
|
| 227 |
-
ctx.
|
| 228 |
-
ctx.
|
| 229 |
-
ctx.
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
const
|
| 237 |
-
const
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
else color = '#
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
ctx.
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
ctx.
|
| 260 |
-
ctx.
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
<
|
| 419 |
-
{
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
<
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
<article
|
| 529 |
-
|
| 530 |
-
<
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
<
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
</span>
|
| 558 |
-
</div>
|
| 559 |
-
<div className="records-detail-item">
|
| 560 |
-
<span className="records-detail-item-label">
|
| 561 |
-
<span className="records-detail-item-value">
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
<
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
</div>
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
<
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
</
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
}
|
| 644 |
-
|
| 645 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
|
| 3 |
+
function Records() {
|
| 4 |
+
const [filter, setFilter] = useState('all');
|
| 5 |
+
const [sessions, setSessions] = useState([]);
|
| 6 |
+
const [loading, setLoading] = useState(false);
|
| 7 |
+
const [detailState, setDetailState] = useState({
|
| 8 |
+
open: false,
|
| 9 |
+
loading: false,
|
| 10 |
+
error: '',
|
| 11 |
+
session: null
|
| 12 |
+
});
|
| 13 |
+
const chartRef = useRef(null);
|
| 14 |
+
|
| 15 |
+
const fileInputRef = useRef(null);
|
| 16 |
+
|
| 17 |
+
// Format a session duration.
|
| 18 |
+
const formatDuration = (seconds) => {
|
| 19 |
+
const safeSeconds = Math.max(0, Number(seconds) || 0);
|
| 20 |
+
const mins = Math.floor(safeSeconds / 60);
|
| 21 |
+
const secs = safeSeconds % 60;
|
| 22 |
+
return `${mins}m ${secs}s`;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
// Format a session timestamp for table display.
|
| 26 |
+
const formatDate = (dateString) => {
|
| 27 |
+
const date = new Date(dateString);
|
| 28 |
+
return date.toLocaleDateString('en-US', {
|
| 29 |
+
month: 'short',
|
| 30 |
+
day: 'numeric',
|
| 31 |
+
hour: '2-digit',
|
| 32 |
+
minute: '2-digit'
|
| 33 |
+
});
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const formatDateTime = (dateString) => {
|
| 37 |
+
if (!dateString) return 'Not available';
|
| 38 |
+
const date = new Date(dateString);
|
| 39 |
+
return date.toLocaleString('en-US', {
|
| 40 |
+
month: 'short',
|
| 41 |
+
day: 'numeric',
|
| 42 |
+
year: 'numeric',
|
| 43 |
+
hour: '2-digit',
|
| 44 |
+
minute: '2-digit'
|
| 45 |
+
});
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const parseMetadata = (detectionData) => {
|
| 49 |
+
if (!detectionData) return {};
|
| 50 |
+
if (typeof detectionData === 'object') return detectionData;
|
| 51 |
+
try {
|
| 52 |
+
return JSON.parse(detectionData);
|
| 53 |
+
} catch (_) {
|
| 54 |
+
return {};
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const averageOf = (values) => {
|
| 59 |
+
const valid = values.filter((value) => Number.isFinite(value));
|
| 60 |
+
if (valid.length === 0) return null;
|
| 61 |
+
return valid.reduce((sum, value) => sum + value, 0) / valid.length;
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const buildTimelineSegments = (events, maxSegments = 48) => {
|
| 65 |
+
if (!events.length) return [];
|
| 66 |
+
|
| 67 |
+
const segmentSize = Math.ceil(events.length / maxSegments);
|
| 68 |
+
const segments = [];
|
| 69 |
+
|
| 70 |
+
for (let i = 0; i < events.length; i += segmentSize) {
|
| 71 |
+
const slice = events.slice(i, i + segmentSize);
|
| 72 |
+
const focusedCount = slice.filter((event) => event.isFocused).length;
|
| 73 |
+
const focusRatio = focusedCount / slice.length;
|
| 74 |
+
const confidence = averageOf(slice.map((event) => event.confidence));
|
| 75 |
+
|
| 76 |
+
let tone = 'distracted';
|
| 77 |
+
if (focusRatio >= 0.75) tone = 'focused';
|
| 78 |
+
else if (focusRatio >= 0.35) tone = 'mixed';
|
| 79 |
+
|
| 80 |
+
segments.push({
|
| 81 |
+
tone,
|
| 82 |
+
focusRatio,
|
| 83 |
+
confidence,
|
| 84 |
+
count: slice.length
|
| 85 |
+
});
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return segments;
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
const buildDetailView = (session) => {
|
| 92 |
+
if (!session) return null;
|
| 93 |
+
|
| 94 |
+
const parsedEvents = (session.events || []).map((event) => {
|
| 95 |
+
const metadata = parseMetadata(event.detection_data);
|
| 96 |
+
return {
|
| 97 |
+
...event,
|
| 98 |
+
metadata,
|
| 99 |
+
isFocused: Boolean(event.is_focused),
|
| 100 |
+
confidence: Number(event.confidence) || 0
|
| 101 |
+
};
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
const focusRatio = session.total_frames
|
| 105 |
+
? session.focused_frames / session.total_frames
|
| 106 |
+
: parsedEvents.length
|
| 107 |
+
? parsedEvents.filter((event) => event.isFocused).length / parsedEvents.length
|
| 108 |
+
: 0;
|
| 109 |
+
|
| 110 |
+
const modelCounts = parsedEvents.reduce((counts, event) => {
|
| 111 |
+
const model = event.metadata?.model;
|
| 112 |
+
if (model) counts[model] = (counts[model] || 0) + 1;
|
| 113 |
+
return counts;
|
| 114 |
+
}, {});
|
| 115 |
+
|
| 116 |
+
const dominantModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'Unavailable';
|
| 117 |
+
const avgConfidence = averageOf(parsedEvents.map((event) => event.confidence));
|
| 118 |
+
const avgFaceScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_face)));
|
| 119 |
+
const avgEyeScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_eye)));
|
| 120 |
+
const avgMar = averageOf(parsedEvents.map((event) => Number(event.metadata?.mar)));
|
| 121 |
+
|
| 122 |
+
const startTime = session.start_time ? new Date(session.start_time) : null;
|
| 123 |
+
const timeline = buildTimelineSegments(parsedEvents);
|
| 124 |
+
const recentEvents = parsedEvents.slice(-10).reverse();
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
parsedEvents,
|
| 128 |
+
focusRatio,
|
| 129 |
+
dominantModel,
|
| 130 |
+
avgConfidence,
|
| 131 |
+
avgFaceScore,
|
| 132 |
+
avgEyeScore,
|
| 133 |
+
avgMar,
|
| 134 |
+
timeline,
|
| 135 |
+
recentEvents,
|
| 136 |
+
formatOffset(timestamp) {
|
| 137 |
+
if (!startTime || !timestamp) return '--';
|
| 138 |
+
const offsetSeconds = Math.max(0, Math.round((new Date(timestamp) - startTime) / 1000));
|
| 139 |
+
const mins = Math.floor(offsetSeconds / 60);
|
| 140 |
+
const secs = offsetSeconds % 60;
|
| 141 |
+
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
| 142 |
+
}
|
| 143 |
+
};
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
const getScoreTone = (score) => {
|
| 147 |
+
if (score >= 0.8) return 'excellent';
|
| 148 |
+
if (score >= 0.6) return 'good';
|
| 149 |
+
if (score >= 0.4) return 'fair';
|
| 150 |
+
return 'low';
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
const closeDetails = () => {
|
| 154 |
+
setDetailState({
|
| 155 |
+
open: false,
|
| 156 |
+
loading: false,
|
| 157 |
+
error: '',
|
| 158 |
+
session: null
|
| 159 |
+
});
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
// Load session rows for the selected filter.
|
| 163 |
+
const loadSessions = async (filterType) => {
|
| 164 |
+
setLoading(true);
|
| 165 |
+
try {
|
| 166 |
+
const response = await fetch(`/api/sessions?filter=${filterType}&limit=50`);
|
| 167 |
+
const data = await response.json();
|
| 168 |
+
setSessions(data);
|
| 169 |
+
drawChart(data);
|
| 170 |
+
} catch (error) {
|
| 171 |
+
console.error('Failed to load sessions:', error);
|
| 172 |
+
} finally {
|
| 173 |
+
setLoading(false);
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
// Draw the session score chart.
|
| 178 |
+
const drawChart = (data) => {
|
| 179 |
+
const canvas = chartRef.current;
|
| 180 |
+
if (!canvas) return;
|
| 181 |
+
|
| 182 |
+
const ctx = canvas.getContext('2d');
|
| 183 |
+
const width = canvas.width = canvas.offsetWidth;
|
| 184 |
+
const height = canvas.height = 300;
|
| 185 |
+
|
| 186 |
+
// Clear the canvas before each redraw.
|
| 187 |
+
ctx.clearRect(0, 0, width, height);
|
| 188 |
+
|
| 189 |
+
if (data.length === 0) {
|
| 190 |
+
ctx.fillStyle = '#999';
|
| 191 |
+
ctx.font = '16px Nunito';
|
| 192 |
+
ctx.textAlign = 'center';
|
| 193 |
+
ctx.fillText('No data available', width / 2, height / 2);
|
| 194 |
+
return;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// Use at most the latest 20 sessions in the chart.
|
| 198 |
+
const displayData = data.slice(0, 20).reverse();
|
| 199 |
+
const padding = 50;
|
| 200 |
+
const chartWidth = width - padding * 2;
|
| 201 |
+
const chartHeight = height - padding * 2;
|
| 202 |
+
const barWidth = chartWidth / displayData.length;
|
| 203 |
+
|
| 204 |
+
// Use a normalized max score for chart scaling.
|
| 205 |
+
const maxScore = 1.0;
|
| 206 |
+
|
| 207 |
+
// Draw the chart axes.
|
| 208 |
+
ctx.strokeStyle = '#E0E0E0';
|
| 209 |
+
ctx.lineWidth = 2;
|
| 210 |
+
ctx.beginPath();
|
| 211 |
+
ctx.moveTo(padding, padding);
|
| 212 |
+
ctx.lineTo(padding, height - padding);
|
| 213 |
+
ctx.lineTo(width - padding, height - padding);
|
| 214 |
+
ctx.stroke();
|
| 215 |
+
|
| 216 |
+
// Draw Y-axis labels.
|
| 217 |
+
ctx.fillStyle = '#666';
|
| 218 |
+
ctx.font = '12px Nunito';
|
| 219 |
+
ctx.textAlign = 'right';
|
| 220 |
+
for (let i = 0; i <= 4; i++) {
|
| 221 |
+
const y = height - padding - (chartHeight * i / 4);
|
| 222 |
+
const value = (maxScore * i / 4 * 100).toFixed(0);
|
| 223 |
+
ctx.fillText(value + '%', padding - 10, y + 4);
|
| 224 |
+
|
| 225 |
+
// Draw horizontal grid lines.
|
| 226 |
+
ctx.strokeStyle = '#F0F0F0';
|
| 227 |
+
ctx.lineWidth = 1;
|
| 228 |
+
ctx.beginPath();
|
| 229 |
+
ctx.moveTo(padding, y);
|
| 230 |
+
ctx.lineTo(width - padding, y);
|
| 231 |
+
ctx.stroke();
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// Draw the bar chart.
|
| 235 |
+
displayData.forEach((session, index) => {
|
| 236 |
+
const barHeight = (session.focus_score / maxScore) * chartHeight;
|
| 237 |
+
const x = padding + index * barWidth + barWidth * 0.1;
|
| 238 |
+
const y = height - padding - barHeight;
|
| 239 |
+
const barActualWidth = barWidth * 0.8;
|
| 240 |
+
|
| 241 |
+
// Map each score to a blue-toned color band.
|
| 242 |
+
const score = session.focus_score;
|
| 243 |
+
let color;
|
| 244 |
+
if (score >= 0.8) color = '#4A90E2';
|
| 245 |
+
else if (score >= 0.6) color = '#5DADE2';
|
| 246 |
+
else if (score >= 0.4) color = '#85C1E9';
|
| 247 |
+
else color = '#AED6F1';
|
| 248 |
+
|
| 249 |
+
ctx.fillStyle = color;
|
| 250 |
+
ctx.fillRect(x, y, barActualWidth, barHeight);
|
| 251 |
+
|
| 252 |
+
// Draw a matching outline around each bar.
|
| 253 |
+
ctx.strokeStyle = color;
|
| 254 |
+
ctx.lineWidth = 1;
|
| 255 |
+
ctx.strokeRect(x, y, barActualWidth, barHeight);
|
| 256 |
+
});
|
| 257 |
+
|
| 258 |
+
// Draw the chart title.
|
| 259 |
+
ctx.textAlign = 'left';
|
| 260 |
+
ctx.font = 'bold 14px Nunito';
|
| 261 |
+
ctx.fillStyle = '#4A90E2';
|
| 262 |
+
ctx.fillText('Focus Score by Session', padding, 30);
|
| 263 |
+
};
|
| 264 |
+
|
| 265 |
+
// Initial load.
|
| 266 |
+
useEffect(() => {
|
| 267 |
+
loadSessions(filter);
|
| 268 |
+
}, [filter]);
|
| 269 |
+
|
| 270 |
+
useEffect(() => {
|
| 271 |
+
if (!detailState.open) return undefined;
|
| 272 |
+
|
| 273 |
+
const previousOverflow = document.body.style.overflow;
|
| 274 |
+
document.body.style.overflow = 'hidden';
|
| 275 |
+
|
| 276 |
+
const handleKeyDown = (event) => {
|
| 277 |
+
if (event.key === 'Escape') {
|
| 278 |
+
closeDetails();
|
| 279 |
+
}
|
| 280 |
+
};
|
| 281 |
+
|
| 282 |
+
window.addEventListener('keydown', handleKeyDown);
|
| 283 |
+
|
| 284 |
+
return () => {
|
| 285 |
+
document.body.style.overflow = previousOverflow;
|
| 286 |
+
window.removeEventListener('keydown', handleKeyDown);
|
| 287 |
+
};
|
| 288 |
+
}, [detailState.open]);
|
| 289 |
+
|
| 290 |
+
// Filter button handler.
|
| 291 |
+
const handleFilterClick = (filterType) => {
|
| 292 |
+
setFilter(filterType);
|
| 293 |
+
};
|
| 294 |
+
|
| 295 |
+
// Open the detail modal for one session.
|
| 296 |
+
const handleViewDetails = async (sessionId) => {
|
| 297 |
+
setDetailState({
|
| 298 |
+
open: true,
|
| 299 |
+
loading: true,
|
| 300 |
+
error: '',
|
| 301 |
+
session: null
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
try {
|
| 305 |
+
const response = await fetch(`/api/sessions/${sessionId}`);
|
| 306 |
+
if (!response.ok) {
|
| 307 |
+
throw new Error('Failed to load session details.');
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
const data = await response.json();
|
| 311 |
+
setDetailState({
|
| 312 |
+
open: true,
|
| 313 |
+
loading: false,
|
| 314 |
+
error: '',
|
| 315 |
+
session: data
|
| 316 |
+
});
|
| 317 |
+
} catch (error) {
|
| 318 |
+
setDetailState({
|
| 319 |
+
open: true,
|
| 320 |
+
loading: false,
|
| 321 |
+
error: error.message || 'Failed to load session details.',
|
| 322 |
+
session: null
|
| 323 |
+
});
|
| 324 |
+
}
|
| 325 |
+
};
|
| 326 |
+
|
| 327 |
+
const handleExport = async () => {
|
| 328 |
+
try {
|
| 329 |
+
const response = await fetch('/api/sessions?filter=all');
|
| 330 |
+
if (!response.ok) throw new Error("Failed to fetch data");
|
| 331 |
+
|
| 332 |
+
const data = await response.json();
|
| 333 |
+
const jsonString = JSON.stringify(data, null, 2);
|
| 334 |
+
localStorage.setItem('focus_magic_backup', jsonString);
|
| 335 |
+
|
| 336 |
+
const blob = new Blob([jsonString], { type: 'application/json' });
|
| 337 |
+
const url = URL.createObjectURL(blob);
|
| 338 |
+
const link = document.createElement('a');
|
| 339 |
+
link.href = url;
|
| 340 |
+
link.download = `focus-guard-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
| 341 |
+
|
| 342 |
+
document.body.appendChild(link);
|
| 343 |
+
link.click();
|
| 344 |
+
|
| 345 |
+
document.body.removeChild(link);
|
| 346 |
+
URL.revokeObjectURL(url);
|
| 347 |
+
} catch (error) {
|
| 348 |
+
console.error(error);
|
| 349 |
+
alert("Export failed: " + error.message);
|
| 350 |
+
}
|
| 351 |
+
};
|
| 352 |
+
|
| 353 |
+
const triggerImport = () => {
|
| 354 |
+
fileInputRef.current.click();
|
| 355 |
+
};
|
| 356 |
+
|
| 357 |
+
const handleFileChange = async (event) => {
|
| 358 |
+
const file = event.target.files[0];
|
| 359 |
+
if (!file) return;
|
| 360 |
+
|
| 361 |
+
const reader = new FileReader();
|
| 362 |
+
reader.onload = async (e) => {
|
| 363 |
+
try {
|
| 364 |
+
const content = e.target.result;
|
| 365 |
+
const sessions = JSON.parse(content);
|
| 366 |
+
|
| 367 |
+
if (!Array.isArray(sessions)) {
|
| 368 |
+
throw new Error("Invalid file format: Expected a list of sessions.");
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
const response = await fetch('/api/import', {
|
| 372 |
+
method: 'POST',
|
| 373 |
+
headers: { 'Content-Type': 'application/json' },
|
| 374 |
+
body: JSON.stringify(sessions)
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
if (response.ok) {
|
| 378 |
+
const result = await response.json();
|
| 379 |
+
alert(`Success! Imported ${result.count} sessions.`);
|
| 380 |
+
loadSessions(filter);
|
| 381 |
+
} else {
|
| 382 |
+
alert("Import failed on server side.");
|
| 383 |
+
}
|
| 384 |
+
} catch (err) {
|
| 385 |
+
alert("Error parsing file: " + err.message);
|
| 386 |
+
}
|
| 387 |
+
event.target.value = '';
|
| 388 |
+
};
|
| 389 |
+
reader.readAsText(file);
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
const handleClearHistory = async () => {
|
| 393 |
+
if (!window.confirm("Are you sure? This will delete ALL your session history permanently.")) {
|
| 394 |
+
return;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
try {
|
| 398 |
+
const response = await fetch('/api/history', { method: 'DELETE' });
|
| 399 |
+
if (response.ok) {
|
| 400 |
+
alert("All history has been cleared.");
|
| 401 |
+
loadSessions(filter);
|
| 402 |
+
} else {
|
| 403 |
+
alert("Failed to clear history.");
|
| 404 |
+
}
|
| 405 |
+
} catch (err) {
|
| 406 |
+
alert("Error: " + err.message);
|
| 407 |
+
}
|
| 408 |
+
};
|
| 409 |
+
|
| 410 |
+
const detailView = buildDetailView(detailState.session);
|
| 411 |
+
|
| 412 |
+
return (
|
| 413 |
+
<main id="page-d" className="page">
|
| 414 |
+
<h1 className="page-title">My Records</h1>
|
| 415 |
+
|
| 416 |
+
<div className="records-controls" style={{ display: 'flex', justifyContent: 'center', gap: '10px', marginBottom: '30px' }}>
|
| 417 |
+
<button id="filter-today" onClick={() => handleFilterClick('today')} style={{ padding: '10px 30px', borderRadius: '8px', border: filter === 'today' ? 'none' : '2px solid #4A90E2', background: filter === 'today' ? '#4A90E2' : 'transparent', color: filter === 'today' ? 'white' : '#4A90E2', fontSize: '14px', fontWeight: '500', cursor: 'pointer', transition: 'all 0.3s' }}>Today</button>
|
| 418 |
+
<button id="filter-week" onClick={() => handleFilterClick('week')} style={{ padding: '10px 30px', borderRadius: '8px', border: filter === 'week' ? 'none' : '2px solid #4A90E2', background: filter === 'week' ? '#4A90E2' : 'transparent', color: filter === 'week' ? 'white' : '#4A90E2', fontSize: '14px', fontWeight: '500', cursor: 'pointer', transition: 'all 0.3s' }}>This Week</button>
|
| 419 |
+
<button id="filter-month" onClick={() => handleFilterClick('month')} style={{ padding: '10px 30px', borderRadius: '8px', border: filter === 'month' ? 'none' : '2px solid #4A90E2', background: filter === 'month' ? '#4A90E2' : 'transparent', color: filter === 'month' ? 'white' : '#4A90E2', fontSize: '14px', fontWeight: '500', cursor: 'pointer', transition: 'all 0.3s' }}>This Month</button>
|
| 420 |
+
<button id="filter-all" onClick={() => handleFilterClick('all')} style={{ padding: '10px 30px', borderRadius: '8px', border: filter === 'all' ? 'none' : '2px solid #4A90E2', background: filter === 'all' ? '#4A90E2' : 'transparent', color: filter === 'all' ? 'white' : '#4A90E2', fontSize: '14px', fontWeight: '500', cursor: 'pointer', transition: 'all 0.3s' }}>All Time</button>
|
| 421 |
+
</div>
|
| 422 |
+
|
| 423 |
+
<div className="chart-container" style={{ background: 'white', padding: '20px', borderRadius: '10px', marginBottom: '30px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
| 424 |
+
<canvas ref={chartRef} id="focus-chart" style={{ width: '100%', height: '300px' }}></canvas>
|
| 425 |
+
</div>
|
| 426 |
+
|
| 427 |
+
<div className="sessions-list" style={{ background: 'white', padding: '20px', borderRadius: '10px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', marginBottom: '30px' }}>
|
| 428 |
+
<h2 style={{ color: '#333', marginBottom: '20px', fontSize: '18px', fontWeight: '600' }}>Recent Sessions</h2>
|
| 429 |
+
{loading ? (
|
| 430 |
+
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>Loading sessions...</div>
|
| 431 |
+
) : sessions.length === 0 ? (
|
| 432 |
+
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>No sessions found for this period.</div>
|
| 433 |
+
) : (
|
| 434 |
+
<table id="sessions-table" style={{ width: '100%', borderCollapse: 'collapse', borderRadius: '10px', overflow: 'hidden' }}>
|
| 435 |
+
<thead>
|
| 436 |
+
<tr style={{ background: '#4A90E2' }}>
|
| 437 |
+
<th style={{ padding: '15px', textAlign: 'left', color: 'white', fontWeight: '600', fontSize: '14px' }}>Date</th>
|
| 438 |
+
<th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Duration</th>
|
| 439 |
+
<th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Focus Score</th>
|
| 440 |
+
<th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Action</th>
|
| 441 |
+
</tr>
|
| 442 |
+
</thead>
|
| 443 |
+
<tbody id="sessions-tbody">
|
| 444 |
+
{sessions.map((session, index) => (
|
| 445 |
+
<tr key={session.id} style={{ background: index % 2 === 0 ? '#f8f9fa' : 'white', borderBottom: '1px solid #e9ecef' }}>
|
| 446 |
+
<td style={{ padding: '15px', color: '#333', fontSize: '13px' }}>{formatDate(session.start_time)}</td>
|
| 447 |
+
<td style={{ padding: '15px', textAlign: 'center', color: '#333', fontSize: '13px' }}>{formatDuration(session.duration_seconds)}</td>
|
| 448 |
+
<td style={{ padding: '15px', textAlign: 'center' }}>
|
| 449 |
+
<span style={{ color: session.focus_score >= 0.8 ? '#28a745' : session.focus_score >= 0.6 ? '#ffc107' : session.focus_score >= 0.4 ? '#fd7e14' : '#dc3545', fontWeight: '600', fontSize: '13px' }}>
|
| 450 |
+
{(session.focus_score * 100).toFixed(1)}%
|
| 451 |
+
</span>
|
| 452 |
+
</td>
|
| 453 |
+
<td style={{ padding: '15px', textAlign: 'center' }}>
|
| 454 |
+
<button onClick={() => handleViewDetails(session.id)} className="btn-view">View</button>
|
| 455 |
+
</td>
|
| 456 |
+
</tr>
|
| 457 |
+
))}
|
| 458 |
+
</tbody>
|
| 459 |
+
</table>
|
| 460 |
+
)}
|
| 461 |
+
</div>
|
| 462 |
+
|
| 463 |
+
<div className="data-management-section" style={{
|
| 464 |
+
background: 'white',
|
| 465 |
+
padding: '25px',
|
| 466 |
+
borderRadius: '10px',
|
| 467 |
+
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
| 468 |
+
width: '80%',
|
| 469 |
+
margin: '0 auto',
|
| 470 |
+
textAlign: 'center'
|
| 471 |
+
}}>
|
| 472 |
+
<h2 style={{ color: '#333', marginBottom: '10px', fontSize: '18px', fontWeight: '600', textAlign: 'left' }}>Data Management</h2>
|
| 473 |
+
<p style={{ color: '#666', fontSize: '14px', marginBottom: '20px', textAlign: 'left' }}>
|
| 474 |
+
Export your focus history to a file, import previously saved history, or permanently clear all data.
|
| 475 |
+
</p>
|
| 476 |
+
|
| 477 |
+
<input
|
| 478 |
+
type="file"
|
| 479 |
+
ref={fileInputRef}
|
| 480 |
+
style={{ display: 'none' }}
|
| 481 |
+
accept=".json"
|
| 482 |
+
onChange={handleFileChange}
|
| 483 |
+
/>
|
| 484 |
+
|
| 485 |
+
<div style={{ display: 'flex', gap: '15px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
| 486 |
+
<button className="action-btn blue" onClick={handleExport} style={{ width: '30%', minWidth: '140px', padding: '12px', borderRadius: '8px' }}>
|
| 487 |
+
Export Data
|
| 488 |
+
</button>
|
| 489 |
+
<button className="action-btn yellow" onClick={triggerImport} style={{ width: '30%', minWidth: '140px', padding: '12px', borderRadius: '8px', color: '#333' }}>
|
| 490 |
+
Import Data
|
| 491 |
+
</button>
|
| 492 |
+
<button className="action-btn red" onClick={handleClearHistory} style={{ width: '30%', minWidth: '140px', padding: '12px', borderRadius: '8px' }}>
|
| 493 |
+
Clear History
|
| 494 |
+
</button>
|
| 495 |
+
</div>
|
| 496 |
+
</div>
|
| 497 |
+
|
| 498 |
+
{detailState.open ? (
|
| 499 |
+
<div className="modal-overlay" onClick={closeDetails}>
|
| 500 |
+
<div className="modal-content records-detail-modal" onClick={(event) => event.stopPropagation()}>
|
| 501 |
+
<div className="records-detail-header">
|
| 502 |
+
<div>
|
| 503 |
+
<div className="records-detail-kicker">Session Detail</div>
|
| 504 |
+
<h2>
|
| 505 |
+
{detailState.session ? formatDateTime(detailState.session.start_time) : 'Loading session'}
|
| 506 |
+
</h2>
|
| 507 |
+
<p className="records-detail-subtitle">
|
| 508 |
+
Review score, capture quality, and a condensed event timeline for this session.
|
| 509 |
+
</p>
|
| 510 |
+
</div>
|
| 511 |
+
<button type="button" className="records-detail-close" onClick={closeDetails}>
|
| 512 |
+
Close
|
| 513 |
+
</button>
|
| 514 |
+
</div>
|
| 515 |
+
|
| 516 |
+
{detailState.loading ? (
|
| 517 |
+
<div className="records-detail-feedback">Loading session details...</div>
|
| 518 |
+
) : detailState.error ? (
|
| 519 |
+
<div className="records-detail-feedback records-detail-feedback-error">{detailState.error}</div>
|
| 520 |
+
) : detailState.session && detailView ? (
|
| 521 |
+
<>
|
| 522 |
+
<section className="records-detail-summary">
|
| 523 |
+
<article className={`records-detail-stat ${getScoreTone(detailState.session.focus_score)}`}>
|
| 524 |
+
<span className="records-detail-stat-label">Focus Score</span>
|
| 525 |
+
<strong className="records-detail-stat-value">
|
| 526 |
+
{(detailState.session.focus_score * 100).toFixed(1)}%
|
| 527 |
+
</strong>
|
| 528 |
+
</article>
|
| 529 |
+
<article className="records-detail-stat">
|
| 530 |
+
<span className="records-detail-stat-label">Duration</span>
|
| 531 |
+
<strong className="records-detail-stat-value">
|
| 532 |
+
{formatDuration(detailState.session.duration_seconds)}
|
| 533 |
+
</strong>
|
| 534 |
+
</article>
|
| 535 |
+
<article className="records-detail-stat">
|
| 536 |
+
<span className="records-detail-stat-label">Frames Analysed</span>
|
| 537 |
+
<strong className="records-detail-stat-value">{detailState.session.total_frames}</strong>
|
| 538 |
+
</article>
|
| 539 |
+
<article className="records-detail-stat">
|
| 540 |
+
<span className="records-detail-stat-label">Focused Frames</span>
|
| 541 |
+
<strong className="records-detail-stat-value">
|
| 542 |
+
{(detailView.focusRatio * 100).toFixed(1)}%
|
| 543 |
+
</strong>
|
| 544 |
+
</article>
|
| 545 |
+
</section>
|
| 546 |
+
|
| 547 |
+
<section className="records-detail-grid">
|
| 548 |
+
<article className="records-detail-card">
|
| 549 |
+
<h3>Session Info</h3>
|
| 550 |
+
<div className="records-detail-list">
|
| 551 |
+
<div className="records-detail-item">
|
| 552 |
+
<span className="records-detail-item-label">Started</span>
|
| 553 |
+
<span className="records-detail-item-value">{formatDateTime(detailState.session.start_time)}</span>
|
| 554 |
+
</div>
|
| 555 |
+
<div className="records-detail-item">
|
| 556 |
+
<span className="records-detail-item-label">Ended</span>
|
| 557 |
+
<span className="records-detail-item-value">{formatDateTime(detailState.session.end_time)}</span>
|
| 558 |
+
</div>
|
| 559 |
+
<div className="records-detail-item">
|
| 560 |
+
<span className="records-detail-item-label">Dominant Model</span>
|
| 561 |
+
<span className="records-detail-item-value">{detailView.dominantModel}</span>
|
| 562 |
+
</div>
|
| 563 |
+
<div className="records-detail-item">
|
| 564 |
+
<span className="records-detail-item-label">Event Samples</span>
|
| 565 |
+
<span className="records-detail-item-value">{detailView.parsedEvents.length}</span>
|
| 566 |
+
</div>
|
| 567 |
+
</div>
|
| 568 |
+
</article>
|
| 569 |
+
|
| 570 |
+
<article className="records-detail-card">
|
| 571 |
+
<h3>Signal Quality</h3>
|
| 572 |
+
<div className="records-detail-list">
|
| 573 |
+
<div className="records-detail-item">
|
| 574 |
+
<span className="records-detail-item-label">Avg Confidence</span>
|
| 575 |
+
<span className="records-detail-item-value">
|
| 576 |
+
{detailView.avgConfidence !== null ? `${(detailView.avgConfidence * 100).toFixed(1)}%` : '--'}
|
| 577 |
+
</span>
|
| 578 |
+
</div>
|
| 579 |
+
<div className="records-detail-item">
|
| 580 |
+
<span className="records-detail-item-label">Avg Face Score</span>
|
| 581 |
+
<span className="records-detail-item-value">
|
| 582 |
+
{detailView.avgFaceScore !== null ? detailView.avgFaceScore.toFixed(3) : '--'}
|
| 583 |
+
</span>
|
| 584 |
+
</div>
|
| 585 |
+
<div className="records-detail-item">
|
| 586 |
+
<span className="records-detail-item-label">Avg Eye Score</span>
|
| 587 |
+
<span className="records-detail-item-value">
|
| 588 |
+
{detailView.avgEyeScore !== null ? detailView.avgEyeScore.toFixed(3) : '--'}
|
| 589 |
+
</span>
|
| 590 |
+
</div>
|
| 591 |
+
<div className="records-detail-item">
|
| 592 |
+
<span className="records-detail-item-label">Avg MAR</span>
|
| 593 |
+
<span className="records-detail-item-value">
|
| 594 |
+
{detailView.avgMar !== null ? detailView.avgMar.toFixed(3) : '--'}
|
| 595 |
+
</span>
|
| 596 |
+
</div>
|
| 597 |
+
</div>
|
| 598 |
+
</article>
|
| 599 |
+
</section>
|
| 600 |
+
|
| 601 |
+
<section className="records-detail-card">
|
| 602 |
+
<div className="records-detail-section-head">
|
| 603 |
+
<h3>Focus Timeline</h3>
|
| 604 |
+
<span>{detailView.parsedEvents.length} events condensed</span>
|
| 605 |
+
</div>
|
| 606 |
+
{detailView.timeline.length > 0 ? (
|
| 607 |
+
<>
|
| 608 |
+
<div className="records-detail-timeline">
|
| 609 |
+
{detailView.timeline.map((segment, index) => (
|
| 610 |
+
<div
|
| 611 |
+
key={`${segment.tone}-${index}`}
|
| 612 |
+
className={`records-detail-segment ${segment.tone}`}
|
| 613 |
+
title={`${(segment.focusRatio * 100).toFixed(0)}% focused, ${segment.count} events`}
|
| 614 |
+
/>
|
| 615 |
+
))}
|
| 616 |
+
</div>
|
| 617 |
+
<div className="records-detail-legend">
|
| 618 |
+
<span><i className="records-detail-dot focused" />Focused</span>
|
| 619 |
+
<span><i className="records-detail-dot mixed" />Mixed</span>
|
| 620 |
+
<span><i className="records-detail-dot distracted" />Distracted</span>
|
| 621 |
+
</div>
|
| 622 |
+
</>
|
| 623 |
+
) : (
|
| 624 |
+
<div className="records-detail-empty">No event timeline was recorded for this session.</div>
|
| 625 |
+
)}
|
| 626 |
+
</section>
|
| 627 |
+
|
| 628 |
+
<section className="records-detail-card">
|
| 629 |
+
<div className="records-detail-section-head">
|
| 630 |
+
<h3>Recent Events</h3>
|
| 631 |
+
<span>Last {detailView.recentEvents.length} samples</span>
|
| 632 |
+
</div>
|
| 633 |
+
{detailView.recentEvents.length > 0 ? (
|
| 634 |
+
<div className="records-detail-events">
|
| 635 |
+
{detailView.recentEvents.map((event) => (
|
| 636 |
+
<article key={event.id} className="records-detail-event">
|
| 637 |
+
<div className="records-detail-event-time">{detailView.formatOffset(event.timestamp)}</div>
|
| 638 |
+
<div className="records-detail-event-copy">
|
| 639 |
+
<div className="records-detail-event-status">
|
| 640 |
+
{event.isFocused ? 'Focused' : 'Distracted'}
|
| 641 |
+
</div>
|
| 642 |
+
<div className="records-detail-event-meta">
|
| 643 |
+
{event.metadata?.model || 'model n/a'} · confidence {(event.confidence * 100).toFixed(1)}%
|
| 644 |
+
</div>
|
| 645 |
+
</div>
|
| 646 |
+
<div className={`records-detail-event-badge ${event.isFocused ? 'focused' : 'distracted'}`}>
|
| 647 |
+
{event.isFocused ? 'OK' : 'Alert'}
|
| 648 |
+
</div>
|
| 649 |
+
</article>
|
| 650 |
+
))}
|
| 651 |
+
</div>
|
| 652 |
+
) : (
|
| 653 |
+
<div className="records-detail-empty">No individual event samples are available.</div>
|
| 654 |
+
)}
|
| 655 |
+
</section>
|
| 656 |
+
</>
|
| 657 |
+
) : null}
|
| 658 |
+
</div>
|
| 659 |
+
</div>
|
| 660 |
+
) : null}
|
| 661 |
+
</main>
|
| 662 |
+
);
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
export default Records;
|
src/index.css
CHANGED
|
@@ -1,73 +1,73 @@
|
|
| 1 |
-
:root {
|
| 2 |
-
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
| 3 |
-
line-height: 1.5;
|
| 4 |
-
font-weight: 400;
|
| 5 |
-
|
| 6 |
-
color-scheme: light dark;
|
| 7 |
-
color: rgba(255, 255, 255, 0.87);
|
| 8 |
-
background-color: #242424;
|
| 9 |
-
|
| 10 |
-
font-synthesis: none;
|
| 11 |
-
text-rendering: optimizeLegibility;
|
| 12 |
-
-webkit-font-smoothing: antialiased;
|
| 13 |
-
-moz-osx-font-smoothing: grayscale;
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
a {
|
| 17 |
-
font-weight: 500;
|
| 18 |
-
color: #646cff;
|
| 19 |
-
text-decoration: inherit;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
a:hover {
|
| 23 |
-
color: #535bf2;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
body {
|
| 27 |
-
margin: 0;
|
| 28 |
-
display: flex;
|
| 29 |
-
place-items: center;
|
| 30 |
-
min-width: 320px;
|
| 31 |
-
min-height: 100vh;
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
h1 {
|
| 35 |
-
font-size: 3.2em;
|
| 36 |
-
line-height: 1.1;
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
button {
|
| 40 |
-
border-radius: 8px;
|
| 41 |
-
border: 1px solid transparent;
|
| 42 |
-
padding: 0.6em 1.2em;
|
| 43 |
-
font-size: 1em;
|
| 44 |
-
font-weight: 500;
|
| 45 |
-
font-family: inherit;
|
| 46 |
-
background-color: #1a1a1a;
|
| 47 |
-
cursor: pointer;
|
| 48 |
-
transition: border-color 0.25s;
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
button:hover {
|
| 52 |
-
border-color: #646cff;
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
button:focus,
|
| 56 |
-
button:focus-visible {
|
| 57 |
-
outline: 4px auto -webkit-focus-ring-color;
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
@media (prefers-color-scheme: light) {
|
| 61 |
-
:root {
|
| 62 |
-
color: #213547;
|
| 63 |
-
background-color: #ffffff;
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
a:hover {
|
| 67 |
-
color: #747bff;
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
button {
|
| 71 |
-
background-color: #f9f9f9;
|
| 72 |
-
}
|
| 73 |
}
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
| 3 |
+
line-height: 1.5;
|
| 4 |
+
font-weight: 400;
|
| 5 |
+
|
| 6 |
+
color-scheme: light dark;
|
| 7 |
+
color: rgba(255, 255, 255, 0.87);
|
| 8 |
+
background-color: #242424;
|
| 9 |
+
|
| 10 |
+
font-synthesis: none;
|
| 11 |
+
text-rendering: optimizeLegibility;
|
| 12 |
+
-webkit-font-smoothing: antialiased;
|
| 13 |
+
-moz-osx-font-smoothing: grayscale;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
a {
|
| 17 |
+
font-weight: 500;
|
| 18 |
+
color: #646cff;
|
| 19 |
+
text-decoration: inherit;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
a:hover {
|
| 23 |
+
color: #535bf2;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
body {
|
| 27 |
+
margin: 0;
|
| 28 |
+
display: flex;
|
| 29 |
+
place-items: center;
|
| 30 |
+
min-width: 320px;
|
| 31 |
+
min-height: 100vh;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
h1 {
|
| 35 |
+
font-size: 3.2em;
|
| 36 |
+
line-height: 1.1;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
button {
|
| 40 |
+
border-radius: 8px;
|
| 41 |
+
border: 1px solid transparent;
|
| 42 |
+
padding: 0.6em 1.2em;
|
| 43 |
+
font-size: 1em;
|
| 44 |
+
font-weight: 500;
|
| 45 |
+
font-family: inherit;
|
| 46 |
+
background-color: #1a1a1a;
|
| 47 |
+
cursor: pointer;
|
| 48 |
+
transition: border-color 0.25s;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
button:hover {
|
| 52 |
+
border-color: #646cff;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
button:focus,
|
| 56 |
+
button:focus-visible {
|
| 57 |
+
outline: 4px auto -webkit-focus-ring-color;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
@media (prefers-color-scheme: light) {
|
| 61 |
+
:root {
|
| 62 |
+
color: #213547;
|
| 63 |
+
background-color: #ffffff;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
a:hover {
|
| 67 |
+
color: #747bff;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
button {
|
| 71 |
+
background-color: #f9f9f9;
|
| 72 |
+
}
|
| 73 |
}
|
src/main.jsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
-
import { StrictMode } from 'react'
|
| 2 |
-
import { createRoot } from 'react-dom/client'
|
| 3 |
-
import './index.css'
|
| 4 |
-
import App from './App.jsx'
|
| 5 |
-
|
| 6 |
-
createRoot(document.getElementById('root')).render(
|
| 7 |
-
<StrictMode>
|
| 8 |
-
<App />
|
| 9 |
-
</StrictMode>,
|
| 10 |
-
)
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
src/utils/VideoManager.js
CHANGED
|
@@ -1,353 +1,353 @@
|
|
| 1 |
-
// src/utils/VideoManager.js
|
| 2 |
-
|
| 3 |
-
export class VideoManager {
|
| 4 |
-
constructor(callbacks) {
|
| 5 |
-
// callbacks 用于通知 React 组件更新界面
|
| 6 |
-
// 例如: onStatusUpdate, onSessionStart, onSessionEnd
|
| 7 |
-
this.callbacks = callbacks || {};
|
| 8 |
-
|
| 9 |
-
this.videoElement = null; // 显示后端处理后的视频
|
| 10 |
-
this.stream = null; // 本地摄像头流
|
| 11 |
-
this.pc = null;
|
| 12 |
-
this.dataChannel = null;
|
| 13 |
-
|
| 14 |
-
this.isStreaming = false;
|
| 15 |
-
this.sessionId = null;
|
| 16 |
-
this.frameRate = 30;
|
| 17 |
-
|
| 18 |
-
// 状态平滑处理
|
| 19 |
-
this.currentStatus = false;
|
| 20 |
-
this.statusBuffer = [];
|
| 21 |
-
this.bufferSize = 5;
|
| 22 |
-
|
| 23 |
-
// 检测数据
|
| 24 |
-
this.latestDetectionData = null;
|
| 25 |
-
this.lastConfidence = 0;
|
| 26 |
-
this.detectionHoldMs = 30;
|
| 27 |
-
|
| 28 |
-
// 通知系统
|
| 29 |
-
this.notificationEnabled = true;
|
| 30 |
-
this.notificationThreshold = 30; // 默认30秒
|
| 31 |
-
this.unfocusedStartTime = null;
|
| 32 |
-
this.lastNotificationTime = null;
|
| 33 |
-
this.notificationCooldown = 60000; // 通知冷却时间60秒
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
// 初始化:获取摄像头流,并记录展示视频的元素
|
| 37 |
-
async initCamera(videoRef) {
|
| 38 |
-
try {
|
| 39 |
-
this.stream = await navigator.mediaDevices.getUserMedia({
|
| 40 |
-
video: {
|
| 41 |
-
width: { ideal: 640 },
|
| 42 |
-
height: { ideal: 480 },
|
| 43 |
-
facingMode: 'user'
|
| 44 |
-
},
|
| 45 |
-
audio: false
|
| 46 |
-
});
|
| 47 |
-
|
| 48 |
-
this.videoElement = videoRef;
|
| 49 |
-
return true;
|
| 50 |
-
} catch (error) {
|
| 51 |
-
console.error('Camera init error:', error);
|
| 52 |
-
throw error;
|
| 53 |
-
}
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
async startStreaming() {
|
| 57 |
-
if (!this.stream) {
|
| 58 |
-
console.error('❌ No stream available');
|
| 59 |
-
throw new Error('Camera stream not initialized');
|
| 60 |
-
}
|
| 61 |
-
this.isStreaming = true;
|
| 62 |
-
|
| 63 |
-
console.log('📹 Starting streaming...');
|
| 64 |
-
|
| 65 |
-
// 请求通知权限
|
| 66 |
-
await this.requestNotificationPermission();
|
| 67 |
-
// 加载通知设置
|
| 68 |
-
await this.loadNotificationSettings();
|
| 69 |
-
|
| 70 |
-
this.pc = new RTCPeerConnection({
|
| 71 |
-
iceServers: [
|
| 72 |
-
{ urls: 'stun:stun.l.google.com:19302' },
|
| 73 |
-
{ urls: 'stun:stun1.l.google.com:19302' },
|
| 74 |
-
{ urls: 'stun:stun2.l.google.com:19302' },
|
| 75 |
-
{ urls: 'stun:stun3.l.google.com:19302' },
|
| 76 |
-
{ urls: 'stun:stun4.l.google.com:19302' }
|
| 77 |
-
],
|
| 78 |
-
iceCandidatePoolSize: 10
|
| 79 |
-
});
|
| 80 |
-
|
| 81 |
-
// 添加连接状态监控
|
| 82 |
-
this.pc.onconnectionstatechange = () => {
|
| 83 |
-
console.log('🔗 Connection state:', this.pc.connectionState);
|
| 84 |
-
};
|
| 85 |
-
|
| 86 |
-
this.pc.oniceconnectionstatechange = () => {
|
| 87 |
-
console.log('🧊 ICE connection state:', this.pc.iceConnectionState);
|
| 88 |
-
};
|
| 89 |
-
|
| 90 |
-
this.pc.onicegatheringstatechange = () => {
|
| 91 |
-
console.log('📡 ICE gathering state:', this.pc.iceGatheringState);
|
| 92 |
-
};
|
| 93 |
-
|
| 94 |
-
// DataChannel for status updates
|
| 95 |
-
this.dataChannel = this.pc.createDataChannel('status');
|
| 96 |
-
this.dataChannel.onmessage = (event) => {
|
| 97 |
-
try {
|
| 98 |
-
const data = JSON.parse(event.data);
|
| 99 |
-
this.handleServerMessage(data);
|
| 100 |
-
} catch (e) {
|
| 101 |
-
console.error('Failed to parse data channel message:', e);
|
| 102 |
-
}
|
| 103 |
-
};
|
| 104 |
-
|
| 105 |
-
this.pc.ontrack = (event) => {
|
| 106 |
-
const stream = event.streams[0];
|
| 107 |
-
if (this.videoElement) {
|
| 108 |
-
this.videoElement.srcObject = stream;
|
| 109 |
-
this.videoElement.autoplay = true;
|
| 110 |
-
this.videoElement.playsInline = true;
|
| 111 |
-
this.videoElement.play().catch(() => {});
|
| 112 |
-
}
|
| 113 |
-
};
|
| 114 |
-
|
| 115 |
-
// Add local camera tracks
|
| 116 |
-
this.stream.getTracks().forEach((track) => {
|
| 117 |
-
this.pc.addTrack(track, this.stream);
|
| 118 |
-
});
|
| 119 |
-
|
| 120 |
-
const offer = await this.pc.createOffer();
|
| 121 |
-
await this.pc.setLocalDescription(offer);
|
| 122 |
-
|
| 123 |
-
// Wait for ICE gathering to complete so SDP includes candidates
|
| 124 |
-
await new Promise((resolve) => {
|
| 125 |
-
if (this.pc.iceGatheringState === 'complete') {
|
| 126 |
-
resolve();
|
| 127 |
-
return;
|
| 128 |
-
}
|
| 129 |
-
const onIce = () => {
|
| 130 |
-
if (this.pc.iceGatheringState === 'complete') {
|
| 131 |
-
this.pc.removeEventListener('icegatheringstatechange', onIce);
|
| 132 |
-
resolve();
|
| 133 |
-
}
|
| 134 |
-
};
|
| 135 |
-
this.pc.addEventListener('icegatheringstatechange', onIce);
|
| 136 |
-
});
|
| 137 |
-
|
| 138 |
-
console.log('📤 Sending offer to server...');
|
| 139 |
-
const response = await fetch('/api/webrtc/offer', {
|
| 140 |
-
method: 'POST',
|
| 141 |
-
headers: { 'Content-Type': 'application/json' },
|
| 142 |
-
body: JSON.stringify({
|
| 143 |
-
sdp: this.pc.localDescription.sdp,
|
| 144 |
-
type: this.pc.localDescription.type
|
| 145 |
-
})
|
| 146 |
-
});
|
| 147 |
-
|
| 148 |
-
if (!response.ok) {
|
| 149 |
-
const errorText = await response.text();
|
| 150 |
-
console.error('❌ Server error:', errorText);
|
| 151 |
-
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
const answer = await response.json();
|
| 155 |
-
console.log('✅ Received answer from server, session_id:', answer.session_id);
|
| 156 |
-
|
| 157 |
-
await this.pc.setRemoteDescription(answer);
|
| 158 |
-
console.log('✅ Remote description set successfully');
|
| 159 |
-
|
| 160 |
-
this.sessionId = answer.session_id;
|
| 161 |
-
if (this.callbacks.onSessionStart) {
|
| 162 |
-
this.callbacks.onSessionStart(this.sessionId);
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
async requestNotificationPermission() {
|
| 168 |
-
if ('Notification' in window && Notification.permission === 'default') {
|
| 169 |
-
try {
|
| 170 |
-
await Notification.requestPermission();
|
| 171 |
-
} catch (error) {
|
| 172 |
-
console.error('Failed to request notification permission:', error);
|
| 173 |
-
}
|
| 174 |
-
}
|
| 175 |
-
}
|
| 176 |
-
|
| 177 |
-
async loadNotificationSettings() {
|
| 178 |
-
try {
|
| 179 |
-
const response = await fetch('/api/settings');
|
| 180 |
-
const settings = await response.json();
|
| 181 |
-
if (settings) {
|
| 182 |
-
this.notificationEnabled = settings.notification_enabled ?? true;
|
| 183 |
-
this.notificationThreshold = settings.notification_threshold ?? 30;
|
| 184 |
-
}
|
| 185 |
-
} catch (error) {
|
| 186 |
-
console.error('Failed to load notification settings:', error);
|
| 187 |
-
}
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
sendNotification(title, message) {
|
| 191 |
-
if (!this.notificationEnabled) return;
|
| 192 |
-
if ('Notification' in window && Notification.permission === 'granted') {
|
| 193 |
-
try {
|
| 194 |
-
const notification = new Notification(title, {
|
| 195 |
-
body: message,
|
| 196 |
-
icon: '/vite.svg',
|
| 197 |
-
badge: '/vite.svg',
|
| 198 |
-
tag: 'focus-guard-distraction',
|
| 199 |
-
requireInteraction: false
|
| 200 |
-
});
|
| 201 |
-
|
| 202 |
-
// 3秒后自动关闭
|
| 203 |
-
setTimeout(() => notification.close(), 3000);
|
| 204 |
-
} catch (error) {
|
| 205 |
-
console.error('Failed to send notification:', error);
|
| 206 |
-
}
|
| 207 |
-
}
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
handleServerMessage(data) {
|
| 211 |
-
switch (data.type) {
|
| 212 |
-
case 'detection':
|
| 213 |
-
this.updateStatus(data.focused);
|
| 214 |
-
this.latestDetectionData = {
|
| 215 |
-
detections: data.detections || [],
|
| 216 |
-
confidence: data.confidence || 0,
|
| 217 |
-
focused: data.focused,
|
| 218 |
-
timestamp: performance.now()
|
| 219 |
-
};
|
| 220 |
-
this.lastConfidence = data.confidence || 0;
|
| 221 |
-
|
| 222 |
-
if (this.callbacks.onStatusUpdate) {
|
| 223 |
-
this.callbacks.onStatusUpdate(this.currentStatus);
|
| 224 |
-
}
|
| 225 |
-
break;
|
| 226 |
-
default:
|
| 227 |
-
break;
|
| 228 |
-
}
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
updateStatus(newFocused) {
|
| 232 |
-
this.statusBuffer.push(newFocused);
|
| 233 |
-
if (this.statusBuffer.length > this.bufferSize) {
|
| 234 |
-
this.statusBuffer.shift();
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
if (this.statusBuffer.length < this.bufferSize) return false;
|
| 238 |
-
|
| 239 |
-
const focusedCount = this.statusBuffer.filter(f => f).length;
|
| 240 |
-
const focusedRatio = focusedCount / this.statusBuffer.length;
|
| 241 |
-
|
| 242 |
-
const previousStatus = this.currentStatus;
|
| 243 |
-
|
| 244 |
-
if (focusedRatio >= 0.75) {
|
| 245 |
-
this.currentStatus = true;
|
| 246 |
-
} else if (focusedRatio <= 0.25) {
|
| 247 |
-
this.currentStatus = false;
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
// 通知逻辑
|
| 251 |
-
this.handleNotificationLogic(previousStatus, this.currentStatus);
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
handleNotificationLogic(previousStatus, currentStatus) {
|
| 255 |
-
const now = Date.now();
|
| 256 |
-
|
| 257 |
-
// 如果从专注变为不专注,记录开始时间
|
| 258 |
-
if (previousStatus && !currentStatus) {
|
| 259 |
-
this.unfocusedStartTime = now;
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
// 如果从不专注变为专注,清除计时
|
| 263 |
-
if (!previousStatus && currentStatus) {
|
| 264 |
-
this.unfocusedStartTime = null;
|
| 265 |
-
}
|
| 266 |
-
|
| 267 |
-
// 如果持续不专注
|
| 268 |
-
if (!currentStatus && this.unfocusedStartTime) {
|
| 269 |
-
const unfocusedDuration = (now - this.unfocusedStartTime) / 1000; // 秒
|
| 270 |
-
|
| 271 |
-
// 检查是否超过阈值且不在冷却期
|
| 272 |
-
if (unfocusedDuration >= this.notificationThreshold) {
|
| 273 |
-
const canSendNotification = !this.lastNotificationTime ||
|
| 274 |
-
(now - this.lastNotificationTime) >= this.notificationCooldown;
|
| 275 |
-
|
| 276 |
-
if (canSendNotification) {
|
| 277 |
-
this.sendNotification(
|
| 278 |
-
'⚠️ Focus Alert',
|
| 279 |
-
`You've been distracted for ${Math.floor(unfocusedDuration)} seconds. Get back to work!`
|
| 280 |
-
);
|
| 281 |
-
this.lastNotificationTime = now;
|
| 282 |
-
}
|
| 283 |
-
}
|
| 284 |
-
}
|
| 285 |
-
}
|
| 286 |
-
|
| 287 |
-
async stopStreaming() {
|
| 288 |
-
this.isStreaming = false;
|
| 289 |
-
|
| 290 |
-
try {
|
| 291 |
-
if (document.pictureInPictureElement) {
|
| 292 |
-
await document.exitPictureInPicture();
|
| 293 |
-
}
|
| 294 |
-
if (this.videoElement && typeof this.videoElement.webkitSetPresentationMode === 'function') {
|
| 295 |
-
if (this.videoElement.webkitPresentationMode === 'picture-in-picture') {
|
| 296 |
-
this.videoElement.webkitSetPresentationMode('inline');
|
| 297 |
-
}
|
| 298 |
-
}
|
| 299 |
-
} catch (e) {
|
| 300 |
-
// ignore PiP exit errors
|
| 301 |
-
}
|
| 302 |
-
|
| 303 |
-
if (this.pc) {
|
| 304 |
-
try {
|
| 305 |
-
this.pc.getSenders().forEach(sender => sender.track && sender.track.stop());
|
| 306 |
-
this.pc.close();
|
| 307 |
-
} catch (e) {
|
| 308 |
-
console.error('Failed to close RTCPeerConnection:', e);
|
| 309 |
-
}
|
| 310 |
-
this.pc = null;
|
| 311 |
-
}
|
| 312 |
-
|
| 313 |
-
if (this.stream) {
|
| 314 |
-
this.stream.getTracks().forEach(track => track.stop());
|
| 315 |
-
this.stream = null;
|
| 316 |
-
}
|
| 317 |
-
|
| 318 |
-
if (this.videoElement) {
|
| 319 |
-
this.videoElement.srcObject = null;
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
if (this.sessionId) {
|
| 323 |
-
try {
|
| 324 |
-
const response = await fetch('/api/sessions/end', {
|
| 325 |
-
method: 'POST',
|
| 326 |
-
headers: { 'Content-Type': 'application/json' },
|
| 327 |
-
body: JSON.stringify({ session_id: this.sessionId })
|
| 328 |
-
});
|
| 329 |
-
const summary = await response.json();
|
| 330 |
-
if (this.callbacks.onSessionEnd) {
|
| 331 |
-
this.callbacks.onSessionEnd(summary);
|
| 332 |
-
}
|
| 333 |
-
} catch (e) {
|
| 334 |
-
console.error('Failed to end session:', e);
|
| 335 |
-
}
|
| 336 |
-
}
|
| 337 |
-
|
| 338 |
-
// 清理通知状态
|
| 339 |
-
this.unfocusedStartTime = null;
|
| 340 |
-
this.lastNotificationTime = null;
|
| 341 |
-
this.sessionId = null;
|
| 342 |
-
}
|
| 343 |
-
|
| 344 |
-
setFrameRate(rate) {
|
| 345 |
-
this.frameRate = Math.max(1, Math.min(60, rate));
|
| 346 |
-
if (this.stream) {
|
| 347 |
-
const videoTrack = this.stream.getVideoTracks()[0];
|
| 348 |
-
if (videoTrack && videoTrack.applyConstraints) {
|
| 349 |
-
videoTrack.applyConstraints({ frameRate: { ideal: this.frameRate, max: this.frameRate } }).catch(() => {});
|
| 350 |
-
}
|
| 351 |
-
}
|
| 352 |
-
}
|
| 353 |
-
}
|
|
|
|
| 1 |
+
// src/utils/VideoManager.js
|
| 2 |
+
|
| 3 |
+
export class VideoManager {
|
| 4 |
+
constructor(callbacks) {
|
| 5 |
+
// callbacks 用于通知 React 组件更新界面
|
| 6 |
+
// 例如: onStatusUpdate, onSessionStart, onSessionEnd
|
| 7 |
+
this.callbacks = callbacks || {};
|
| 8 |
+
|
| 9 |
+
this.videoElement = null; // 显示后端处理后的视频
|
| 10 |
+
this.stream = null; // 本地摄像头流
|
| 11 |
+
this.pc = null;
|
| 12 |
+
this.dataChannel = null;
|
| 13 |
+
|
| 14 |
+
this.isStreaming = false;
|
| 15 |
+
this.sessionId = null;
|
| 16 |
+
this.frameRate = 30;
|
| 17 |
+
|
| 18 |
+
// 状态平滑处理
|
| 19 |
+
this.currentStatus = false;
|
| 20 |
+
this.statusBuffer = [];
|
| 21 |
+
this.bufferSize = 5;
|
| 22 |
+
|
| 23 |
+
// 检测数据
|
| 24 |
+
this.latestDetectionData = null;
|
| 25 |
+
this.lastConfidence = 0;
|
| 26 |
+
this.detectionHoldMs = 30;
|
| 27 |
+
|
| 28 |
+
// 通知系统
|
| 29 |
+
this.notificationEnabled = true;
|
| 30 |
+
this.notificationThreshold = 30; // 默认30秒
|
| 31 |
+
this.unfocusedStartTime = null;
|
| 32 |
+
this.lastNotificationTime = null;
|
| 33 |
+
this.notificationCooldown = 60000; // 通知冷却时间60秒
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// 初始化:获取摄像头流,并记录展示视频的元素
|
| 37 |
+
async initCamera(videoRef) {
|
| 38 |
+
try {
|
| 39 |
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
| 40 |
+
video: {
|
| 41 |
+
width: { ideal: 640 },
|
| 42 |
+
height: { ideal: 480 },
|
| 43 |
+
facingMode: 'user'
|
| 44 |
+
},
|
| 45 |
+
audio: false
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
this.videoElement = videoRef;
|
| 49 |
+
return true;
|
| 50 |
+
} catch (error) {
|
| 51 |
+
console.error('Camera init error:', error);
|
| 52 |
+
throw error;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async startStreaming() {
|
| 57 |
+
if (!this.stream) {
|
| 58 |
+
console.error('❌ No stream available');
|
| 59 |
+
throw new Error('Camera stream not initialized');
|
| 60 |
+
}
|
| 61 |
+
this.isStreaming = true;
|
| 62 |
+
|
| 63 |
+
console.log('📹 Starting streaming...');
|
| 64 |
+
|
| 65 |
+
// 请求通知权限
|
| 66 |
+
await this.requestNotificationPermission();
|
| 67 |
+
// 加载通知设置
|
| 68 |
+
await this.loadNotificationSettings();
|
| 69 |
+
|
| 70 |
+
this.pc = new RTCPeerConnection({
|
| 71 |
+
iceServers: [
|
| 72 |
+
{ urls: 'stun:stun.l.google.com:19302' },
|
| 73 |
+
{ urls: 'stun:stun1.l.google.com:19302' },
|
| 74 |
+
{ urls: 'stun:stun2.l.google.com:19302' },
|
| 75 |
+
{ urls: 'stun:stun3.l.google.com:19302' },
|
| 76 |
+
{ urls: 'stun:stun4.l.google.com:19302' }
|
| 77 |
+
],
|
| 78 |
+
iceCandidatePoolSize: 10
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
// 添加连接状态监控
|
| 82 |
+
this.pc.onconnectionstatechange = () => {
|
| 83 |
+
console.log('🔗 Connection state:', this.pc.connectionState);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
this.pc.oniceconnectionstatechange = () => {
|
| 87 |
+
console.log('🧊 ICE connection state:', this.pc.iceConnectionState);
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
this.pc.onicegatheringstatechange = () => {
|
| 91 |
+
console.log('📡 ICE gathering state:', this.pc.iceGatheringState);
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
// DataChannel for status updates
|
| 95 |
+
this.dataChannel = this.pc.createDataChannel('status');
|
| 96 |
+
this.dataChannel.onmessage = (event) => {
|
| 97 |
+
try {
|
| 98 |
+
const data = JSON.parse(event.data);
|
| 99 |
+
this.handleServerMessage(data);
|
| 100 |
+
} catch (e) {
|
| 101 |
+
console.error('Failed to parse data channel message:', e);
|
| 102 |
+
}
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
this.pc.ontrack = (event) => {
|
| 106 |
+
const stream = event.streams[0];
|
| 107 |
+
if (this.videoElement) {
|
| 108 |
+
this.videoElement.srcObject = stream;
|
| 109 |
+
this.videoElement.autoplay = true;
|
| 110 |
+
this.videoElement.playsInline = true;
|
| 111 |
+
this.videoElement.play().catch(() => {});
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
// Add local camera tracks
|
| 116 |
+
this.stream.getTracks().forEach((track) => {
|
| 117 |
+
this.pc.addTrack(track, this.stream);
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
const offer = await this.pc.createOffer();
|
| 121 |
+
await this.pc.setLocalDescription(offer);
|
| 122 |
+
|
| 123 |
+
// Wait for ICE gathering to complete so SDP includes candidates
|
| 124 |
+
await new Promise((resolve) => {
|
| 125 |
+
if (this.pc.iceGatheringState === 'complete') {
|
| 126 |
+
resolve();
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
const onIce = () => {
|
| 130 |
+
if (this.pc.iceGatheringState === 'complete') {
|
| 131 |
+
this.pc.removeEventListener('icegatheringstatechange', onIce);
|
| 132 |
+
resolve();
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
this.pc.addEventListener('icegatheringstatechange', onIce);
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
console.log('📤 Sending offer to server...');
|
| 139 |
+
const response = await fetch('/api/webrtc/offer', {
|
| 140 |
+
method: 'POST',
|
| 141 |
+
headers: { 'Content-Type': 'application/json' },
|
| 142 |
+
body: JSON.stringify({
|
| 143 |
+
sdp: this.pc.localDescription.sdp,
|
| 144 |
+
type: this.pc.localDescription.type
|
| 145 |
+
})
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
if (!response.ok) {
|
| 149 |
+
const errorText = await response.text();
|
| 150 |
+
console.error('❌ Server error:', errorText);
|
| 151 |
+
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
const answer = await response.json();
|
| 155 |
+
console.log('✅ Received answer from server, session_id:', answer.session_id);
|
| 156 |
+
|
| 157 |
+
await this.pc.setRemoteDescription(answer);
|
| 158 |
+
console.log('✅ Remote description set successfully');
|
| 159 |
+
|
| 160 |
+
this.sessionId = answer.session_id;
|
| 161 |
+
if (this.callbacks.onSessionStart) {
|
| 162 |
+
this.callbacks.onSessionStart(this.sessionId);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
async requestNotificationPermission() {
|
| 168 |
+
if ('Notification' in window && Notification.permission === 'default') {
|
| 169 |
+
try {
|
| 170 |
+
await Notification.requestPermission();
|
| 171 |
+
} catch (error) {
|
| 172 |
+
console.error('Failed to request notification permission:', error);
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
async loadNotificationSettings() {
|
| 178 |
+
try {
|
| 179 |
+
const response = await fetch('/api/settings');
|
| 180 |
+
const settings = await response.json();
|
| 181 |
+
if (settings) {
|
| 182 |
+
this.notificationEnabled = settings.notification_enabled ?? true;
|
| 183 |
+
this.notificationThreshold = settings.notification_threshold ?? 30;
|
| 184 |
+
}
|
| 185 |
+
} catch (error) {
|
| 186 |
+
console.error('Failed to load notification settings:', error);
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
sendNotification(title, message) {
|
| 191 |
+
if (!this.notificationEnabled) return;
|
| 192 |
+
if ('Notification' in window && Notification.permission === 'granted') {
|
| 193 |
+
try {
|
| 194 |
+
const notification = new Notification(title, {
|
| 195 |
+
body: message,
|
| 196 |
+
icon: '/vite.svg',
|
| 197 |
+
badge: '/vite.svg',
|
| 198 |
+
tag: 'focus-guard-distraction',
|
| 199 |
+
requireInteraction: false
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// 3秒后自动关闭
|
| 203 |
+
setTimeout(() => notification.close(), 3000);
|
| 204 |
+
} catch (error) {
|
| 205 |
+
console.error('Failed to send notification:', error);
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
handleServerMessage(data) {
|
| 211 |
+
switch (data.type) {
|
| 212 |
+
case 'detection':
|
| 213 |
+
this.updateStatus(data.focused);
|
| 214 |
+
this.latestDetectionData = {
|
| 215 |
+
detections: data.detections || [],
|
| 216 |
+
confidence: data.confidence || 0,
|
| 217 |
+
focused: data.focused,
|
| 218 |
+
timestamp: performance.now()
|
| 219 |
+
};
|
| 220 |
+
this.lastConfidence = data.confidence || 0;
|
| 221 |
+
|
| 222 |
+
if (this.callbacks.onStatusUpdate) {
|
| 223 |
+
this.callbacks.onStatusUpdate(this.currentStatus);
|
| 224 |
+
}
|
| 225 |
+
break;
|
| 226 |
+
default:
|
| 227 |
+
break;
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
updateStatus(newFocused) {
|
| 232 |
+
this.statusBuffer.push(newFocused);
|
| 233 |
+
if (this.statusBuffer.length > this.bufferSize) {
|
| 234 |
+
this.statusBuffer.shift();
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
if (this.statusBuffer.length < this.bufferSize) return false;
|
| 238 |
+
|
| 239 |
+
const focusedCount = this.statusBuffer.filter(f => f).length;
|
| 240 |
+
const focusedRatio = focusedCount / this.statusBuffer.length;
|
| 241 |
+
|
| 242 |
+
const previousStatus = this.currentStatus;
|
| 243 |
+
|
| 244 |
+
if (focusedRatio >= 0.75) {
|
| 245 |
+
this.currentStatus = true;
|
| 246 |
+
} else if (focusedRatio <= 0.25) {
|
| 247 |
+
this.currentStatus = false;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// 通知逻辑
|
| 251 |
+
this.handleNotificationLogic(previousStatus, this.currentStatus);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
handleNotificationLogic(previousStatus, currentStatus) {
|
| 255 |
+
const now = Date.now();
|
| 256 |
+
|
| 257 |
+
// 如果从专注变为不专注,记录开始时间
|
| 258 |
+
if (previousStatus && !currentStatus) {
|
| 259 |
+
this.unfocusedStartTime = now;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// 如果从不专注变为专注,清除计时
|
| 263 |
+
if (!previousStatus && currentStatus) {
|
| 264 |
+
this.unfocusedStartTime = null;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// 如果持续不专注
|
| 268 |
+
if (!currentStatus && this.unfocusedStartTime) {
|
| 269 |
+
const unfocusedDuration = (now - this.unfocusedStartTime) / 1000; // 秒
|
| 270 |
+
|
| 271 |
+
// 检查是否超过阈值且不在冷却期
|
| 272 |
+
if (unfocusedDuration >= this.notificationThreshold) {
|
| 273 |
+
const canSendNotification = !this.lastNotificationTime ||
|
| 274 |
+
(now - this.lastNotificationTime) >= this.notificationCooldown;
|
| 275 |
+
|
| 276 |
+
if (canSendNotification) {
|
| 277 |
+
this.sendNotification(
|
| 278 |
+
'⚠️ Focus Alert',
|
| 279 |
+
`You've been distracted for ${Math.floor(unfocusedDuration)} seconds. Get back to work!`
|
| 280 |
+
);
|
| 281 |
+
this.lastNotificationTime = now;
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
async stopStreaming() {
|
| 288 |
+
this.isStreaming = false;
|
| 289 |
+
|
| 290 |
+
try {
|
| 291 |
+
if (document.pictureInPictureElement) {
|
| 292 |
+
await document.exitPictureInPicture();
|
| 293 |
+
}
|
| 294 |
+
if (this.videoElement && typeof this.videoElement.webkitSetPresentationMode === 'function') {
|
| 295 |
+
if (this.videoElement.webkitPresentationMode === 'picture-in-picture') {
|
| 296 |
+
this.videoElement.webkitSetPresentationMode('inline');
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
} catch (e) {
|
| 300 |
+
// ignore PiP exit errors
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
if (this.pc) {
|
| 304 |
+
try {
|
| 305 |
+
this.pc.getSenders().forEach(sender => sender.track && sender.track.stop());
|
| 306 |
+
this.pc.close();
|
| 307 |
+
} catch (e) {
|
| 308 |
+
console.error('Failed to close RTCPeerConnection:', e);
|
| 309 |
+
}
|
| 310 |
+
this.pc = null;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
if (this.stream) {
|
| 314 |
+
this.stream.getTracks().forEach(track => track.stop());
|
| 315 |
+
this.stream = null;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
if (this.videoElement) {
|
| 319 |
+
this.videoElement.srcObject = null;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
if (this.sessionId) {
|
| 323 |
+
try {
|
| 324 |
+
const response = await fetch('/api/sessions/end', {
|
| 325 |
+
method: 'POST',
|
| 326 |
+
headers: { 'Content-Type': 'application/json' },
|
| 327 |
+
body: JSON.stringify({ session_id: this.sessionId })
|
| 328 |
+
});
|
| 329 |
+
const summary = await response.json();
|
| 330 |
+
if (this.callbacks.onSessionEnd) {
|
| 331 |
+
this.callbacks.onSessionEnd(summary);
|
| 332 |
+
}
|
| 333 |
+
} catch (e) {
|
| 334 |
+
console.error('Failed to end session:', e);
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
// 清理通知状态
|
| 339 |
+
this.unfocusedStartTime = null;
|
| 340 |
+
this.lastNotificationTime = null;
|
| 341 |
+
this.sessionId = null;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
setFrameRate(rate) {
|
| 345 |
+
this.frameRate = Math.max(1, Math.min(60, rate));
|
| 346 |
+
if (this.stream) {
|
| 347 |
+
const videoTrack = this.stream.getVideoTracks()[0];
|
| 348 |
+
if (videoTrack && videoTrack.applyConstraints) {
|
| 349 |
+
videoTrack.applyConstraints({ frameRate: { ideal: this.frameRate, max: this.frameRate } }).catch(() => {});
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
}
|
src/utils/VideoManagerLocal.js
CHANGED
|
@@ -1,788 +1,949 @@
|
|
| 1 |
-
// src/utils/VideoManagerLocal.js
|
| 2 |
-
//
|
| 3 |
-
|
| 4 |
-
export class VideoManagerLocal {
|
| 5 |
-
constructor(callbacks) {
|
| 6 |
-
this.callbacks = callbacks || {};
|
| 7 |
-
|
| 8 |
-
this.localVideoElement = null; //
|
| 9 |
-
this.displayVideoElement = null; //
|
| 10 |
-
this.canvas = null;
|
| 11 |
-
this.stream = null;
|
| 12 |
-
this.ws = null;
|
| 13 |
-
|
| 14 |
-
this.isStreaming = false;
|
| 15 |
-
this.sessionId = null;
|
| 16 |
-
this.sessionStartTime = null;
|
| 17 |
-
this.frameRate = 15; //
|
| 18 |
-
this.captureInterval = null;
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
this.
|
| 23 |
-
this.
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
this.
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
this.
|
| 38 |
-
this.
|
| 39 |
-
this.
|
| 40 |
-
this.
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
this.
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
this.localVideoElement.
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
this.canvas
|
| 88 |
-
this.canvas.
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
this.
|
| 162 |
-
} catch (
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
//
|
| 281 |
-
ctx.
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
if (
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
}
|
| 420 |
-
}
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
if (
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
const
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
}
|
| 655 |
-
}
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
}
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// src/utils/VideoManagerLocal.js
|
| 2 |
+
// Local video processing implementation using WebSocket + Canvas, without WebRTC.
|
| 3 |
+
|
| 4 |
+
export class VideoManagerLocal {
|
| 5 |
+
constructor(callbacks) {
|
| 6 |
+
this.callbacks = callbacks || {};
|
| 7 |
+
|
| 8 |
+
this.localVideoElement = null; // Local camera preview element.
|
| 9 |
+
this.displayVideoElement = null; // Processed output display element.
|
| 10 |
+
this.canvas = null;
|
| 11 |
+
this.stream = null;
|
| 12 |
+
this.ws = null;
|
| 13 |
+
|
| 14 |
+
this.isStreaming = false;
|
| 15 |
+
this.sessionId = null;
|
| 16 |
+
this.sessionStartTime = null;
|
| 17 |
+
this.frameRate = 15; // Lower FPS reduces transfer and processing load.
|
| 18 |
+
this.captureInterval = null;
|
| 19 |
+
this.reconnectTimeout = null;
|
| 20 |
+
|
| 21 |
+
// Status smoothing
|
| 22 |
+
this.currentStatus = false;
|
| 23 |
+
this.statusBuffer = [];
|
| 24 |
+
this.bufferSize = 3;
|
| 25 |
+
|
| 26 |
+
// Detection data
|
| 27 |
+
this.latestDetectionData = null;
|
| 28 |
+
this.lastConfidence = 0;
|
| 29 |
+
|
| 30 |
+
// Tessellation connections (fetched once from server)
|
| 31 |
+
this._tessellation = null;
|
| 32 |
+
|
| 33 |
+
// Continuous render loop
|
| 34 |
+
this._animFrameId = null;
|
| 35 |
+
|
| 36 |
+
// Notification state
|
| 37 |
+
this.notificationEnabled = true;
|
| 38 |
+
this.notificationThreshold = 30;
|
| 39 |
+
this.unfocusedStartTime = null;
|
| 40 |
+
this.lastNotificationTime = null;
|
| 41 |
+
this.notificationCooldown = 60000;
|
| 42 |
+
|
| 43 |
+
// Performance metrics
|
| 44 |
+
this.stats = {
|
| 45 |
+
framesSent: 0,
|
| 46 |
+
framesProcessed: 0,
|
| 47 |
+
avgLatency: 0,
|
| 48 |
+
lastLatencies: []
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
// Calibration state (9-point gaze calibration)
|
| 52 |
+
this.calibrationState = {
|
| 53 |
+
active: false,
|
| 54 |
+
collecting: false,
|
| 55 |
+
done: false,
|
| 56 |
+
success: false,
|
| 57 |
+
target: [0.5, 0.5],
|
| 58 |
+
index: 0,
|
| 59 |
+
numPoints: 9
|
| 60 |
+
};
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Initialize the camera
|
| 64 |
+
async initCamera(localVideoRef, displayCanvasRef) {
|
| 65 |
+
try {
|
| 66 |
+
console.log('Initializing local camera...');
|
| 67 |
+
|
| 68 |
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
| 69 |
+
video: {
|
| 70 |
+
width: { ideal: 640 },
|
| 71 |
+
height: { ideal: 480 },
|
| 72 |
+
facingMode: 'user'
|
| 73 |
+
},
|
| 74 |
+
audio: false
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
this.localVideoElement = localVideoRef;
|
| 78 |
+
this.displayCanvas = displayCanvasRef;
|
| 79 |
+
|
| 80 |
+
// Show the local camera stream
|
| 81 |
+
if (this.localVideoElement) {
|
| 82 |
+
this.localVideoElement.srcObject = this.stream;
|
| 83 |
+
this.localVideoElement.play();
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Capture at 640x480 for L2CS / gaze (matches HF commit 2eba0cc).
|
| 87 |
+
this.canvas = document.createElement('canvas');
|
| 88 |
+
this.canvas.width = 640;
|
| 89 |
+
this.canvas.height = 480;
|
| 90 |
+
|
| 91 |
+
console.log('Local camera initialized');
|
| 92 |
+
return true;
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.error('Camera init error:', error);
|
| 95 |
+
throw error;
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Start streaming
|
| 100 |
+
async startStreaming() {
|
| 101 |
+
if (!this.stream) {
|
| 102 |
+
throw new Error('Camera not initialized');
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
if (this.isStreaming) {
|
| 106 |
+
console.warn('Already streaming');
|
| 107 |
+
return;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
console.log('Starting WebSocket streaming...');
|
| 111 |
+
this.isStreaming = true;
|
| 112 |
+
|
| 113 |
+
try {
|
| 114 |
+
// Fetch tessellation topology (once)
|
| 115 |
+
if (!this._tessellation) {
|
| 116 |
+
try {
|
| 117 |
+
const res = await fetch('/api/mesh-topology');
|
| 118 |
+
const data = await res.json();
|
| 119 |
+
this._tessellation = data.tessellation; // [[start, end], ...]
|
| 120 |
+
} catch (e) {
|
| 121 |
+
console.warn('Failed to fetch mesh topology:', e);
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Request notification permission
|
| 126 |
+
await this.requestNotificationPermission();
|
| 127 |
+
await this.loadNotificationSettings();
|
| 128 |
+
|
| 129 |
+
// Open the WebSocket connection
|
| 130 |
+
await this.connectWebSocket();
|
| 131 |
+
|
| 132 |
+
// Start sending captured frames on a timer
|
| 133 |
+
this.startCapture();
|
| 134 |
+
|
| 135 |
+
// Start continuous render loop for smooth video
|
| 136 |
+
this._lastDetection = null;
|
| 137 |
+
this._startRenderLoop();
|
| 138 |
+
|
| 139 |
+
console.log('Streaming started');
|
| 140 |
+
} catch (error) {
|
| 141 |
+
this.isStreaming = false;
|
| 142 |
+
this._stopRenderLoop();
|
| 143 |
+
this._lastDetection = null;
|
| 144 |
+
|
| 145 |
+
if (this.captureInterval) {
|
| 146 |
+
clearInterval(this.captureInterval);
|
| 147 |
+
this.captureInterval = null;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if (this.reconnectTimeout) {
|
| 151 |
+
clearTimeout(this.reconnectTimeout);
|
| 152 |
+
this.reconnectTimeout = null;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
if (this.ws) {
|
| 156 |
+
this.ws.onopen = null;
|
| 157 |
+
this.ws.onmessage = null;
|
| 158 |
+
this.ws.onerror = null;
|
| 159 |
+
this.ws.onclose = null;
|
| 160 |
+
try {
|
| 161 |
+
this.ws.close();
|
| 162 |
+
} catch (_) {}
|
| 163 |
+
this.ws = null;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
throw error instanceof Error ? error : new Error('Failed to start video streaming.');
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// Connect the WebSocket
|
| 171 |
+
async connectWebSocket() {
|
| 172 |
+
return new Promise((resolve, reject) => {
|
| 173 |
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 174 |
+
const wsUrl = `${protocol}//${window.location.host}/ws/video`;
|
| 175 |
+
|
| 176 |
+
console.log('Connecting to WebSocket:', wsUrl);
|
| 177 |
+
|
| 178 |
+
const socket = new WebSocket(wsUrl);
|
| 179 |
+
this.ws = socket;
|
| 180 |
+
|
| 181 |
+
let settled = false;
|
| 182 |
+
let opened = false;
|
| 183 |
+
const rejectWithMessage = (message) => {
|
| 184 |
+
if (settled) return;
|
| 185 |
+
settled = true;
|
| 186 |
+
reject(new Error(message));
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
socket.onopen = () => {
|
| 190 |
+
opened = true;
|
| 191 |
+
settled = true;
|
| 192 |
+
console.log('WebSocket connected');
|
| 193 |
+
|
| 194 |
+
// Send the start-session control message
|
| 195 |
+
socket.send(JSON.stringify({ type: 'start_session' }));
|
| 196 |
+
resolve();
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
socket.onmessage = (event) => {
|
| 200 |
+
try {
|
| 201 |
+
const data = JSON.parse(event.data);
|
| 202 |
+
this.handleServerMessage(data);
|
| 203 |
+
} catch (e) {
|
| 204 |
+
console.error('Failed to parse message:', e);
|
| 205 |
+
}
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
socket.onerror = () => {
|
| 209 |
+
console.error('WebSocket error:', { url: wsUrl, readyState: socket.readyState });
|
| 210 |
+
rejectWithMessage(`Failed to connect to ${wsUrl}. Check that the backend server is running and reachable.`);
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
socket.onclose = (event) => {
|
| 214 |
+
console.log('WebSocket disconnected', event.code, event.reason);
|
| 215 |
+
if (this.ws === socket) {
|
| 216 |
+
this.ws = null;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
if (!opened) {
|
| 220 |
+
rejectWithMessage(`WebSocket closed before connection was established (${event.code || 'no code'}). Check that the backend server is running on the expected port.`);
|
| 221 |
+
return;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
if (this.isStreaming) {
|
| 225 |
+
console.log('Attempting to reconnect...');
|
| 226 |
+
if (this.reconnectTimeout) {
|
| 227 |
+
clearTimeout(this.reconnectTimeout);
|
| 228 |
+
}
|
| 229 |
+
this.reconnectTimeout = setTimeout(() => {
|
| 230 |
+
this.reconnectTimeout = null;
|
| 231 |
+
if (!this.isStreaming) return;
|
| 232 |
+
this.connectWebSocket().catch((error) => {
|
| 233 |
+
console.error('Reconnect failed:', error);
|
| 234 |
+
});
|
| 235 |
+
}, 2000);
|
| 236 |
+
}
|
| 237 |
+
};
|
| 238 |
+
});
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// Capture and send frames (binary blobs for speed)
|
| 242 |
+
startCapture() {
|
| 243 |
+
const interval = 1000 / this.frameRate;
|
| 244 |
+
this._sendingBlob = false; // prevent overlapping toBlob calls
|
| 245 |
+
|
| 246 |
+
this.captureInterval = setInterval(() => {
|
| 247 |
+
if (!this.isStreaming || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
| 248 |
+
if (this._sendingBlob) return; // previous frame still encoding, skip
|
| 249 |
+
|
| 250 |
+
try {
|
| 251 |
+
const ctx = this.canvas.getContext('2d');
|
| 252 |
+
ctx.drawImage(this.localVideoElement, 0, 0, this.canvas.width, this.canvas.height);
|
| 253 |
+
|
| 254 |
+
this._sendingBlob = true;
|
| 255 |
+
this.canvas.toBlob((blob) => {
|
| 256 |
+
this._sendingBlob = false;
|
| 257 |
+
if (blob && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
| 258 |
+
this.ws.send(blob);
|
| 259 |
+
this.stats.framesSent++;
|
| 260 |
+
}
|
| 261 |
+
}, 'image/jpeg', 0.75);
|
| 262 |
+
} catch (error) {
|
| 263 |
+
this._sendingBlob = false;
|
| 264 |
+
console.error('Capture error:', error);
|
| 265 |
+
}
|
| 266 |
+
}, interval);
|
| 267 |
+
|
| 268 |
+
console.log(`Capturing at ${this.frameRate} FPS`);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// Continuous render loop: draws camera feed + last detection overlay at display refresh rate
|
| 272 |
+
_startRenderLoop() {
|
| 273 |
+
const render = () => {
|
| 274 |
+
if (!this.isStreaming) return;
|
| 275 |
+
if (this.displayCanvas && this.localVideoElement && this.localVideoElement.readyState >= 2) {
|
| 276 |
+
const ctx = this.displayCanvas.getContext('2d');
|
| 277 |
+
const w = this.displayCanvas.width;
|
| 278 |
+
const h = this.displayCanvas.height;
|
| 279 |
+
|
| 280 |
+
// Always draw the live camera feed
|
| 281 |
+
ctx.drawImage(this.localVideoElement, 0, 0, w, h);
|
| 282 |
+
|
| 283 |
+
// Overlay last known detection results
|
| 284 |
+
const data = this._lastDetection;
|
| 285 |
+
if (data) {
|
| 286 |
+
const isL2cs = data.model === 'l2cs';
|
| 287 |
+
if (data.landmarks && !isL2cs) {
|
| 288 |
+
this.drawFaceMesh(ctx, data.landmarks, w, h);
|
| 289 |
+
}
|
| 290 |
+
// Top HUD bar (matching live_demo.py)
|
| 291 |
+
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
| 292 |
+
ctx.fillRect(0, 0, w, 55);
|
| 293 |
+
|
| 294 |
+
const statusText = data.focused ? 'FOCUSED' : 'NOT FOCUSED';
|
| 295 |
+
const color = data.focused ? '#00FF00' : '#FF0000';
|
| 296 |
+
ctx.fillStyle = color;
|
| 297 |
+
ctx.font = 'bold 18px Arial';
|
| 298 |
+
ctx.fillText(statusText, 10, 22);
|
| 299 |
+
|
| 300 |
+
// Model name + mesh label (top right)
|
| 301 |
+
if (data.model) {
|
| 302 |
+
ctx.fillStyle = '#FFFFFF';
|
| 303 |
+
ctx.font = '12px Arial';
|
| 304 |
+
ctx.textAlign = 'right';
|
| 305 |
+
ctx.fillText(data.model.toUpperCase(), w - 10, 22);
|
| 306 |
+
ctx.textAlign = 'left';
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
// Detail line: conf, S_face, S_eye, MAR
|
| 310 |
+
ctx.fillStyle = '#FFFFFF';
|
| 311 |
+
ctx.font = '12px Arial';
|
| 312 |
+
let detail = `conf:${(data.confidence || 0).toFixed(2)}`;
|
| 313 |
+
if (data.sf !== undefined) detail += ` S_face:${data.sf.toFixed(2)}`;
|
| 314 |
+
if (data.se !== undefined) detail += ` S_eye:${data.se.toFixed(2)}`;
|
| 315 |
+
if (data.mar !== undefined) detail += ` MAR:${data.mar.toFixed(2)}`;
|
| 316 |
+
ctx.fillText(detail, 10, 38);
|
| 317 |
+
|
| 318 |
+
// Head pose angles (right side)
|
| 319 |
+
if (data.yaw !== undefined) {
|
| 320 |
+
ctx.fillStyle = '#B4B4B4';
|
| 321 |
+
ctx.font = '11px Arial';
|
| 322 |
+
ctx.textAlign = 'right';
|
| 323 |
+
ctx.fillText(`yaw:${data.yaw > 0 ? '+' : ''}${data.yaw.toFixed(0)} pitch:${data.pitch > 0 ? '+' : ''}${data.pitch.toFixed(0)} roll:${data.roll > 0 ? '+' : ''}${data.roll.toFixed(0)}`, w - 10, 48);
|
| 324 |
+
ctx.textAlign = 'left';
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
// Gaze pointer removed from camera — shown in mini-map only.
|
| 328 |
+
|
| 329 |
+
// Eye gaze (L2CS): iris-based arrows matching live_demo.py
|
| 330 |
+
if (isL2cs && data.landmarks) {
|
| 331 |
+
const lm = data.landmarks;
|
| 332 |
+
const getPt = (idx) => {
|
| 333 |
+
if (!lm) return null;
|
| 334 |
+
if (Array.isArray(lm)) return lm[idx] || null;
|
| 335 |
+
return lm[String(idx)] || null;
|
| 336 |
+
};
|
| 337 |
+
|
| 338 |
+
// Draw eye contours (green)
|
| 339 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.LEFT_EYE, w, h, '#00FF00', 2, true);
|
| 340 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.RIGHT_EYE, w, h, '#00FF00', 2, true);
|
| 341 |
+
|
| 342 |
+
// EAR key points (yellow)
|
| 343 |
+
for (const earIndices of [VideoManagerLocal.LEFT_EAR_POINTS, VideoManagerLocal.RIGHT_EAR_POINTS]) {
|
| 344 |
+
for (const idx of earIndices) {
|
| 345 |
+
const pt = getPt(idx);
|
| 346 |
+
if (!pt) continue;
|
| 347 |
+
ctx.beginPath();
|
| 348 |
+
ctx.arc(pt[0] * w, pt[1] * h, 3, 0, 2 * Math.PI);
|
| 349 |
+
ctx.fillStyle = '#FFFF00';
|
| 350 |
+
ctx.fill();
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// Irises + gaze lines (matching live_demo.py)
|
| 355 |
+
const irisSets = [
|
| 356 |
+
{ iris: VideoManagerLocal.LEFT_IRIS, inner: 133, outer: 33 },
|
| 357 |
+
{ iris: VideoManagerLocal.RIGHT_IRIS, inner: 362, outer: 263 },
|
| 358 |
+
];
|
| 359 |
+
for (const { iris, inner, outer } of irisSets) {
|
| 360 |
+
const centerPt = getPt(iris[0]);
|
| 361 |
+
if (!centerPt) continue;
|
| 362 |
+
const cx = centerPt[0] * w, cy = centerPt[1] * h;
|
| 363 |
+
|
| 364 |
+
// Iris circle (magenta)
|
| 365 |
+
let radiusSum = 0, count = 0;
|
| 366 |
+
for (let i = 1; i < iris.length; i++) {
|
| 367 |
+
const pt = getPt(iris[i]);
|
| 368 |
+
if (!pt) continue;
|
| 369 |
+
radiusSum += Math.hypot(pt[0] * w - cx, pt[1] * h - cy);
|
| 370 |
+
count++;
|
| 371 |
+
}
|
| 372 |
+
const radius = Math.max(count > 0 ? radiusSum / count : 3, 2);
|
| 373 |
+
ctx.beginPath();
|
| 374 |
+
ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
|
| 375 |
+
ctx.strokeStyle = '#FF00FF';
|
| 376 |
+
ctx.lineWidth = 2;
|
| 377 |
+
ctx.stroke();
|
| 378 |
+
|
| 379 |
+
// Iris center dot (white)
|
| 380 |
+
ctx.beginPath();
|
| 381 |
+
ctx.arc(cx, cy, 2, 0, 2 * Math.PI);
|
| 382 |
+
ctx.fillStyle = '#FFFFFF';
|
| 383 |
+
ctx.fill();
|
| 384 |
+
|
| 385 |
+
// Gaze direction line (red) — from iris center, 3x displacement
|
| 386 |
+
const innerPt = getPt(inner);
|
| 387 |
+
const outerPt = getPt(outer);
|
| 388 |
+
if (innerPt && outerPt) {
|
| 389 |
+
const eyeCx = (innerPt[0] + outerPt[0]) / 2.0 * w;
|
| 390 |
+
const eyeCy = (innerPt[1] + outerPt[1]) / 2.0 * h;
|
| 391 |
+
const dx = cx - eyeCx;
|
| 392 |
+
const dy = cy - eyeCy;
|
| 393 |
+
ctx.beginPath();
|
| 394 |
+
ctx.moveTo(cx, cy);
|
| 395 |
+
ctx.lineTo(cx + dx * 3, cy + dy * 3);
|
| 396 |
+
ctx.strokeStyle = '#FF0000';
|
| 397 |
+
ctx.lineWidth = 1;
|
| 398 |
+
ctx.stroke();
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
// Performance stats
|
| 404 |
+
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
| 405 |
+
ctx.fillRect(0, h - 25, w, 25);
|
| 406 |
+
ctx.font = '12px Arial';
|
| 407 |
+
ctx.fillStyle = '#FFFFFF';
|
| 408 |
+
ctx.fillText(`FPS: ${this.frameRate} | Latency: ${this.stats.avgLatency.toFixed(0)}ms`, 10, h - 8);
|
| 409 |
+
}
|
| 410 |
+
this._animFrameId = requestAnimationFrame(render);
|
| 411 |
+
};
|
| 412 |
+
this._animFrameId = requestAnimationFrame(render);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
_stopRenderLoop() {
|
| 416 |
+
if (this._animFrameId) {
|
| 417 |
+
cancelAnimationFrame(this._animFrameId);
|
| 418 |
+
this._animFrameId = null;
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
// Handle messages from the server
|
| 423 |
+
handleServerMessage(data) {
|
| 424 |
+
switch (data.type) {
|
| 425 |
+
case 'session_started':
|
| 426 |
+
this.sessionId = data.session_id;
|
| 427 |
+
this.sessionStartTime = Date.now();
|
| 428 |
+
console.log('Session started:', this.sessionId);
|
| 429 |
+
if (this.callbacks.onSessionStart) {
|
| 430 |
+
this.callbacks.onSessionStart(this.sessionId);
|
| 431 |
+
}
|
| 432 |
+
break;
|
| 433 |
+
|
| 434 |
+
case 'detection':
|
| 435 |
+
this.stats.framesProcessed++;
|
| 436 |
+
|
| 437 |
+
// Track latency from send→receive
|
| 438 |
+
const now = performance.now();
|
| 439 |
+
if (this._lastSendTime) {
|
| 440 |
+
const latency = now - this._lastSendTime;
|
| 441 |
+
this.stats.lastLatencies.push(latency);
|
| 442 |
+
if (this.stats.lastLatencies.length > 10) this.stats.lastLatencies.shift();
|
| 443 |
+
this.stats.avgLatency = this.stats.lastLatencies.reduce((a, b) => a + b, 0) / this.stats.lastLatencies.length;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
this.updateStatus(data.focused);
|
| 447 |
+
|
| 448 |
+
this.latestDetectionData = {
|
| 449 |
+
confidence: data.confidence || 0,
|
| 450 |
+
focused: data.focused,
|
| 451 |
+
timestamp: now
|
| 452 |
+
};
|
| 453 |
+
this.lastConfidence = data.confidence || 0;
|
| 454 |
+
|
| 455 |
+
if (this.callbacks.onStatusUpdate) {
|
| 456 |
+
this.callbacks.onStatusUpdate(this.currentStatus);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// Normalize response: server sends 'lm' (sparse) or 'landmarks'
|
| 460 |
+
const detectionData = {
|
| 461 |
+
focused: data.focused,
|
| 462 |
+
confidence: data.confidence || 0,
|
| 463 |
+
model: data.model,
|
| 464 |
+
landmarks: data.lm || data.landmarks || null,
|
| 465 |
+
yaw: data.yaw,
|
| 466 |
+
pitch: data.pitch,
|
| 467 |
+
roll: data.roll,
|
| 468 |
+
mar: data.mar,
|
| 469 |
+
sf: data.sf,
|
| 470 |
+
se: data.se,
|
| 471 |
+
gaze_x: data.gaze_x,
|
| 472 |
+
gaze_y: data.gaze_y,
|
| 473 |
+
gaze_yaw: data.gaze_yaw,
|
| 474 |
+
gaze_pitch: data.gaze_pitch,
|
| 475 |
+
on_screen: data.on_screen,
|
| 476 |
+
};
|
| 477 |
+
this.drawDetectionResult(detectionData);
|
| 478 |
+
|
| 479 |
+
// Emit gaze data for mini-map
|
| 480 |
+
if (this.callbacks.onGazeData) {
|
| 481 |
+
this.callbacks.onGazeData({
|
| 482 |
+
gaze_x: data.gaze_x != null ? data.gaze_x : null,
|
| 483 |
+
gaze_y: data.gaze_y != null ? data.gaze_y : null,
|
| 484 |
+
on_screen: data.on_screen != null ? data.on_screen : null,
|
| 485 |
+
});
|
| 486 |
+
}
|
| 487 |
+
break;
|
| 488 |
+
|
| 489 |
+
case 'calibration_started':
|
| 490 |
+
this.calibrationState = {
|
| 491 |
+
active: true,
|
| 492 |
+
collecting: true,
|
| 493 |
+
done: false,
|
| 494 |
+
success: false,
|
| 495 |
+
target: data.target || [0.5, 0.5],
|
| 496 |
+
index: data.index ?? 0,
|
| 497 |
+
numPoints: data.num_points ?? 9,
|
| 498 |
+
};
|
| 499 |
+
if (this.callbacks.onCalibrationUpdate) {
|
| 500 |
+
this.callbacks.onCalibrationUpdate(this.calibrationState);
|
| 501 |
+
}
|
| 502 |
+
break;
|
| 503 |
+
|
| 504 |
+
case 'calibration_point':
|
| 505 |
+
this.calibrationState = {
|
| 506 |
+
...this.calibrationState,
|
| 507 |
+
target: data.target || [0.5, 0.5],
|
| 508 |
+
index: data.index ?? this.calibrationState.index,
|
| 509 |
+
};
|
| 510 |
+
if (this.callbacks.onCalibrationUpdate) {
|
| 511 |
+
this.callbacks.onCalibrationUpdate(this.calibrationState);
|
| 512 |
+
}
|
| 513 |
+
break;
|
| 514 |
+
|
| 515 |
+
case 'calibration_done':
|
| 516 |
+
this.calibrationState = {
|
| 517 |
+
...this.calibrationState,
|
| 518 |
+
active: true,
|
| 519 |
+
collecting: false,
|
| 520 |
+
done: true,
|
| 521 |
+
success: data.success === true,
|
| 522 |
+
error: data.error || null,
|
| 523 |
+
};
|
| 524 |
+
if (this.callbacks.onCalibrationUpdate) {
|
| 525 |
+
this.callbacks.onCalibrationUpdate(this.calibrationState);
|
| 526 |
+
}
|
| 527 |
+
break;
|
| 528 |
+
|
| 529 |
+
case 'calibration_cancelled':
|
| 530 |
+
this.calibrationState = {
|
| 531 |
+
active: false,
|
| 532 |
+
collecting: false,
|
| 533 |
+
done: false,
|
| 534 |
+
success: false,
|
| 535 |
+
target: [0.5, 0.5],
|
| 536 |
+
index: 0,
|
| 537 |
+
numPoints: 9,
|
| 538 |
+
};
|
| 539 |
+
if (this.callbacks.onCalibrationUpdate) {
|
| 540 |
+
this.callbacks.onCalibrationUpdate(this.calibrationState);
|
| 541 |
+
}
|
| 542 |
+
break;
|
| 543 |
+
|
| 544 |
+
case 'session_ended':
|
| 545 |
+
console.log('Received session_ended message');
|
| 546 |
+
console.log('Session summary:', data.summary);
|
| 547 |
+
if (this.callbacks.onSessionEnd) {
|
| 548 |
+
console.log('Calling onSessionEnd callback');
|
| 549 |
+
this.callbacks.onSessionEnd(data.summary);
|
| 550 |
+
} else {
|
| 551 |
+
console.warn('No onSessionEnd callback registered');
|
| 552 |
+
}
|
| 553 |
+
this.sessionId = null;
|
| 554 |
+
this.sessionStartTime = null;
|
| 555 |
+
break;
|
| 556 |
+
|
| 557 |
+
case 'error':
|
| 558 |
+
console.error('Server error:', data.message);
|
| 559 |
+
break;
|
| 560 |
+
|
| 561 |
+
default:
|
| 562 |
+
console.log('Unknown message type:', data.type);
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
// Face mesh landmark index groups (matches live_demo.py)
|
| 567 |
+
static FACE_OVAL = [10,338,297,332,284,251,389,356,454,323,361,288,397,365,379,378,400,377,152,148,176,149,150,136,172,58,132,93,234,127,162,21,54,103,67,109,10];
|
| 568 |
+
static LEFT_EYE = [33,7,163,144,145,153,154,155,133,173,157,158,159,160,161,246];
|
| 569 |
+
static RIGHT_EYE = [362,382,381,380,374,373,390,249,263,466,388,387,386,385,384,398];
|
| 570 |
+
static LEFT_IRIS = [468,469,470,471,472];
|
| 571 |
+
static RIGHT_IRIS = [473,474,475,476,477];
|
| 572 |
+
static LEFT_EYEBROW = [70,63,105,66,107,55,65,52,53,46];
|
| 573 |
+
static RIGHT_EYEBROW = [300,293,334,296,336,285,295,282,283,276];
|
| 574 |
+
static NOSE_BRIDGE = [6,197,195,5,4,1,19,94,2];
|
| 575 |
+
static LIPS_OUTER = [61,146,91,181,84,17,314,405,321,375,291,409,270,269,267,0,37,39,40,185,61];
|
| 576 |
+
static LIPS_INNER = [78,95,88,178,87,14,317,402,318,324,308,415,310,311,312,13,82,81,80,191,78];
|
| 577 |
+
static LEFT_EAR_POINTS = [33, 160, 158, 133, 153, 145];
|
| 578 |
+
static RIGHT_EAR_POINTS = [362, 385, 387, 263, 373, 380];
|
| 579 |
+
// Iris/eye corners for gaze lines
|
| 580 |
+
static LEFT_IRIS_CENTER = 468;
|
| 581 |
+
static RIGHT_IRIS_CENTER = 473;
|
| 582 |
+
static LEFT_EYE_INNER = 133;
|
| 583 |
+
static LEFT_EYE_OUTER = 33;
|
| 584 |
+
static RIGHT_EYE_INNER = 362;
|
| 585 |
+
static RIGHT_EYE_OUTER = 263;
|
| 586 |
+
|
| 587 |
+
// Draw a polyline through landmark indices (lm can be array or sparse object)
|
| 588 |
+
_drawPolyline(ctx, lm, indices, w, h, color, lineWidth, closed = false) {
|
| 589 |
+
if (!lm || indices.length < 2) return;
|
| 590 |
+
const isArray = Array.isArray(lm);
|
| 591 |
+
const _get = isArray ? (i) => lm[i] : (i) => lm[String(i)];
|
| 592 |
+
ctx.beginPath();
|
| 593 |
+
const firstPt = _get(indices[0]);
|
| 594 |
+
if (!firstPt) return;
|
| 595 |
+
ctx.moveTo(firstPt[0] * w, firstPt[1] * h);
|
| 596 |
+
for (let i = 1; i < indices.length; i++) {
|
| 597 |
+
const pt = _get(indices[i]);
|
| 598 |
+
if (!pt) continue;
|
| 599 |
+
ctx.lineTo(pt[0] * w, pt[1] * h);
|
| 600 |
+
}
|
| 601 |
+
if (closed) ctx.closePath();
|
| 602 |
+
ctx.strokeStyle = color;
|
| 603 |
+
ctx.lineWidth = lineWidth;
|
| 604 |
+
ctx.stroke();
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
// Draw face mesh overlay from landmarks (supports both array and sparse object)
|
| 608 |
+
drawFaceMesh(ctx, lm, w, h) {
|
| 609 |
+
if (!lm) return;
|
| 610 |
+
const isArray = Array.isArray(lm);
|
| 611 |
+
// For array format need at least 468 entries; for sparse object just check it has keys
|
| 612 |
+
if (isArray && lm.length < 468) return;
|
| 613 |
+
if (!isArray && typeof lm === 'object' && Object.keys(lm).length === 0) return;
|
| 614 |
+
|
| 615 |
+
const _get = isArray ? (i) => lm[i] : (i) => lm[String(i)];
|
| 616 |
+
|
| 617 |
+
// Tessellation (gray triangular grid, semi-transparent)
|
| 618 |
+
if (this._tessellation && isArray) {
|
| 619 |
+
ctx.strokeStyle = 'rgba(200,200,200,0.25)';
|
| 620 |
+
ctx.lineWidth = 1;
|
| 621 |
+
ctx.beginPath();
|
| 622 |
+
for (const [s, e] of this._tessellation) {
|
| 623 |
+
const ps = lm[s], pe = lm[e];
|
| 624 |
+
if (!ps || !pe) continue;
|
| 625 |
+
ctx.moveTo(ps[0] * w, ps[1] * h);
|
| 626 |
+
ctx.lineTo(pe[0] * w, pe[1] * h);
|
| 627 |
+
}
|
| 628 |
+
ctx.stroke();
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
// Face oval
|
| 632 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.FACE_OVAL, w, h, 'rgba(0,255,255,0.5)', 1, true);
|
| 633 |
+
// Eyebrows
|
| 634 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.LEFT_EYEBROW, w, h, '#90EE90', 2);
|
| 635 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.RIGHT_EYEBROW, w, h, '#90EE90', 2);
|
| 636 |
+
// Eyes
|
| 637 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.LEFT_EYE, w, h, '#00FF00', 2, true);
|
| 638 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.RIGHT_EYE, w, h, '#00FF00', 2, true);
|
| 639 |
+
// Nose bridge
|
| 640 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.NOSE_BRIDGE, w, h, 'rgba(0,165,255,0.6)', 1);
|
| 641 |
+
// Lips
|
| 642 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.LIPS_OUTER, w, h, '#FF00FF', 1);
|
| 643 |
+
this._drawPolyline(ctx, lm, VideoManagerLocal.LIPS_INNER, w, h, 'rgba(200,0,200,0.7)', 1);
|
| 644 |
+
|
| 645 |
+
// EAR key points (yellow dots)
|
| 646 |
+
for (const earIndices of [VideoManagerLocal.LEFT_EAR_POINTS, VideoManagerLocal.RIGHT_EAR_POINTS]) {
|
| 647 |
+
for (const idx of earIndices) {
|
| 648 |
+
const pt = _get(idx);
|
| 649 |
+
if (!pt) continue;
|
| 650 |
+
ctx.beginPath();
|
| 651 |
+
ctx.arc(pt[0] * w, pt[1] * h, 3, 0, 2 * Math.PI);
|
| 652 |
+
ctx.fillStyle = '#FFFF00';
|
| 653 |
+
ctx.fill();
|
| 654 |
+
}
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
// Irises (circles + gaze direction lines)
|
| 658 |
+
const irisSets = [
|
| 659 |
+
{ iris: VideoManagerLocal.LEFT_IRIS, center: VideoManagerLocal.LEFT_IRIS_CENTER, inner: VideoManagerLocal.LEFT_EYE_INNER, outer: VideoManagerLocal.LEFT_EYE_OUTER },
|
| 660 |
+
{ iris: VideoManagerLocal.RIGHT_IRIS, center: VideoManagerLocal.RIGHT_IRIS_CENTER, inner: VideoManagerLocal.RIGHT_EYE_INNER, outer: VideoManagerLocal.RIGHT_EYE_OUTER },
|
| 661 |
+
];
|
| 662 |
+
for (const { iris, center: centerIdx, inner, outer } of irisSets) {
|
| 663 |
+
const centerPt = _get(iris[0]);
|
| 664 |
+
if (!centerPt) continue;
|
| 665 |
+
const cx = centerPt[0] * w, cy = centerPt[1] * h;
|
| 666 |
+
let radiusSum = 0, count = 0;
|
| 667 |
+
for (let i = 1; i < iris.length; i++) {
|
| 668 |
+
const pt = _get(iris[i]);
|
| 669 |
+
if (!pt) continue;
|
| 670 |
+
radiusSum += Math.hypot(pt[0] * w - cx, pt[1] * h - cy);
|
| 671 |
+
count++;
|
| 672 |
+
}
|
| 673 |
+
const radius = Math.max(count > 0 ? radiusSum / count : 3, 2);
|
| 674 |
+
// Iris circle
|
| 675 |
+
ctx.beginPath();
|
| 676 |
+
ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
|
| 677 |
+
ctx.strokeStyle = '#FF00FF';
|
| 678 |
+
ctx.lineWidth = 2;
|
| 679 |
+
ctx.stroke();
|
| 680 |
+
// Iris center dot
|
| 681 |
+
ctx.beginPath();
|
| 682 |
+
ctx.arc(cx, cy, 2, 0, 2 * Math.PI);
|
| 683 |
+
ctx.fillStyle = '#FFFFFF';
|
| 684 |
+
ctx.fill();
|
| 685 |
+
// Gaze direction line (red)
|
| 686 |
+
const innerPt = _get(inner);
|
| 687 |
+
const outerPt = _get(outer);
|
| 688 |
+
if (innerPt && outerPt) {
|
| 689 |
+
const eyeCx = (innerPt[0] + outerPt[0]) / 2.0 * w;
|
| 690 |
+
const eyeCy = (innerPt[1] + outerPt[1]) / 2.0 * h;
|
| 691 |
+
const dx = cx - eyeCx;
|
| 692 |
+
const dy = cy - eyeCy;
|
| 693 |
+
ctx.beginPath();
|
| 694 |
+
ctx.moveTo(cx, cy);
|
| 695 |
+
ctx.lineTo(cx + dx * 3, cy + dy * 3);
|
| 696 |
+
ctx.strokeStyle = '#FF0000';
|
| 697 |
+
ctx.lineWidth = 1;
|
| 698 |
+
ctx.stroke();
|
| 699 |
+
}
|
| 700 |
+
}
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
// Store detection data for the render loop to draw
|
| 704 |
+
drawDetectionResult(data) {
|
| 705 |
+
this._lastDetection = data;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
updateStatus(newFocused) {
|
| 709 |
+
this.statusBuffer.push(newFocused);
|
| 710 |
+
if (this.statusBuffer.length > this.bufferSize) {
|
| 711 |
+
this.statusBuffer.shift();
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
if (this.statusBuffer.length < this.bufferSize) return false;
|
| 715 |
+
|
| 716 |
+
const focusedCount = this.statusBuffer.filter(f => f).length;
|
| 717 |
+
const focusedRatio = focusedCount / this.statusBuffer.length;
|
| 718 |
+
|
| 719 |
+
const previousStatus = this.currentStatus;
|
| 720 |
+
|
| 721 |
+
if (focusedRatio >= 0.75) {
|
| 722 |
+
this.currentStatus = true;
|
| 723 |
+
} else if (focusedRatio <= 0.25) {
|
| 724 |
+
this.currentStatus = false;
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
this.handleNotificationLogic(previousStatus, this.currentStatus);
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
handleNotificationLogic(previousStatus, currentStatus) {
|
| 731 |
+
const now = Date.now();
|
| 732 |
+
|
| 733 |
+
if (previousStatus && !currentStatus) {
|
| 734 |
+
this.unfocusedStartTime = now;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
if (!previousStatus && currentStatus) {
|
| 738 |
+
this.unfocusedStartTime = null;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
if (!currentStatus && this.unfocusedStartTime) {
|
| 742 |
+
const unfocusedDuration = (now - this.unfocusedStartTime) / 1000;
|
| 743 |
+
|
| 744 |
+
if (unfocusedDuration >= this.notificationThreshold) {
|
| 745 |
+
const canSendNotification = !this.lastNotificationTime ||
|
| 746 |
+
(now - this.lastNotificationTime) >= this.notificationCooldown;
|
| 747 |
+
|
| 748 |
+
if (canSendNotification) {
|
| 749 |
+
this.sendNotification(
|
| 750 |
+
'Focus Alert',
|
| 751 |
+
`You've been distracted for ${Math.floor(unfocusedDuration)} seconds. Get back to work!`
|
| 752 |
+
);
|
| 753 |
+
this.lastNotificationTime = now;
|
| 754 |
+
}
|
| 755 |
+
}
|
| 756 |
+
}
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
async requestNotificationPermission() {
|
| 760 |
+
if ('Notification' in window && Notification.permission === 'default') {
|
| 761 |
+
try {
|
| 762 |
+
await Notification.requestPermission();
|
| 763 |
+
} catch (error) {
|
| 764 |
+
console.error('Failed to request notification permission:', error);
|
| 765 |
+
}
|
| 766 |
+
}
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
async loadNotificationSettings() {
|
| 770 |
+
try {
|
| 771 |
+
const response = await fetch('/api/settings');
|
| 772 |
+
const settings = await response.json();
|
| 773 |
+
if (settings) {
|
| 774 |
+
this.notificationEnabled = settings.notification_enabled ?? true;
|
| 775 |
+
this.notificationThreshold = settings.notification_threshold ?? 30;
|
| 776 |
+
}
|
| 777 |
+
} catch (error) {
|
| 778 |
+
console.error('Failed to load notification settings:', error);
|
| 779 |
+
}
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
sendNotification(title, message) {
|
| 783 |
+
if (!this.notificationEnabled) return;
|
| 784 |
+
if ('Notification' in window && Notification.permission === 'granted') {
|
| 785 |
+
try {
|
| 786 |
+
const notification = new Notification(title, {
|
| 787 |
+
body: message,
|
| 788 |
+
icon: '/vite.svg',
|
| 789 |
+
badge: '/vite.svg',
|
| 790 |
+
tag: 'focus-guard-distraction',
|
| 791 |
+
requireInteraction: false
|
| 792 |
+
});
|
| 793 |
+
setTimeout(() => notification.close(), 3000);
|
| 794 |
+
} catch (error) {
|
| 795 |
+
console.error('Failed to send notification:', error);
|
| 796 |
+
}
|
| 797 |
+
}
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
async stopStreaming() {
|
| 801 |
+
console.log('Stopping streaming...');
|
| 802 |
+
|
| 803 |
+
this.isStreaming = false;
|
| 804 |
+
|
| 805 |
+
if (this.reconnectTimeout) {
|
| 806 |
+
clearTimeout(this.reconnectTimeout);
|
| 807 |
+
this.reconnectTimeout = null;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
// Stop the render loop
|
| 811 |
+
this._stopRenderLoop();
|
| 812 |
+
this._lastDetection = null;
|
| 813 |
+
|
| 814 |
+
// Stop frame capture
|
| 815 |
+
if (this.captureInterval) {
|
| 816 |
+
clearInterval(this.captureInterval);
|
| 817 |
+
this.captureInterval = null;
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
// Send the end-session request and wait for the response
|
| 821 |
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.sessionId) {
|
| 822 |
+
const sessionId = this.sessionId;
|
| 823 |
+
|
| 824 |
+
// Wait for the session_ended message
|
| 825 |
+
const waitForSessionEnd = new Promise((resolve) => {
|
| 826 |
+
const originalHandler = this.ws.onmessage;
|
| 827 |
+
const timeout = setTimeout(() => {
|
| 828 |
+
this.ws.onmessage = originalHandler;
|
| 829 |
+
console.log('Session end timeout, proceeding anyway');
|
| 830 |
+
resolve();
|
| 831 |
+
}, 2000);
|
| 832 |
+
|
| 833 |
+
this.ws.onmessage = (event) => {
|
| 834 |
+
try {
|
| 835 |
+
const data = JSON.parse(event.data);
|
| 836 |
+
if (data.type === 'session_ended') {
|
| 837 |
+
clearTimeout(timeout);
|
| 838 |
+
this.handleServerMessage(data);
|
| 839 |
+
this.ws.onmessage = originalHandler;
|
| 840 |
+
resolve();
|
| 841 |
+
} else {
|
| 842 |
+
// Continue handling non-terminal messages
|
| 843 |
+
this.handleServerMessage(data);
|
| 844 |
+
}
|
| 845 |
+
} catch (e) {
|
| 846 |
+
console.error('Failed to parse message:', e);
|
| 847 |
+
}
|
| 848 |
+
};
|
| 849 |
+
});
|
| 850 |
+
|
| 851 |
+
console.log('Sending end_session request for session:', sessionId);
|
| 852 |
+
this.ws.send(JSON.stringify({
|
| 853 |
+
type: 'end_session',
|
| 854 |
+
session_id: sessionId
|
| 855 |
+
}));
|
| 856 |
+
|
| 857 |
+
// Wait for the response or a timeout
|
| 858 |
+
await waitForSessionEnd;
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
// Delay socket shutdown briefly so pending messages can flush
|
| 862 |
+
await new Promise(resolve => setTimeout(resolve, 200));
|
| 863 |
+
|
| 864 |
+
// Close the WebSocket
|
| 865 |
+
if (this.ws) {
|
| 866 |
+
this.ws.close();
|
| 867 |
+
this.ws = null;
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
// Stop the camera
|
| 871 |
+
if (this.stream) {
|
| 872 |
+
this.stream.getTracks().forEach(track => track.stop());
|
| 873 |
+
this.stream = null;
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
// Clear the video element
|
| 877 |
+
if (this.localVideoElement) {
|
| 878 |
+
this.localVideoElement.srcObject = null;
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
// Clear the canvas
|
| 882 |
+
if (this.displayCanvas) {
|
| 883 |
+
const ctx = this.displayCanvas.getContext('2d');
|
| 884 |
+
ctx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height);
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
// Reset transient state
|
| 888 |
+
this.unfocusedStartTime = null;
|
| 889 |
+
this.lastNotificationTime = null;
|
| 890 |
+
|
| 891 |
+
console.log('Streaming stopped');
|
| 892 |
+
console.log('Stats:', this.stats);
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
setFrameRate(rate) {
|
| 896 |
+
this.frameRate = Math.max(10, Math.min(30, rate));
|
| 897 |
+
console.log(`Frame rate set to ${this.frameRate} FPS`);
|
| 898 |
+
|
| 899 |
+
// Restart capture if streaming is already active
|
| 900 |
+
if (this.isStreaming && this.captureInterval) {
|
| 901 |
+
clearInterval(this.captureInterval);
|
| 902 |
+
this.startCapture();
|
| 903 |
+
}
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
startCalibration() {
|
| 907 |
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
| 908 |
+
this.ws.send(JSON.stringify({ type: 'calibration_start' }));
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
nextCalibrationPoint() {
|
| 912 |
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
| 913 |
+
this.ws.send(JSON.stringify({ type: 'calibration_next' }));
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
cancelCalibration() {
|
| 917 |
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
| 918 |
+
this.ws.send(JSON.stringify({ type: 'calibration_cancel' }));
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
getCalibrationState() {
|
| 922 |
+
return this.calibrationState;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
dismissCalibrationDone() {
|
| 926 |
+
this.calibrationState = {
|
| 927 |
+
active: false,
|
| 928 |
+
collecting: false,
|
| 929 |
+
done: false,
|
| 930 |
+
success: false,
|
| 931 |
+
target: [0.5, 0.5],
|
| 932 |
+
index: 0,
|
| 933 |
+
numPoints: 9,
|
| 934 |
+
};
|
| 935 |
+
if (this.callbacks.onCalibrationUpdate) {
|
| 936 |
+
this.callbacks.onCalibrationUpdate(this.calibrationState);
|
| 937 |
+
}
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
getStats() {
|
| 941 |
+
return {
|
| 942 |
+
...this.stats,
|
| 943 |
+
isStreaming: this.isStreaming,
|
| 944 |
+
sessionId: this.sessionId,
|
| 945 |
+
currentStatus: this.currentStatus,
|
| 946 |
+
lastConfidence: this.lastConfidence
|
| 947 |
+
};
|
| 948 |
+
}
|
| 949 |
+
}
|