add: page /dosen, /dosen/{id}
Browse files- resources/css/app.css +959 -22
- resources/js/components/auth/register-role-form.tsx +200 -7
- resources/js/components/dosen/dosen-shell.tsx +200 -0
- resources/js/components/student/question-composer.tsx +27 -13
- resources/js/components/student/student-shell.tsx +276 -160
- resources/js/lib/rag.ts +148 -0
- resources/js/pages/auth/login.tsx +14 -4
- resources/js/pages/dosen-detail.tsx +618 -0
- resources/js/pages/dosen.tsx +541 -0
- resources/js/pages/dosen/pengaturan.tsx +22 -0
- resources/js/pages/mahasiswa-chat.tsx +152 -19
- resources/js/pages/mahasiswa.tsx +29 -12
- routes/web.php +8 -0
- tests/Feature/DosenDashboardTest.php +23 -0
resources/css/app.css
CHANGED
|
@@ -109,6 +109,7 @@ :root {
|
|
| 109 |
--app-primary-soft: #ccdcff;
|
| 110 |
--app-primary-foreground: #ffffff;
|
| 111 |
--app-danger: #ef4444;
|
|
|
|
| 112 |
--app-placeholder: rgba(115, 115, 115, 0.42);
|
| 113 |
--app-toggle-bg: rgba(255, 255, 255, 0.9);
|
| 114 |
--app-shadow: 1px 1px 4.5px rgba(39, 100, 235, 0.36);
|
|
@@ -161,6 +162,22 @@ :root {
|
|
| 161 |
--student-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
|
| 162 |
--student-sidebar-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
|
| 163 |
--student-composer-shadow: var(--app-composer-shadow);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
}
|
| 165 |
|
| 166 |
.dark {
|
|
@@ -211,6 +228,7 @@ .dark {
|
|
| 211 |
--app-primary-soft: #c8d7ff;
|
| 212 |
--app-primary-foreground: #ffffff;
|
| 213 |
--app-danger: #ff4545;
|
|
|
|
| 214 |
--app-placeholder: rgba(244, 244, 245, 0.42);
|
| 215 |
--app-toggle-bg: #171717;
|
| 216 |
--app-shadow: 0 0 20px rgba(52, 120, 255, 0.34);
|
|
@@ -259,6 +277,26 @@ .dark {
|
|
| 259 |
--student-shadow: 0 24px 70px rgba(0, 0, 0, 0.18);
|
| 260 |
--student-sidebar-shadow: 1px 0 0 var(--app-border);
|
| 261 |
--student-composer-shadow: var(--app-composer-shadow);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
}
|
| 263 |
|
| 264 |
@layer base {
|
|
@@ -269,6 +307,17 @@ @layer base {
|
|
| 269 |
body {
|
| 270 |
@apply bg-background text-foreground;
|
| 271 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
}
|
| 273 |
|
| 274 |
@layer components {
|
|
@@ -282,7 +331,7 @@ @layer components {
|
|
| 282 |
}
|
| 283 |
|
| 284 |
.welcome-theme-toggle {
|
| 285 |
-
@apply absolute
|
| 286 |
background-color: var(--welcome-toggle-bg);
|
| 287 |
border-color: var(--welcome-card-active-border);
|
| 288 |
color: var(--welcome-accent);
|
|
@@ -295,7 +344,7 @@ @layer components {
|
|
| 295 |
}
|
| 296 |
|
| 297 |
.welcome-title {
|
| 298 |
-
@apply text-center text-[34px]
|
| 299 |
color: var(--welcome-title);
|
| 300 |
}
|
| 301 |
|
|
@@ -345,7 +394,7 @@ @layer components {
|
|
| 345 |
}
|
| 346 |
|
| 347 |
.welcome-entry-title {
|
| 348 |
-
@apply truncate text-left text-lg
|
| 349 |
color: var(--welcome-title);
|
| 350 |
}
|
| 351 |
|
|
@@ -365,7 +414,7 @@ @layer components {
|
|
| 365 |
}
|
| 366 |
|
| 367 |
.welcome-register-link {
|
| 368 |
-
@apply font-medium underline-offset-4 hover:underline focus-visible:
|
| 369 |
color: var(--welcome-accent);
|
| 370 |
--tw-ring-color: color-mix(
|
| 371 |
in oklab,
|
|
@@ -414,7 +463,7 @@ @layer components {
|
|
| 414 |
}
|
| 415 |
|
| 416 |
.register-back-link {
|
| 417 |
-
@apply inline-flex text-sm font-semibold underline-offset-4 transition-colors hover:underline focus-visible:
|
| 418 |
color: var(--welcome-subtitle);
|
| 419 |
--tw-ring-color: color-mix(
|
| 420 |
in oklab,
|
|
@@ -424,7 +473,7 @@ @layer components {
|
|
| 424 |
}
|
| 425 |
|
| 426 |
.register-heading {
|
| 427 |
-
@apply mt-2 text-center text-2xl
|
| 428 |
color: var(--welcome-title);
|
| 429 |
}
|
| 430 |
|
|
@@ -437,7 +486,7 @@ @layer components {
|
|
| 437 |
}
|
| 438 |
|
| 439 |
.register-label {
|
| 440 |
-
@apply text-base
|
| 441 |
color: var(--welcome-title);
|
| 442 |
}
|
| 443 |
|
|
@@ -462,6 +511,20 @@ @layer components {
|
|
| 462 |
);
|
| 463 |
}
|
| 464 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
.register-submit {
|
| 466 |
@apply mt-1 h-[43px] w-full rounded-[6px] border text-base font-semibold shadow-none;
|
| 467 |
background-color: var(--welcome-accent) !important;
|
|
@@ -498,7 +561,7 @@ @layer components {
|
|
| 498 |
}
|
| 499 |
|
| 500 |
.login-remember-label {
|
| 501 |
-
@apply text-sm
|
| 502 |
color: var(--welcome-subtitle);
|
| 503 |
}
|
| 504 |
|
|
@@ -520,7 +583,7 @@ @layer components {
|
|
| 520 |
}
|
| 521 |
|
| 522 |
.student-dashboard-shell {
|
| 523 |
-
@apply min-h-svh;
|
| 524 |
background-color: var(--student-bg);
|
| 525 |
color: var(--student-text);
|
| 526 |
}
|
|
@@ -548,7 +611,7 @@ @layer components {
|
|
| 548 |
}
|
| 549 |
|
| 550 |
.student-logo-action {
|
| 551 |
-
@apply flex size-8 shrink-0 items-center justify-center rounded-[8px]
|
| 552 |
--tw-ring-color: color-mix(
|
| 553 |
in oklab,
|
| 554 |
var(--student-primary) 45%,
|
|
@@ -639,7 +702,7 @@ @layer components {
|
|
| 639 |
}
|
| 640 |
|
| 641 |
.student-history-delete {
|
| 642 |
-
@apply absolute top-1/2
|
| 643 |
color: var(--student-danger);
|
| 644 |
}
|
| 645 |
|
|
@@ -656,6 +719,71 @@ @layer components {
|
|
| 656 |
);
|
| 657 |
}
|
| 658 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
.student-history-menu-icon {
|
| 660 |
@apply hidden;
|
| 661 |
color: var(--student-text);
|
|
@@ -667,7 +795,7 @@ @layer components {
|
|
| 667 |
}
|
| 668 |
|
| 669 |
.student-history-title {
|
| 670 |
-
@apply block truncate text-[11px]
|
| 671 |
color: var(--student-text);
|
| 672 |
}
|
| 673 |
|
|
@@ -702,7 +830,7 @@ @layer components {
|
|
| 702 |
}
|
| 703 |
|
| 704 |
.student-user-name {
|
| 705 |
-
@apply block truncate text-[11px]
|
| 706 |
color: var(--student-text);
|
| 707 |
}
|
| 708 |
|
|
@@ -766,13 +894,13 @@ @layer components {
|
|
| 766 |
}
|
| 767 |
|
| 768 |
.student-workspace {
|
| 769 |
-
@apply flex min-w-0 flex-1 flex-col;
|
| 770 |
background-color: var(--student-bg);
|
| 771 |
color: var(--student-text);
|
| 772 |
}
|
| 773 |
|
| 774 |
.student-topbar {
|
| 775 |
-
@apply flex h-[72px] shrink-0 items-center justify-between border-b px-5 sm:px-6;
|
| 776 |
background-color: var(--student-surface);
|
| 777 |
border-color: var(--student-border);
|
| 778 |
}
|
|
@@ -800,7 +928,7 @@ @layer components {
|
|
| 800 |
}
|
| 801 |
|
| 802 |
.student-chat-stage {
|
| 803 |
-
@apply flex flex-1 items-center justify-center px-4 py-10;
|
| 804 |
}
|
| 805 |
|
| 806 |
.student-empty-state {
|
|
@@ -818,7 +946,7 @@ @layer components {
|
|
| 818 |
}
|
| 819 |
|
| 820 |
.student-greeting h2 {
|
| 821 |
-
@apply text-3xl
|
| 822 |
color: var(--student-text);
|
| 823 |
}
|
| 824 |
|
|
@@ -891,17 +1019,17 @@ @layer components {
|
|
| 891 |
}
|
| 892 |
|
| 893 |
.student-floating-send {
|
| 894 |
-
@apply absolute
|
| 895 |
background-color: var(--student-primary) !important;
|
| 896 |
color: var(--student-primary-foreground) !important;
|
| 897 |
}
|
| 898 |
|
| 899 |
.student-chat-stage--session {
|
| 900 |
-
@apply flex-col items-stretch justify-between overflow-hidden px-5 py-0;
|
| 901 |
}
|
| 902 |
|
| 903 |
.student-chat-thread {
|
| 904 |
-
@apply min-h-0 w-full flex-1 overflow-y-auto py-8;
|
| 905 |
}
|
| 906 |
|
| 907 |
.student-chat-messages {
|
|
@@ -914,7 +1042,7 @@ @layer components {
|
|
| 914 |
}
|
| 915 |
|
| 916 |
.student-chat-status {
|
| 917 |
-
@apply
|
| 918 |
}
|
| 919 |
|
| 920 |
.student-message {
|
|
@@ -957,7 +1085,7 @@ @layer components {
|
|
| 957 |
}
|
| 958 |
|
| 959 |
.student-message-content {
|
| 960 |
-
@apply
|
| 961 |
}
|
| 962 |
|
| 963 |
.student-message-time {
|
|
@@ -1005,6 +1133,775 @@ @layer components {
|
|
| 1005 |
color: var(--student-muted);
|
| 1006 |
}
|
| 1007 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1008 |
@media (max-width: 640px) {
|
| 1009 |
.student-chat-stage--session {
|
| 1010 |
@apply px-3;
|
|
@@ -1017,5 +1914,45 @@ @layer components {
|
|
| 1017 |
.student-chat-composer {
|
| 1018 |
@apply pb-4;
|
| 1019 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1020 |
}
|
| 1021 |
}
|
|
|
|
| 109 |
--app-primary-soft: #ccdcff;
|
| 110 |
--app-primary-foreground: #ffffff;
|
| 111 |
--app-danger: #ef4444;
|
| 112 |
+
--app-success: #16a34a;
|
| 113 |
--app-placeholder: rgba(115, 115, 115, 0.42);
|
| 114 |
--app-toggle-bg: rgba(255, 255, 255, 0.9);
|
| 115 |
--app-shadow: 1px 1px 4.5px rgba(39, 100, 235, 0.36);
|
|
|
|
| 162 |
--student-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
|
| 163 |
--student-sidebar-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
|
| 164 |
--student-composer-shadow: var(--app-composer-shadow);
|
| 165 |
+
--lecturer-bg: var(--app-bg);
|
| 166 |
+
--lecturer-sidebar-bg: var(--app-sidebar-bg);
|
| 167 |
+
--lecturer-surface: var(--app-surface);
|
| 168 |
+
--lecturer-surface-soft: var(--app-surface-soft);
|
| 169 |
+
--lecturer-border: var(--app-border);
|
| 170 |
+
--lecturer-border-soft: var(--app-border-soft);
|
| 171 |
+
--lecturer-border-strong: var(--app-border-strong);
|
| 172 |
+
--lecturer-text: var(--app-text);
|
| 173 |
+
--lecturer-muted: var(--app-muted);
|
| 174 |
+
--lecturer-primary: var(--app-primary);
|
| 175 |
+
--lecturer-primary-soft: var(--app-primary-soft);
|
| 176 |
+
--lecturer-primary-foreground: var(--app-primary-foreground);
|
| 177 |
+
--lecturer-danger: var(--app-danger);
|
| 178 |
+
--lecturer-success: var(--app-success);
|
| 179 |
+
--lecturer-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
| 180 |
+
--lecturer-sidebar-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
|
| 181 |
}
|
| 182 |
|
| 183 |
.dark {
|
|
|
|
| 228 |
--app-primary-soft: #c8d7ff;
|
| 229 |
--app-primary-foreground: #ffffff;
|
| 230 |
--app-danger: #ff4545;
|
| 231 |
+
--app-success: #22c55e;
|
| 232 |
--app-placeholder: rgba(244, 244, 245, 0.42);
|
| 233 |
--app-toggle-bg: #171717;
|
| 234 |
--app-shadow: 0 0 20px rgba(52, 120, 255, 0.34);
|
|
|
|
| 277 |
--student-shadow: 0 24px 70px rgba(0, 0, 0, 0.18);
|
| 278 |
--student-sidebar-shadow: 1px 0 0 var(--app-border);
|
| 279 |
--student-composer-shadow: var(--app-composer-shadow);
|
| 280 |
+
--lecturer-bg: var(--app-bg);
|
| 281 |
+
--lecturer-sidebar-bg: var(--app-sidebar-bg);
|
| 282 |
+
--lecturer-surface: var(--app-surface);
|
| 283 |
+
--lecturer-surface-soft: var(--app-surface-soft);
|
| 284 |
+
--lecturer-border: var(--app-border);
|
| 285 |
+
--lecturer-border-soft: var(--app-border-soft);
|
| 286 |
+
--lecturer-border-strong: var(--app-border-strong);
|
| 287 |
+
--lecturer-text: var(--app-text);
|
| 288 |
+
--lecturer-muted: var(--app-muted);
|
| 289 |
+
--lecturer-primary: var(--app-primary);
|
| 290 |
+
--lecturer-primary-soft: color-mix(
|
| 291 |
+
in oklab,
|
| 292 |
+
var(--app-primary) 22%,
|
| 293 |
+
var(--app-surface)
|
| 294 |
+
);
|
| 295 |
+
--lecturer-primary-foreground: var(--app-primary-foreground);
|
| 296 |
+
--lecturer-danger: var(--app-danger);
|
| 297 |
+
--lecturer-success: var(--app-success);
|
| 298 |
+
--lecturer-shadow: 0 18px 42px rgba(0, 0, 0, 0.2);
|
| 299 |
+
--lecturer-sidebar-shadow: 1px 0 0 var(--app-border);
|
| 300 |
}
|
| 301 |
|
| 302 |
@layer base {
|
|
|
|
| 307 |
body {
|
| 308 |
@apply bg-background text-foreground;
|
| 309 |
}
|
| 310 |
+
|
| 311 |
+
button:not(:disabled),
|
| 312 |
+
[role='button']:not([aria-disabled='true']),
|
| 313 |
+
[data-slot='button']:not([aria-disabled='true']) {
|
| 314 |
+
@apply cursor-pointer;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
button:disabled,
|
| 318 |
+
[aria-disabled='true'] {
|
| 319 |
+
@apply cursor-not-allowed;
|
| 320 |
+
}
|
| 321 |
}
|
| 322 |
|
| 323 |
@layer components {
|
|
|
|
| 331 |
}
|
| 332 |
|
| 333 |
.welcome-theme-toggle {
|
| 334 |
+
@apply absolute right-4 top-4 h-10 w-10 rounded-[10px] shadow-none backdrop-blur sm:right-8 sm:top-8;
|
| 335 |
background-color: var(--welcome-toggle-bg);
|
| 336 |
border-color: var(--welcome-card-active-border);
|
| 337 |
color: var(--welcome-accent);
|
|
|
|
| 344 |
}
|
| 345 |
|
| 346 |
.welcome-title {
|
| 347 |
+
@apply text-center text-[34px] font-bold leading-tight tracking-[0.01em] sm:text-[40px];
|
| 348 |
color: var(--welcome-title);
|
| 349 |
}
|
| 350 |
|
|
|
|
| 394 |
}
|
| 395 |
|
| 396 |
.welcome-entry-title {
|
| 397 |
+
@apply truncate text-left text-lg font-semibold leading-tight;
|
| 398 |
color: var(--welcome-title);
|
| 399 |
}
|
| 400 |
|
|
|
|
| 414 |
}
|
| 415 |
|
| 416 |
.welcome-register-link {
|
| 417 |
+
@apply font-medium underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2;
|
| 418 |
color: var(--welcome-accent);
|
| 419 |
--tw-ring-color: color-mix(
|
| 420 |
in oklab,
|
|
|
|
| 463 |
}
|
| 464 |
|
| 465 |
.register-back-link {
|
| 466 |
+
@apply inline-flex text-sm font-semibold underline-offset-4 transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2;
|
| 467 |
color: var(--welcome-subtitle);
|
| 468 |
--tw-ring-color: color-mix(
|
| 469 |
in oklab,
|
|
|
|
| 473 |
}
|
| 474 |
|
| 475 |
.register-heading {
|
| 476 |
+
@apply mt-2 text-center text-2xl font-semibold leading-tight;
|
| 477 |
color: var(--welcome-title);
|
| 478 |
}
|
| 479 |
|
|
|
|
| 486 |
}
|
| 487 |
|
| 488 |
.register-label {
|
| 489 |
+
@apply text-base font-medium leading-none;
|
| 490 |
color: var(--welcome-title);
|
| 491 |
}
|
| 492 |
|
|
|
|
| 511 |
);
|
| 512 |
}
|
| 513 |
|
| 514 |
+
.register-input[aria-invalid='true'] {
|
| 515 |
+
border-color: var(--register-danger) !important;
|
| 516 |
+
--tw-ring-color: color-mix(
|
| 517 |
+
in oklab,
|
| 518 |
+
var(--register-danger) 28%,
|
| 519 |
+
transparent
|
| 520 |
+
);
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
.register-error {
|
| 524 |
+
@apply text-sm font-medium leading-snug;
|
| 525 |
+
color: var(--register-danger);
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
.register-submit {
|
| 529 |
@apply mt-1 h-[43px] w-full rounded-[6px] border text-base font-semibold shadow-none;
|
| 530 |
background-color: var(--welcome-accent) !important;
|
|
|
|
| 561 |
}
|
| 562 |
|
| 563 |
.login-remember-label {
|
| 564 |
+
@apply text-sm font-normal leading-none;
|
| 565 |
color: var(--welcome-subtitle);
|
| 566 |
}
|
| 567 |
|
|
|
|
| 583 |
}
|
| 584 |
|
| 585 |
.student-dashboard-shell {
|
| 586 |
+
@apply h-svh min-h-svh overflow-hidden;
|
| 587 |
background-color: var(--student-bg);
|
| 588 |
color: var(--student-text);
|
| 589 |
}
|
|
|
|
| 611 |
}
|
| 612 |
|
| 613 |
.student-logo-action {
|
| 614 |
+
@apply flex size-8 shrink-0 items-center justify-center rounded-[8px] outline-none transition-transform focus-visible:ring-2;
|
| 615 |
--tw-ring-color: color-mix(
|
| 616 |
in oklab,
|
| 617 |
var(--student-primary) 45%,
|
|
|
|
| 702 |
}
|
| 703 |
|
| 704 |
.student-history-delete {
|
| 705 |
+
@apply absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-[6px] opacity-0 transition-opacity;
|
| 706 |
color: var(--student-danger);
|
| 707 |
}
|
| 708 |
|
|
|
|
| 719 |
);
|
| 720 |
}
|
| 721 |
|
| 722 |
+
.student-delete-dialog {
|
| 723 |
+
@apply gap-5 rounded-[16px] border p-6 shadow-none sm:max-w-[440px];
|
| 724 |
+
background-color: var(--student-surface);
|
| 725 |
+
border-color: var(--student-border);
|
| 726 |
+
box-shadow: var(--student-shadow);
|
| 727 |
+
color: var(--student-text);
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
.student-delete-dialog-body {
|
| 731 |
+
@apply flex flex-col gap-3;
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
.student-delete-copy {
|
| 735 |
+
@apply text-sm leading-relaxed;
|
| 736 |
+
color: var(--student-muted);
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
.student-delete-copy strong {
|
| 740 |
+
color: var(--student-text);
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
.student-delete-error {
|
| 744 |
+
@apply rounded-[10px] border px-4 py-3 text-sm font-medium;
|
| 745 |
+
background-color: color-mix(
|
| 746 |
+
in oklab,
|
| 747 |
+
var(--student-danger) 8%,
|
| 748 |
+
var(--student-surface)
|
| 749 |
+
);
|
| 750 |
+
border-color: color-mix(
|
| 751 |
+
in oklab,
|
| 752 |
+
var(--student-danger) 24%,
|
| 753 |
+
transparent
|
| 754 |
+
);
|
| 755 |
+
color: var(--student-danger);
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
.student-delete-dialog-footer {
|
| 759 |
+
@apply gap-3 pt-1;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
.student-delete-cancel {
|
| 763 |
+
@apply h-10 rounded-[8px] px-5 text-sm font-semibold shadow-none;
|
| 764 |
+
background-color: var(--student-surface) !important;
|
| 765 |
+
border-color: var(--student-border) !important;
|
| 766 |
+
color: var(--student-text) !important;
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
.student-delete-cancel:hover {
|
| 770 |
+
background-color: var(--student-surface-soft) !important;
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
.student-delete-confirm {
|
| 774 |
+
@apply h-10 rounded-[8px] px-5 text-sm font-semibold shadow-none;
|
| 775 |
+
background-color: var(--student-danger) !important;
|
| 776 |
+
color: var(--student-primary-foreground) !important;
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
.student-delete-confirm:hover {
|
| 780 |
+
background-color: color-mix(
|
| 781 |
+
in oklab,
|
| 782 |
+
var(--student-danger) 86%,
|
| 783 |
+
#000
|
| 784 |
+
) !important;
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
.student-history-menu-icon {
|
| 788 |
@apply hidden;
|
| 789 |
color: var(--student-text);
|
|
|
|
| 795 |
}
|
| 796 |
|
| 797 |
.student-history-title {
|
| 798 |
+
@apply block truncate text-[11px] font-medium leading-tight;
|
| 799 |
color: var(--student-text);
|
| 800 |
}
|
| 801 |
|
|
|
|
| 830 |
}
|
| 831 |
|
| 832 |
.student-user-name {
|
| 833 |
+
@apply block truncate text-[11px] font-semibold leading-tight;
|
| 834 |
color: var(--student-text);
|
| 835 |
}
|
| 836 |
|
|
|
|
| 894 |
}
|
| 895 |
|
| 896 |
.student-workspace {
|
| 897 |
+
@apply flex h-svh min-w-0 flex-1 flex-col overflow-hidden;
|
| 898 |
background-color: var(--student-bg);
|
| 899 |
color: var(--student-text);
|
| 900 |
}
|
| 901 |
|
| 902 |
.student-topbar {
|
| 903 |
+
@apply sticky top-0 z-20 flex h-[72px] shrink-0 items-center justify-between border-b px-5 sm:px-6;
|
| 904 |
background-color: var(--student-surface);
|
| 905 |
border-color: var(--student-border);
|
| 906 |
}
|
|
|
|
| 928 |
}
|
| 929 |
|
| 930 |
.student-chat-stage {
|
| 931 |
+
@apply flex min-h-0 flex-1 items-center justify-center px-4 py-10;
|
| 932 |
}
|
| 933 |
|
| 934 |
.student-empty-state {
|
|
|
|
| 946 |
}
|
| 947 |
|
| 948 |
.student-greeting h2 {
|
| 949 |
+
@apply text-3xl font-bold leading-tight tracking-[-0.02em];
|
| 950 |
color: var(--student-text);
|
| 951 |
}
|
| 952 |
|
|
|
|
| 1019 |
}
|
| 1020 |
|
| 1021 |
.student-floating-send {
|
| 1022 |
+
@apply absolute bottom-4 right-5 size-8 rounded-[8px] shadow-none;
|
| 1023 |
background-color: var(--student-primary) !important;
|
| 1024 |
color: var(--student-primary-foreground) !important;
|
| 1025 |
}
|
| 1026 |
|
| 1027 |
.student-chat-stage--session {
|
| 1028 |
+
@apply min-h-0 flex-col items-stretch justify-between overflow-hidden px-5 py-0;
|
| 1029 |
}
|
| 1030 |
|
| 1031 |
.student-chat-thread {
|
| 1032 |
+
@apply min-h-0 w-full flex-1 overflow-y-auto overscroll-contain py-8;
|
| 1033 |
}
|
| 1034 |
|
| 1035 |
.student-chat-messages {
|
|
|
|
| 1042 |
}
|
| 1043 |
|
| 1044 |
.student-chat-status {
|
| 1045 |
+
@apply mb-4 mt-0;
|
| 1046 |
}
|
| 1047 |
|
| 1048 |
.student-message {
|
|
|
|
| 1085 |
}
|
| 1086 |
|
| 1087 |
.student-message-content {
|
| 1088 |
+
@apply whitespace-pre-wrap leading-relaxed;
|
| 1089 |
}
|
| 1090 |
|
| 1091 |
.student-message-time {
|
|
|
|
| 1133 |
color: var(--student-muted);
|
| 1134 |
}
|
| 1135 |
|
| 1136 |
+
.lecturer-dashboard-shell {
|
| 1137 |
+
@apply min-h-svh;
|
| 1138 |
+
background-color: var(--lecturer-bg);
|
| 1139 |
+
color: var(--lecturer-text);
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
.lecturer-sidebar {
|
| 1143 |
+
border-color: var(--lecturer-border) !important;
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
.lecturer-sidebar [data-sidebar='sidebar'] {
|
| 1147 |
+
background-color: var(--lecturer-sidebar-bg);
|
| 1148 |
+
border-color: var(--lecturer-border);
|
| 1149 |
+
box-shadow: var(--lecturer-sidebar-shadow);
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
.lecturer-sidebar-header {
|
| 1153 |
+
@apply border-b;
|
| 1154 |
+
border-color: var(--lecturer-border);
|
| 1155 |
+
display: flex !important;
|
| 1156 |
+
height: 72px !important;
|
| 1157 |
+
flex-direction: row !important;
|
| 1158 |
+
align-items: center !important;
|
| 1159 |
+
justify-content: space-between !important;
|
| 1160 |
+
gap: 0 !important;
|
| 1161 |
+
padding: 0 20px !important;
|
| 1162 |
+
}
|
| 1163 |
+
|
| 1164 |
+
.lecturer-brand {
|
| 1165 |
+
@apply flex min-w-0 items-center gap-3 rounded-[10px] text-left outline-none focus-visible:ring-2;
|
| 1166 |
+
color: var(--lecturer-text);
|
| 1167 |
+
flex: 1 1 auto;
|
| 1168 |
+
--tw-ring-color: color-mix(
|
| 1169 |
+
in oklab,
|
| 1170 |
+
var(--lecturer-primary) 45%,
|
| 1171 |
+
transparent
|
| 1172 |
+
);
|
| 1173 |
+
}
|
| 1174 |
+
|
| 1175 |
+
.lecturer-brand-mark {
|
| 1176 |
+
@apply flex size-8 shrink-0 items-center justify-center rounded-[8px];
|
| 1177 |
+
background-color: var(--lecturer-primary);
|
| 1178 |
+
color: var(--lecturer-primary-foreground);
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
.lecturer-brand-copy {
|
| 1182 |
+
@apply truncate text-base font-bold;
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
.lecturer-sidebar-trigger {
|
| 1186 |
+
@apply rounded-[8px] shadow-none;
|
| 1187 |
+
color: var(--lecturer-text);
|
| 1188 |
+
width: 32px !important;
|
| 1189 |
+
height: 32px !important;
|
| 1190 |
+
}
|
| 1191 |
+
|
| 1192 |
+
.lecturer-sidebar-trigger:hover {
|
| 1193 |
+
background-color: var(--lecturer-surface-soft);
|
| 1194 |
+
color: var(--lecturer-text);
|
| 1195 |
+
}
|
| 1196 |
+
|
| 1197 |
+
.lecturer-sidebar-content {
|
| 1198 |
+
gap: 0 !important;
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
.lecturer-nav-group {
|
| 1202 |
+
padding: 16px 20px !important;
|
| 1203 |
+
}
|
| 1204 |
+
|
| 1205 |
+
.lecturer-nav-list {
|
| 1206 |
+
gap: 8px !important;
|
| 1207 |
+
}
|
| 1208 |
+
|
| 1209 |
+
.lecturer-nav-button {
|
| 1210 |
+
@apply justify-start gap-2 rounded-[8px] text-xs font-semibold shadow-none;
|
| 1211 |
+
color: var(--lecturer-muted);
|
| 1212 |
+
height: 34px !important;
|
| 1213 |
+
padding: 0 10px !important;
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
.lecturer-nav-button:hover,
|
| 1217 |
+
.lecturer-nav-button[data-active='true'] {
|
| 1218 |
+
background-color: var(--lecturer-primary-soft);
|
| 1219 |
+
color: var(--lecturer-primary);
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
.lecturer-sidebar-footer {
|
| 1223 |
+
@apply mt-auto border-t;
|
| 1224 |
+
border-color: var(--lecturer-border);
|
| 1225 |
+
padding: 16px 20px !important;
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
.lecturer-user-button {
|
| 1229 |
+
@apply mb-3 w-fit gap-2 rounded-[6px] shadow-none;
|
| 1230 |
+
background-color: var(--lecturer-surface-soft);
|
| 1231 |
+
color: var(--lecturer-text);
|
| 1232 |
+
height: auto !important;
|
| 1233 |
+
padding: 6px 8px !important;
|
| 1234 |
+
}
|
| 1235 |
+
|
| 1236 |
+
.lecturer-user-button:hover {
|
| 1237 |
+
background-color: var(--lecturer-surface-soft);
|
| 1238 |
+
color: var(--lecturer-text);
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
.lecturer-user-avatar {
|
| 1242 |
+
@apply size-6 text-[8px];
|
| 1243 |
+
background-color: var(--lecturer-surface);
|
| 1244 |
+
color: var(--lecturer-text);
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
.lecturer-user-copy {
|
| 1248 |
+
@apply min-w-0;
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
.lecturer-user-name {
|
| 1252 |
+
@apply block truncate text-[11px] font-semibold leading-tight;
|
| 1253 |
+
color: var(--lecturer-text);
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
.lecturer-user-role {
|
| 1257 |
+
@apply block truncate text-[10px] leading-tight;
|
| 1258 |
+
color: var(--lecturer-muted);
|
| 1259 |
+
}
|
| 1260 |
+
|
| 1261 |
+
.lecturer-logout-button {
|
| 1262 |
+
@apply justify-start gap-1 rounded-none text-[11px] font-normal shadow-none;
|
| 1263 |
+
color: var(--lecturer-danger);
|
| 1264 |
+
height: auto !important;
|
| 1265 |
+
padding: 0 !important;
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
.lecturer-logout-button:hover {
|
| 1269 |
+
background-color: transparent;
|
| 1270 |
+
color: var(--lecturer-danger);
|
| 1271 |
+
}
|
| 1272 |
+
|
| 1273 |
+
.group[data-collapsible='icon'] .lecturer-sidebar-header {
|
| 1274 |
+
justify-content: center !important;
|
| 1275 |
+
padding: 0 !important;
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
.group[data-collapsible='icon'] .lecturer-brand-copy,
|
| 1279 |
+
.group[data-collapsible='icon'] .lecturer-nav-button span,
|
| 1280 |
+
.group[data-collapsible='icon'] .lecturer-user-copy,
|
| 1281 |
+
.group[data-collapsible='icon'] .lecturer-logout-button span {
|
| 1282 |
+
@apply hidden;
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
.group[data-collapsible='icon'] .lecturer-brand {
|
| 1286 |
+
flex: 0 0 auto;
|
| 1287 |
+
justify-content: center;
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
.group[data-collapsible='icon'] .lecturer-sidebar-trigger {
|
| 1291 |
+
display: none !important;
|
| 1292 |
+
}
|
| 1293 |
+
|
| 1294 |
+
.group[data-collapsible='icon'] .lecturer-nav-group {
|
| 1295 |
+
padding: 20px 12px !important;
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
.group[data-collapsible='icon'] .lecturer-nav-button,
|
| 1299 |
+
.group[data-collapsible='icon'] .lecturer-user-button,
|
| 1300 |
+
.group[data-collapsible='icon'] .lecturer-logout-button {
|
| 1301 |
+
@apply mx-auto flex items-center justify-center rounded-[9px];
|
| 1302 |
+
width: 32px !important;
|
| 1303 |
+
height: 32px !important;
|
| 1304 |
+
padding: 0 !important;
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
.group[data-collapsible='icon'] .lecturer-sidebar-footer {
|
| 1308 |
+
@apply px-3;
|
| 1309 |
+
}
|
| 1310 |
+
|
| 1311 |
+
.lecturer-workspace {
|
| 1312 |
+
@apply flex min-w-0 flex-1 flex-col;
|
| 1313 |
+
background-color: var(--lecturer-bg);
|
| 1314 |
+
color: var(--lecturer-text);
|
| 1315 |
+
}
|
| 1316 |
+
|
| 1317 |
+
.lecturer-page {
|
| 1318 |
+
@apply w-full px-5 py-8 sm:px-8 lg:px-10;
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
.lecturer-page-header {
|
| 1322 |
+
@apply flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between;
|
| 1323 |
+
}
|
| 1324 |
+
|
| 1325 |
+
.lecturer-page-header h1 {
|
| 1326 |
+
@apply text-3xl font-bold leading-tight tracking-[-0.03em];
|
| 1327 |
+
color: var(--lecturer-text);
|
| 1328 |
+
}
|
| 1329 |
+
|
| 1330 |
+
.lecturer-page-header p {
|
| 1331 |
+
@apply mt-1 text-sm leading-relaxed;
|
| 1332 |
+
color: var(--lecturer-muted);
|
| 1333 |
+
}
|
| 1334 |
+
|
| 1335 |
+
.lecturer-header-actions {
|
| 1336 |
+
@apply flex items-center gap-3;
|
| 1337 |
+
}
|
| 1338 |
+
|
| 1339 |
+
.lecturer-theme-toggle {
|
| 1340 |
+
@apply static size-9 rounded-[9px] shadow-none sm:static;
|
| 1341 |
+
background-color: var(--lecturer-surface) !important;
|
| 1342 |
+
border-color: var(--lecturer-border-strong) !important;
|
| 1343 |
+
color: var(--lecturer-primary) !important;
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
.lecturer-create-button {
|
| 1347 |
+
@apply h-9 rounded-[8px] px-4 text-xs font-semibold shadow-none;
|
| 1348 |
+
background-color: var(--lecturer-primary) !important;
|
| 1349 |
+
color: var(--lecturer-primary-foreground) !important;
|
| 1350 |
+
}
|
| 1351 |
+
|
| 1352 |
+
.lecturer-cancel-button {
|
| 1353 |
+
@apply h-10 rounded-[8px] px-5 text-sm font-semibold shadow-none;
|
| 1354 |
+
background-color: var(--lecturer-surface) !important;
|
| 1355 |
+
border-color: var(--lecturer-border) !important;
|
| 1356 |
+
color: var(--lecturer-text) !important;
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
.lecturer-cancel-button:hover {
|
| 1360 |
+
background-color: var(--lecturer-surface-soft) !important;
|
| 1361 |
+
}
|
| 1362 |
+
|
| 1363 |
+
.lecturer-course-dialog {
|
| 1364 |
+
@apply gap-6 rounded-[16px] border p-6 shadow-none sm:max-w-[460px];
|
| 1365 |
+
background-color: var(--lecturer-surface);
|
| 1366 |
+
border-color: var(--lecturer-border-strong);
|
| 1367 |
+
box-shadow: var(--lecturer-shadow);
|
| 1368 |
+
color: var(--lecturer-text);
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
.lecturer-course-form {
|
| 1372 |
+
@apply flex flex-col gap-5;
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
.lecturer-form-field {
|
| 1376 |
+
@apply flex flex-col gap-2;
|
| 1377 |
+
}
|
| 1378 |
+
|
| 1379 |
+
.lecturer-form-label {
|
| 1380 |
+
color: var(--lecturer-text);
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
.lecturer-form-input,
|
| 1384 |
+
.lecturer-form-textarea {
|
| 1385 |
+
@apply w-full rounded-[8px] border px-4 text-sm shadow-none outline-none transition-[border-color,box-shadow];
|
| 1386 |
+
background-color: color-mix(
|
| 1387 |
+
in oklab,
|
| 1388 |
+
var(--lecturer-primary) 6%,
|
| 1389 |
+
var(--lecturer-surface)
|
| 1390 |
+
);
|
| 1391 |
+
border-color: var(--lecturer-border);
|
| 1392 |
+
color: var(--lecturer-text);
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
.lecturer-form-input {
|
| 1396 |
+
@apply h-11;
|
| 1397 |
+
}
|
| 1398 |
+
|
| 1399 |
+
.lecturer-form-textarea {
|
| 1400 |
+
@apply min-h-28 resize-none py-3;
|
| 1401 |
+
}
|
| 1402 |
+
|
| 1403 |
+
.lecturer-form-input::placeholder,
|
| 1404 |
+
.lecturer-form-textarea::placeholder {
|
| 1405 |
+
color: var(--app-placeholder);
|
| 1406 |
+
}
|
| 1407 |
+
|
| 1408 |
+
.lecturer-form-input:focus,
|
| 1409 |
+
.lecturer-form-textarea:focus {
|
| 1410 |
+
border-color: var(--lecturer-primary);
|
| 1411 |
+
box-shadow: 0 0 0 3px
|
| 1412 |
+
color-mix(in oklab, var(--lecturer-primary) 18%, transparent);
|
| 1413 |
+
}
|
| 1414 |
+
|
| 1415 |
+
.lecturer-course-form .lecturer-status {
|
| 1416 |
+
@apply mx-0 mt-0;
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
.lecturer-course-footer {
|
| 1420 |
+
@apply gap-3 pt-1;
|
| 1421 |
+
}
|
| 1422 |
+
|
| 1423 |
+
.lecturer-metric-grid {
|
| 1424 |
+
@apply mt-8 grid grid-cols-1 gap-5 md:grid-cols-3;
|
| 1425 |
+
}
|
| 1426 |
+
|
| 1427 |
+
.lecturer-metric-card {
|
| 1428 |
+
@apply gap-0 rounded-[14px] border py-0 shadow-none;
|
| 1429 |
+
background-color: var(--lecturer-surface);
|
| 1430 |
+
border-color: var(--lecturer-border-soft);
|
| 1431 |
+
box-shadow: var(--lecturer-shadow);
|
| 1432 |
+
}
|
| 1433 |
+
|
| 1434 |
+
.lecturer-metric-content {
|
| 1435 |
+
@apply flex items-center gap-5 px-6 py-6;
|
| 1436 |
+
}
|
| 1437 |
+
|
| 1438 |
+
.lecturer-metric-icon {
|
| 1439 |
+
@apply flex size-12 shrink-0 items-center justify-center rounded-[12px];
|
| 1440 |
+
background-color: var(--lecturer-primary-soft);
|
| 1441 |
+
color: var(--lecturer-primary);
|
| 1442 |
+
}
|
| 1443 |
+
|
| 1444 |
+
.lecturer-metric-icon[data-tone='danger'] {
|
| 1445 |
+
background-color: color-mix(
|
| 1446 |
+
in oklab,
|
| 1447 |
+
var(--lecturer-danger) 12%,
|
| 1448 |
+
var(--lecturer-surface)
|
| 1449 |
+
);
|
| 1450 |
+
color: var(--lecturer-danger);
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
.lecturer-metric-copy p {
|
| 1454 |
+
@apply text-sm;
|
| 1455 |
+
color: var(--lecturer-muted);
|
| 1456 |
+
}
|
| 1457 |
+
|
| 1458 |
+
.lecturer-metric-value-row {
|
| 1459 |
+
@apply mt-1 flex items-end gap-2;
|
| 1460 |
+
}
|
| 1461 |
+
|
| 1462 |
+
.lecturer-metric-value-row strong {
|
| 1463 |
+
@apply text-3xl font-bold leading-none;
|
| 1464 |
+
color: var(--lecturer-text);
|
| 1465 |
+
}
|
| 1466 |
+
|
| 1467 |
+
.lecturer-metric-icon[data-tone='danger']
|
| 1468 |
+
+ .lecturer-metric-copy
|
| 1469 |
+
.lecturer-metric-value-row
|
| 1470 |
+
strong {
|
| 1471 |
+
color: var(--lecturer-danger);
|
| 1472 |
+
}
|
| 1473 |
+
|
| 1474 |
+
.lecturer-metric-value-row span {
|
| 1475 |
+
@apply pb-1 text-xs;
|
| 1476 |
+
color: var(--lecturer-muted);
|
| 1477 |
+
}
|
| 1478 |
+
|
| 1479 |
+
.lecturer-table-card {
|
| 1480 |
+
@apply mt-8 gap-0 overflow-hidden rounded-[14px] border py-0 shadow-none;
|
| 1481 |
+
background-color: var(--lecturer-surface);
|
| 1482 |
+
border-color: var(--lecturer-border);
|
| 1483 |
+
box-shadow: var(--lecturer-shadow);
|
| 1484 |
+
}
|
| 1485 |
+
|
| 1486 |
+
.lecturer-table-content {
|
| 1487 |
+
@apply px-0;
|
| 1488 |
+
}
|
| 1489 |
+
|
| 1490 |
+
.lecturer-table-heading {
|
| 1491 |
+
@apply flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between;
|
| 1492 |
+
border-color: var(--lecturer-border);
|
| 1493 |
+
}
|
| 1494 |
+
|
| 1495 |
+
.lecturer-table-heading h2 {
|
| 1496 |
+
@apply text-base font-semibold;
|
| 1497 |
+
color: var(--lecturer-text);
|
| 1498 |
+
}
|
| 1499 |
+
|
| 1500 |
+
.lecturer-search-field {
|
| 1501 |
+
@apply relative w-full sm:max-w-[320px];
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
.lecturer-search-field svg {
|
| 1505 |
+
@apply pointer-events-none absolute left-3 top-1/2 -translate-y-1/2;
|
| 1506 |
+
color: var(--lecturer-muted);
|
| 1507 |
+
z-index: 1;
|
| 1508 |
+
}
|
| 1509 |
+
|
| 1510 |
+
.lecturer-search-input {
|
| 1511 |
+
@apply h-10 rounded-[9px] border text-sm shadow-none outline-none transition-[border-color,box-shadow];
|
| 1512 |
+
background-color: color-mix(
|
| 1513 |
+
in oklab,
|
| 1514 |
+
var(--lecturer-primary) 5%,
|
| 1515 |
+
var(--lecturer-surface)
|
| 1516 |
+
);
|
| 1517 |
+
border-color: var(--lecturer-border);
|
| 1518 |
+
color: var(--lecturer-text);
|
| 1519 |
+
padding-right: 0.75rem !important;
|
| 1520 |
+
padding-left: 2.5rem !important;
|
| 1521 |
+
}
|
| 1522 |
+
|
| 1523 |
+
.lecturer-search-input::placeholder {
|
| 1524 |
+
color: var(--app-placeholder);
|
| 1525 |
+
}
|
| 1526 |
+
|
| 1527 |
+
.lecturer-search-input:focus {
|
| 1528 |
+
border-color: var(--lecturer-primary);
|
| 1529 |
+
box-shadow: 0 0 0 3px
|
| 1530 |
+
color-mix(in oklab, var(--lecturer-primary) 16%, transparent);
|
| 1531 |
+
}
|
| 1532 |
+
|
| 1533 |
+
.lecturer-status {
|
| 1534 |
+
@apply mx-6 mt-4 rounded-[10px] border px-4 py-3 text-sm font-medium;
|
| 1535 |
+
background-color: color-mix(
|
| 1536 |
+
in oklab,
|
| 1537 |
+
var(--lecturer-danger) 8%,
|
| 1538 |
+
var(--lecturer-surface)
|
| 1539 |
+
);
|
| 1540 |
+
border-color: color-mix(
|
| 1541 |
+
in oklab,
|
| 1542 |
+
var(--lecturer-danger) 24%,
|
| 1543 |
+
transparent
|
| 1544 |
+
);
|
| 1545 |
+
color: var(--lecturer-danger);
|
| 1546 |
+
}
|
| 1547 |
+
|
| 1548 |
+
.lecturer-table-wrap {
|
| 1549 |
+
@apply w-full overflow-x-auto;
|
| 1550 |
+
}
|
| 1551 |
+
|
| 1552 |
+
.lecturer-table {
|
| 1553 |
+
@apply w-full min-w-[620px] border-collapse text-left;
|
| 1554 |
+
}
|
| 1555 |
+
|
| 1556 |
+
.lecturer-table th {
|
| 1557 |
+
@apply px-6 py-4 text-xs font-semibold;
|
| 1558 |
+
color: var(--lecturer-muted);
|
| 1559 |
+
}
|
| 1560 |
+
|
| 1561 |
+
.lecturer-table td {
|
| 1562 |
+
@apply border-t px-6 py-4 text-sm;
|
| 1563 |
+
border-color: var(--lecturer-border);
|
| 1564 |
+
color: var(--lecturer-muted);
|
| 1565 |
+
}
|
| 1566 |
+
|
| 1567 |
+
.lecturer-table tbody tr:hover {
|
| 1568 |
+
background-color: color-mix(
|
| 1569 |
+
in oklab,
|
| 1570 |
+
var(--lecturer-primary) 5%,
|
| 1571 |
+
transparent
|
| 1572 |
+
);
|
| 1573 |
+
}
|
| 1574 |
+
|
| 1575 |
+
.lecturer-kb-name {
|
| 1576 |
+
@apply block max-w-[260px] font-semibold;
|
| 1577 |
+
color: var(--lecturer-text);
|
| 1578 |
+
}
|
| 1579 |
+
|
| 1580 |
+
.lecturer-doc-pill {
|
| 1581 |
+
@apply inline-flex items-center gap-1.5 rounded-[7px] px-2.5 py-1 text-xs font-semibold;
|
| 1582 |
+
background-color: var(--lecturer-surface-soft);
|
| 1583 |
+
color: var(--lecturer-text);
|
| 1584 |
+
}
|
| 1585 |
+
|
| 1586 |
+
.lecturer-doc-pill svg {
|
| 1587 |
+
color: var(--lecturer-primary);
|
| 1588 |
+
}
|
| 1589 |
+
|
| 1590 |
+
.lecturer-doc-error {
|
| 1591 |
+
@apply size-1.5 rounded-full;
|
| 1592 |
+
background-color: var(--lecturer-danger);
|
| 1593 |
+
}
|
| 1594 |
+
|
| 1595 |
+
.lecturer-table-empty {
|
| 1596 |
+
@apply py-10 text-center text-sm;
|
| 1597 |
+
color: var(--lecturer-muted) !important;
|
| 1598 |
+
}
|
| 1599 |
+
|
| 1600 |
+
.lecturer-manage-button {
|
| 1601 |
+
@apply inline-flex items-center justify-center gap-1 rounded-[7px] px-3 py-1.5 text-sm font-semibold outline-none transition-colors focus-visible:ring-2;
|
| 1602 |
+
background-color: color-mix(
|
| 1603 |
+
in oklab,
|
| 1604 |
+
var(--lecturer-primary) 10%,
|
| 1605 |
+
var(--lecturer-surface)
|
| 1606 |
+
);
|
| 1607 |
+
color: var(--lecturer-primary);
|
| 1608 |
+
--tw-ring-color: color-mix(
|
| 1609 |
+
in oklab,
|
| 1610 |
+
var(--lecturer-primary) 45%,
|
| 1611 |
+
transparent
|
| 1612 |
+
);
|
| 1613 |
+
}
|
| 1614 |
+
|
| 1615 |
+
.lecturer-manage-button:hover {
|
| 1616 |
+
background-color: color-mix(
|
| 1617 |
+
in oklab,
|
| 1618 |
+
var(--lecturer-primary) 16%,
|
| 1619 |
+
var(--lecturer-surface)
|
| 1620 |
+
);
|
| 1621 |
+
}
|
| 1622 |
+
|
| 1623 |
+
.lecturer-detail-page {
|
| 1624 |
+
@apply min-h-svh w-full;
|
| 1625 |
+
background-color: var(--lecturer-bg);
|
| 1626 |
+
color: var(--lecturer-text);
|
| 1627 |
+
}
|
| 1628 |
+
|
| 1629 |
+
.lecturer-detail-header {
|
| 1630 |
+
@apply flex flex-col gap-5 px-5 py-8 sm:flex-row sm:items-start sm:px-8 lg:px-10;
|
| 1631 |
+
background-color: var(--lecturer-surface-soft);
|
| 1632 |
+
border-bottom: 1px solid var(--lecturer-border-soft);
|
| 1633 |
+
}
|
| 1634 |
+
|
| 1635 |
+
.lecturer-detail-back {
|
| 1636 |
+
@apply mt-1 inline-flex size-9 shrink-0 items-center justify-center rounded-full outline-none transition-colors focus-visible:ring-2;
|
| 1637 |
+
color: var(--lecturer-muted);
|
| 1638 |
+
--tw-ring-color: color-mix(
|
| 1639 |
+
in oklab,
|
| 1640 |
+
var(--lecturer-primary) 45%,
|
| 1641 |
+
transparent
|
| 1642 |
+
);
|
| 1643 |
+
}
|
| 1644 |
+
|
| 1645 |
+
.lecturer-detail-back:hover {
|
| 1646 |
+
background-color: var(--lecturer-surface);
|
| 1647 |
+
color: var(--lecturer-text);
|
| 1648 |
+
}
|
| 1649 |
+
|
| 1650 |
+
.lecturer-detail-heading {
|
| 1651 |
+
@apply min-w-0 flex-1;
|
| 1652 |
+
}
|
| 1653 |
+
|
| 1654 |
+
.lecturer-detail-title-row {
|
| 1655 |
+
@apply flex flex-wrap items-center gap-4;
|
| 1656 |
+
}
|
| 1657 |
+
|
| 1658 |
+
.lecturer-detail-title-row h1 {
|
| 1659 |
+
@apply text-3xl font-bold leading-tight tracking-[-0.03em];
|
| 1660 |
+
color: var(--lecturer-text);
|
| 1661 |
+
}
|
| 1662 |
+
|
| 1663 |
+
.lecturer-detail-heading p {
|
| 1664 |
+
@apply mt-1 text-base italic leading-relaxed;
|
| 1665 |
+
color: var(--lecturer-muted);
|
| 1666 |
+
}
|
| 1667 |
+
|
| 1668 |
+
.lecturer-detail-actions {
|
| 1669 |
+
@apply flex items-center gap-3 sm:ml-auto;
|
| 1670 |
+
}
|
| 1671 |
+
|
| 1672 |
+
.lecturer-edit-button {
|
| 1673 |
+
@apply h-12 rounded-[8px] px-5 text-base font-semibold shadow-none;
|
| 1674 |
+
background-color: var(--lecturer-surface) !important;
|
| 1675 |
+
border-color: var(--lecturer-border) !important;
|
| 1676 |
+
color: var(--lecturer-text) !important;
|
| 1677 |
+
}
|
| 1678 |
+
|
| 1679 |
+
.lecturer-edit-button:hover {
|
| 1680 |
+
background-color: color-mix(
|
| 1681 |
+
in oklab,
|
| 1682 |
+
var(--lecturer-primary) 8%,
|
| 1683 |
+
var(--lecturer-surface)
|
| 1684 |
+
) !important;
|
| 1685 |
+
}
|
| 1686 |
+
|
| 1687 |
+
.lecturer-delete-button {
|
| 1688 |
+
@apply size-12 rounded-[8px] shadow-none;
|
| 1689 |
+
background-color: color-mix(
|
| 1690 |
+
in oklab,
|
| 1691 |
+
var(--lecturer-danger) 10%,
|
| 1692 |
+
var(--lecturer-surface)
|
| 1693 |
+
) !important;
|
| 1694 |
+
color: var(--lecturer-danger) !important;
|
| 1695 |
+
}
|
| 1696 |
+
|
| 1697 |
+
.lecturer-delete-button:hover {
|
| 1698 |
+
background-color: color-mix(
|
| 1699 |
+
in oklab,
|
| 1700 |
+
var(--lecturer-danger) 16%,
|
| 1701 |
+
var(--lecturer-surface)
|
| 1702 |
+
) !important;
|
| 1703 |
+
}
|
| 1704 |
+
|
| 1705 |
+
.lecturer-delete-copy {
|
| 1706 |
+
@apply text-sm leading-relaxed;
|
| 1707 |
+
color: var(--lecturer-muted);
|
| 1708 |
+
}
|
| 1709 |
+
|
| 1710 |
+
.lecturer-delete-copy strong {
|
| 1711 |
+
color: var(--lecturer-text);
|
| 1712 |
+
}
|
| 1713 |
+
|
| 1714 |
+
.lecturer-danger-action-button {
|
| 1715 |
+
@apply h-10 rounded-[8px] px-5 text-sm font-semibold shadow-none;
|
| 1716 |
+
background-color: var(--lecturer-danger) !important;
|
| 1717 |
+
color: var(--lecturer-primary-foreground) !important;
|
| 1718 |
+
}
|
| 1719 |
+
|
| 1720 |
+
.lecturer-danger-action-button:hover {
|
| 1721 |
+
background-color: color-mix(
|
| 1722 |
+
in oklab,
|
| 1723 |
+
var(--lecturer-danger) 86%,
|
| 1724 |
+
#000
|
| 1725 |
+
) !important;
|
| 1726 |
+
}
|
| 1727 |
+
|
| 1728 |
+
.lecturer-detail-content {
|
| 1729 |
+
@apply mx-auto flex w-full max-w-[920px] flex-col gap-6 px-5 py-10 sm:px-8;
|
| 1730 |
+
}
|
| 1731 |
+
|
| 1732 |
+
.lecturer-upload-input {
|
| 1733 |
+
@apply sr-only;
|
| 1734 |
+
}
|
| 1735 |
+
|
| 1736 |
+
.lecturer-upload-card {
|
| 1737 |
+
@apply flex min-h-[185px] w-full flex-col items-center justify-center gap-3 rounded-[14px] border border-dashed px-6 py-8 text-center outline-none transition-colors focus-visible:ring-2;
|
| 1738 |
+
background-color: color-mix(
|
| 1739 |
+
in oklab,
|
| 1740 |
+
var(--lecturer-primary) 6%,
|
| 1741 |
+
var(--lecturer-surface)
|
| 1742 |
+
);
|
| 1743 |
+
border-color: color-mix(
|
| 1744 |
+
in oklab,
|
| 1745 |
+
var(--lecturer-primary) 28%,
|
| 1746 |
+
var(--lecturer-border)
|
| 1747 |
+
);
|
| 1748 |
+
color: var(--lecturer-text);
|
| 1749 |
+
--tw-ring-color: color-mix(
|
| 1750 |
+
in oklab,
|
| 1751 |
+
var(--lecturer-primary) 45%,
|
| 1752 |
+
transparent
|
| 1753 |
+
);
|
| 1754 |
+
}
|
| 1755 |
+
|
| 1756 |
+
.lecturer-upload-card:hover {
|
| 1757 |
+
background-color: color-mix(
|
| 1758 |
+
in oklab,
|
| 1759 |
+
var(--lecturer-primary) 10%,
|
| 1760 |
+
var(--lecturer-surface)
|
| 1761 |
+
);
|
| 1762 |
+
border-color: var(--lecturer-primary);
|
| 1763 |
+
}
|
| 1764 |
+
|
| 1765 |
+
.lecturer-upload-card[data-uploading='true'] {
|
| 1766 |
+
opacity: 0.78;
|
| 1767 |
+
}
|
| 1768 |
+
|
| 1769 |
+
.lecturer-upload-icon {
|
| 1770 |
+
@apply flex size-12 items-center justify-center rounded-full;
|
| 1771 |
+
background-color: var(--lecturer-surface);
|
| 1772 |
+
color: var(--lecturer-primary);
|
| 1773 |
+
}
|
| 1774 |
+
|
| 1775 |
+
.lecturer-upload-title {
|
| 1776 |
+
@apply text-base font-medium;
|
| 1777 |
+
color: var(--lecturer-text);
|
| 1778 |
+
}
|
| 1779 |
+
|
| 1780 |
+
.lecturer-upload-description {
|
| 1781 |
+
@apply max-w-[560px] text-sm leading-relaxed;
|
| 1782 |
+
color: var(--lecturer-muted);
|
| 1783 |
+
}
|
| 1784 |
+
|
| 1785 |
+
.lecturer-document-section {
|
| 1786 |
+
@apply mt-1;
|
| 1787 |
+
}
|
| 1788 |
+
|
| 1789 |
+
.lecturer-document-heading {
|
| 1790 |
+
@apply flex items-center gap-2 text-sm font-bold;
|
| 1791 |
+
color: var(--lecturer-text);
|
| 1792 |
+
}
|
| 1793 |
+
|
| 1794 |
+
.lecturer-document-list {
|
| 1795 |
+
@apply mt-5 flex flex-col gap-3;
|
| 1796 |
+
}
|
| 1797 |
+
|
| 1798 |
+
.lecturer-document-card {
|
| 1799 |
+
@apply gap-0 rounded-[10px] border py-0 shadow-none;
|
| 1800 |
+
background-color: color-mix(
|
| 1801 |
+
in oklab,
|
| 1802 |
+
var(--lecturer-primary) 3%,
|
| 1803 |
+
var(--lecturer-surface)
|
| 1804 |
+
);
|
| 1805 |
+
border-color: color-mix(
|
| 1806 |
+
in oklab,
|
| 1807 |
+
var(--lecturer-primary) 18%,
|
| 1808 |
+
var(--lecturer-border)
|
| 1809 |
+
);
|
| 1810 |
+
}
|
| 1811 |
+
|
| 1812 |
+
.lecturer-document-card[data-status='failed'] {
|
| 1813 |
+
background-color: color-mix(
|
| 1814 |
+
in oklab,
|
| 1815 |
+
var(--lecturer-danger) 5%,
|
| 1816 |
+
var(--lecturer-surface)
|
| 1817 |
+
);
|
| 1818 |
+
border-color: color-mix(
|
| 1819 |
+
in oklab,
|
| 1820 |
+
var(--lecturer-danger) 25%,
|
| 1821 |
+
var(--lecturer-border)
|
| 1822 |
+
);
|
| 1823 |
+
}
|
| 1824 |
+
|
| 1825 |
+
.lecturer-document-content {
|
| 1826 |
+
@apply flex items-start gap-4 px-5 py-5;
|
| 1827 |
+
}
|
| 1828 |
+
|
| 1829 |
+
.lecturer-document-icon {
|
| 1830 |
+
@apply flex size-10 shrink-0 items-center justify-center rounded-[8px];
|
| 1831 |
+
background-color: color-mix(
|
| 1832 |
+
in oklab,
|
| 1833 |
+
var(--lecturer-primary) 12%,
|
| 1834 |
+
var(--lecturer-surface)
|
| 1835 |
+
);
|
| 1836 |
+
color: var(--lecturer-primary);
|
| 1837 |
+
}
|
| 1838 |
+
|
| 1839 |
+
.lecturer-document-icon[data-status='failed'] {
|
| 1840 |
+
background-color: color-mix(
|
| 1841 |
+
in oklab,
|
| 1842 |
+
var(--lecturer-danger) 12%,
|
| 1843 |
+
var(--lecturer-surface)
|
| 1844 |
+
);
|
| 1845 |
+
color: var(--lecturer-danger);
|
| 1846 |
+
}
|
| 1847 |
+
|
| 1848 |
+
.lecturer-document-copy {
|
| 1849 |
+
@apply min-w-0 flex-1;
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
.lecturer-document-copy h3 {
|
| 1853 |
+
@apply truncate text-sm font-bold;
|
| 1854 |
+
color: var(--lecturer-text);
|
| 1855 |
+
}
|
| 1856 |
+
|
| 1857 |
+
.lecturer-document-meta {
|
| 1858 |
+
@apply mt-2 flex flex-wrap items-center gap-3 text-xs;
|
| 1859 |
+
color: var(--lecturer-muted);
|
| 1860 |
+
}
|
| 1861 |
+
|
| 1862 |
+
.lecturer-document-status {
|
| 1863 |
+
@apply mt-3 flex items-center gap-1.5 text-xs font-semibold;
|
| 1864 |
+
color: var(--lecturer-success);
|
| 1865 |
+
}
|
| 1866 |
+
|
| 1867 |
+
.lecturer-document-status[data-status='failed'] {
|
| 1868 |
+
color: var(--lecturer-danger);
|
| 1869 |
+
}
|
| 1870 |
+
|
| 1871 |
+
.lecturer-document-status[data-status='processing'] {
|
| 1872 |
+
color: var(--lecturer-primary);
|
| 1873 |
+
}
|
| 1874 |
+
|
| 1875 |
+
.lecturer-document-actions {
|
| 1876 |
+
@apply ml-auto flex shrink-0 items-center gap-3;
|
| 1877 |
+
}
|
| 1878 |
+
|
| 1879 |
+
.lecturer-document-delete {
|
| 1880 |
+
@apply inline-flex size-8 items-center justify-center rounded-[7px] outline-none transition-colors focus-visible:ring-2;
|
| 1881 |
+
color: var(--lecturer-muted);
|
| 1882 |
+
--tw-ring-color: color-mix(
|
| 1883 |
+
in oklab,
|
| 1884 |
+
var(--lecturer-primary) 45%,
|
| 1885 |
+
transparent
|
| 1886 |
+
);
|
| 1887 |
+
}
|
| 1888 |
+
|
| 1889 |
+
.lecturer-document-delete:hover {
|
| 1890 |
+
background-color: color-mix(
|
| 1891 |
+
in oklab,
|
| 1892 |
+
var(--lecturer-danger) 10%,
|
| 1893 |
+
var(--lecturer-surface)
|
| 1894 |
+
);
|
| 1895 |
+
color: var(--lecturer-danger);
|
| 1896 |
+
}
|
| 1897 |
+
|
| 1898 |
+
.lecturer-detail-empty {
|
| 1899 |
+
@apply rounded-[10px] border px-5 py-8 text-center text-sm;
|
| 1900 |
+
background-color: var(--lecturer-surface);
|
| 1901 |
+
border-color: var(--lecturer-border);
|
| 1902 |
+
color: var(--lecturer-muted);
|
| 1903 |
+
}
|
| 1904 |
+
|
| 1905 |
@media (max-width: 640px) {
|
| 1906 |
.student-chat-stage--session {
|
| 1907 |
@apply px-3;
|
|
|
|
| 1914 |
.student-chat-composer {
|
| 1915 |
@apply pb-4;
|
| 1916 |
}
|
| 1917 |
+
|
| 1918 |
+
.lecturer-page {
|
| 1919 |
+
@apply px-4 py-6;
|
| 1920 |
+
}
|
| 1921 |
+
|
| 1922 |
+
.lecturer-page-header h1 {
|
| 1923 |
+
@apply text-2xl;
|
| 1924 |
+
}
|
| 1925 |
+
|
| 1926 |
+
.lecturer-header-actions {
|
| 1927 |
+
@apply w-full;
|
| 1928 |
+
}
|
| 1929 |
+
|
| 1930 |
+
.lecturer-create-button {
|
| 1931 |
+
@apply flex-1;
|
| 1932 |
+
}
|
| 1933 |
+
|
| 1934 |
+
.lecturer-detail-title-row h1 {
|
| 1935 |
+
@apply text-2xl;
|
| 1936 |
+
}
|
| 1937 |
+
|
| 1938 |
+
.lecturer-detail-actions {
|
| 1939 |
+
@apply w-full;
|
| 1940 |
+
}
|
| 1941 |
+
|
| 1942 |
+
.lecturer-edit-button {
|
| 1943 |
+
@apply flex-1;
|
| 1944 |
+
}
|
| 1945 |
+
|
| 1946 |
+
.lecturer-detail-content {
|
| 1947 |
+
@apply px-4 py-6;
|
| 1948 |
+
}
|
| 1949 |
+
|
| 1950 |
+
.lecturer-document-content {
|
| 1951 |
+
@apply flex-col;
|
| 1952 |
+
}
|
| 1953 |
+
|
| 1954 |
+
.lecturer-document-actions {
|
| 1955 |
+
@apply ml-0 w-full justify-between;
|
| 1956 |
+
}
|
| 1957 |
}
|
| 1958 |
}
|
resources/js/components/auth/register-role-form.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { Head, Link } from '@inertiajs/react';
|
| 2 |
import type { FormEvent } from 'react';
|
| 3 |
import { useState } from 'react';
|
|
|
|
| 4 |
import AppearanceToggle from '@/components/appearance-toggle';
|
| 5 |
import { Button } from '@/components/ui/button';
|
| 6 |
import { Card, CardContent } from '@/components/ui/card';
|
|
@@ -30,26 +31,113 @@ type RegisterRoleFormProps = {
|
|
| 30 |
role: RegisterRole;
|
| 31 |
};
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
type RegisterFieldProps = {
|
| 34 |
autoComplete?: string;
|
| 35 |
autoFocus?: boolean;
|
|
|
|
|
|
|
| 36 |
label: string;
|
| 37 |
name: string;
|
|
|
|
|
|
|
|
|
|
| 38 |
placeholder: string;
|
| 39 |
required?: boolean;
|
| 40 |
tabIndex: number;
|
| 41 |
type?: string;
|
|
|
|
| 42 |
};
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
function RegisterField({
|
| 45 |
autoComplete,
|
| 46 |
autoFocus = false,
|
|
|
|
|
|
|
| 47 |
label,
|
| 48 |
name,
|
|
|
|
|
|
|
|
|
|
| 49 |
placeholder,
|
| 50 |
required = false,
|
| 51 |
tabIndex,
|
| 52 |
type = 'text',
|
|
|
|
| 53 |
}: RegisterFieldProps) {
|
| 54 |
return (
|
| 55 |
<div className="register-field">
|
|
@@ -62,11 +150,22 @@ function RegisterField({
|
|
| 62 |
type={type}
|
| 63 |
autoComplete={autoComplete}
|
| 64 |
autoFocus={autoFocus}
|
|
|
|
| 65 |
placeholder={placeholder}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
required={required}
|
| 67 |
tabIndex={tabIndex}
|
|
|
|
| 68 |
className="register-input"
|
| 69 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
</div>
|
| 71 |
);
|
| 72 |
}
|
|
@@ -75,7 +174,55 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
|
|
| 75 |
const content = roleContent[role];
|
| 76 |
const [backendError, setBackendError] = useState<string>();
|
| 77 |
const [backendStatus, setBackendStatus] = useState<string>();
|
|
|
|
|
|
|
|
|
|
| 78 |
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
const handleSubmit = async (
|
| 81 |
event: FormEvent<HTMLFormElement>,
|
|
@@ -83,17 +230,27 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
|
|
| 83 |
event.preventDefault();
|
| 84 |
setBackendError(undefined);
|
| 85 |
setBackendStatus(undefined);
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
try {
|
| 91 |
const response = await authApi.register({
|
| 92 |
-
email:
|
| 93 |
-
password:
|
| 94 |
-
name:
|
| 95 |
role: content.apiRole,
|
| 96 |
-
identity_number:
|
| 97 |
});
|
| 98 |
|
| 99 |
setBackendStatus(response.message);
|
|
@@ -119,7 +276,11 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
|
|
| 119 |
|
| 120 |
<h1 className="register-heading">{content.heading}</h1>
|
| 121 |
|
| 122 |
-
<form
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
<input type="hidden" name="role" value={role} />
|
| 124 |
|
| 125 |
<RegisterField
|
|
@@ -128,15 +289,34 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
|
|
| 128 |
placeholder="Masukkan nama lengkap"
|
| 129 |
autoComplete="name"
|
| 130 |
autoFocus
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
required
|
| 132 |
tabIndex={1}
|
|
|
|
| 133 |
/>
|
| 134 |
<RegisterField
|
| 135 |
name="identity_number"
|
| 136 |
label={content.identifierLabel}
|
| 137 |
placeholder={content.identifierPlaceholder}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
required
|
| 139 |
tabIndex={2}
|
|
|
|
| 140 |
/>
|
| 141 |
<RegisterField
|
| 142 |
name="email"
|
|
@@ -144,8 +324,15 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
|
|
| 144 |
label="Email"
|
| 145 |
placeholder="contoh@kampus.ac.id"
|
| 146 |
autoComplete="email"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
required
|
| 148 |
tabIndex={3}
|
|
|
|
| 149 |
/>
|
| 150 |
<RegisterField
|
| 151 |
name="password"
|
|
@@ -153,8 +340,14 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
|
|
| 153 |
label="Password"
|
| 154 |
placeholder="Minimal 6 karakter"
|
| 155 |
autoComplete="new-password"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
required
|
| 157 |
tabIndex={4}
|
|
|
|
| 158 |
/>
|
| 159 |
|
| 160 |
<Button
|
|
|
|
| 1 |
import { Head, Link } from '@inertiajs/react';
|
| 2 |
import type { FormEvent } from 'react';
|
| 3 |
import { useState } from 'react';
|
| 4 |
+
|
| 5 |
import AppearanceToggle from '@/components/appearance-toggle';
|
| 6 |
import { Button } from '@/components/ui/button';
|
| 7 |
import { Card, CardContent } from '@/components/ui/card';
|
|
|
|
| 31 |
role: RegisterRole;
|
| 32 |
};
|
| 33 |
|
| 34 |
+
type RegisterFormValues = {
|
| 35 |
+
email: string;
|
| 36 |
+
identity_number: string;
|
| 37 |
+
name: string;
|
| 38 |
+
password: string;
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
type RegisterFormErrors = Partial<Record<keyof RegisterFormValues, string>>;
|
| 42 |
+
type RegisterTouchedFields = Partial<Record<keyof RegisterFormValues, boolean>>;
|
| 43 |
+
|
| 44 |
type RegisterFieldProps = {
|
| 45 |
autoComplete?: string;
|
| 46 |
autoFocus?: boolean;
|
| 47 |
+
error?: string;
|
| 48 |
+
inputMode?: 'email' | 'numeric' | 'text';
|
| 49 |
label: string;
|
| 50 |
name: string;
|
| 51 |
+
onBlur?: () => void;
|
| 52 |
+
onChange: (value: string) => void;
|
| 53 |
+
pattern?: string;
|
| 54 |
placeholder: string;
|
| 55 |
required?: boolean;
|
| 56 |
tabIndex: number;
|
| 57 |
type?: string;
|
| 58 |
+
value: string;
|
| 59 |
};
|
| 60 |
|
| 61 |
+
const emptyFormValues: RegisterFormValues = {
|
| 62 |
+
email: '',
|
| 63 |
+
identity_number: '',
|
| 64 |
+
name: '',
|
| 65 |
+
password: '',
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const namePattern = /^[\p{L}\s.'-]+$/u;
|
| 69 |
+
const identityNumberPattern = /^(?:\d{5,30}|[A-Z]{2,10}\d{3,30})$/;
|
| 70 |
+
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
| 71 |
+
|
| 72 |
+
function validateRegisterForm(
|
| 73 |
+
values: RegisterFormValues,
|
| 74 |
+
identifierLabel: string,
|
| 75 |
+
): RegisterFormErrors {
|
| 76 |
+
const errors: RegisterFormErrors = {};
|
| 77 |
+
const normalizedName = values.name.trim().replace(/\s+/g, ' ');
|
| 78 |
+
const normalizedEmail = values.email.trim();
|
| 79 |
+
const normalizedIdentityNumber = values.identity_number.trim();
|
| 80 |
+
|
| 81 |
+
if (!normalizedName) {
|
| 82 |
+
errors.name = 'Nama lengkap wajib diisi.';
|
| 83 |
+
} else if (normalizedName.length < 3) {
|
| 84 |
+
errors.name = 'Nama lengkap minimal 3 karakter.';
|
| 85 |
+
} else if (!namePattern.test(normalizedName)) {
|
| 86 |
+
errors.name = 'Nama hanya boleh berisi huruf dan tanda nama.';
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if (!normalizedIdentityNumber) {
|
| 90 |
+
errors.identity_number = `${identifierLabel} wajib diisi.`;
|
| 91 |
+
} else if (!identityNumberPattern.test(normalizedIdentityNumber)) {
|
| 92 |
+
errors.identity_number = `${identifierLabel} hanya boleh angka, atau kode huruf besar diikuti angka.`;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
if (!normalizedEmail) {
|
| 96 |
+
errors.email = 'Email wajib diisi.';
|
| 97 |
+
} else if (
|
| 98 |
+
normalizedEmail.includes(' ') ||
|
| 99 |
+
!emailPattern.test(normalizedEmail)
|
| 100 |
+
) {
|
| 101 |
+
errors.email = 'Format email tidak valid.';
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
if (!values.password) {
|
| 105 |
+
errors.password = 'Password wajib diisi.';
|
| 106 |
+
} else if (values.password.length < 6) {
|
| 107 |
+
errors.password = 'Password minimal 6 karakter.';
|
| 108 |
+
} else if (/\s/.test(values.password)) {
|
| 109 |
+
errors.password = 'Password tidak boleh mengandung spasi.';
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return errors;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function normalizeRegisterValues(
|
| 116 |
+
values: RegisterFormValues,
|
| 117 |
+
): RegisterFormValues {
|
| 118 |
+
return {
|
| 119 |
+
email: values.email.trim(),
|
| 120 |
+
identity_number: values.identity_number.trim(),
|
| 121 |
+
name: values.name.trim().replace(/\s+/g, ' '),
|
| 122 |
+
password: values.password,
|
| 123 |
+
};
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
function RegisterField({
|
| 127 |
autoComplete,
|
| 128 |
autoFocus = false,
|
| 129 |
+
error,
|
| 130 |
+
inputMode = 'text',
|
| 131 |
label,
|
| 132 |
name,
|
| 133 |
+
onBlur,
|
| 134 |
+
onChange,
|
| 135 |
+
pattern,
|
| 136 |
placeholder,
|
| 137 |
required = false,
|
| 138 |
tabIndex,
|
| 139 |
type = 'text',
|
| 140 |
+
value,
|
| 141 |
}: RegisterFieldProps) {
|
| 142 |
return (
|
| 143 |
<div className="register-field">
|
|
|
|
| 150 |
type={type}
|
| 151 |
autoComplete={autoComplete}
|
| 152 |
autoFocus={autoFocus}
|
| 153 |
+
aria-invalid={Boolean(error)}
|
| 154 |
placeholder={placeholder}
|
| 155 |
+
inputMode={inputMode}
|
| 156 |
+
onBlur={onBlur}
|
| 157 |
+
onChange={(event) => onChange(event.target.value)}
|
| 158 |
+
pattern={pattern}
|
| 159 |
required={required}
|
| 160 |
tabIndex={tabIndex}
|
| 161 |
+
value={value}
|
| 162 |
className="register-input"
|
| 163 |
/>
|
| 164 |
+
{error && (
|
| 165 |
+
<p className="register-error" role="alert">
|
| 166 |
+
{error}
|
| 167 |
+
</p>
|
| 168 |
+
)}
|
| 169 |
</div>
|
| 170 |
);
|
| 171 |
}
|
|
|
|
| 174 |
const content = roleContent[role];
|
| 175 |
const [backendError, setBackendError] = useState<string>();
|
| 176 |
const [backendStatus, setBackendStatus] = useState<string>();
|
| 177 |
+
const [fieldErrors, setFieldErrors] = useState<RegisterFormErrors>({});
|
| 178 |
+
const [formValues, setFormValues] =
|
| 179 |
+
useState<RegisterFormValues>(emptyFormValues);
|
| 180 |
const [isProcessing, setIsProcessing] = useState(false);
|
| 181 |
+
const [submittedOnce, setSubmittedOnce] = useState(false);
|
| 182 |
+
const [touchedFields, setTouchedFields] = useState<RegisterTouchedFields>(
|
| 183 |
+
{},
|
| 184 |
+
);
|
| 185 |
+
|
| 186 |
+
function validateCurrentForm(
|
| 187 |
+
values: RegisterFormValues = formValues,
|
| 188 |
+
): RegisterFormErrors {
|
| 189 |
+
return validateRegisterForm(values, content.identifierLabel);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function setSingleFieldError(
|
| 193 |
+
field: keyof RegisterFormValues,
|
| 194 |
+
errors: RegisterFormErrors,
|
| 195 |
+
): void {
|
| 196 |
+
setFieldErrors((currentErrors) => {
|
| 197 |
+
const nextErrors = { ...currentErrors };
|
| 198 |
+
const message = errors[field];
|
| 199 |
+
|
| 200 |
+
if (message) {
|
| 201 |
+
nextErrors[field] = message;
|
| 202 |
+
} else {
|
| 203 |
+
delete nextErrors[field];
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
return nextErrors;
|
| 207 |
+
});
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function updateField(field: keyof RegisterFormValues, value: string): void {
|
| 211 |
+
const nextValues = { ...formValues, [field]: value };
|
| 212 |
+
setFormValues(nextValues);
|
| 213 |
+
|
| 214 |
+
if (submittedOnce || touchedFields[field]) {
|
| 215 |
+
setSingleFieldError(field, validateCurrentForm(nextValues));
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function validateFieldOnBlur(field: keyof RegisterFormValues): void {
|
| 220 |
+
setTouchedFields((currentFields) => ({
|
| 221 |
+
...currentFields,
|
| 222 |
+
[field]: true,
|
| 223 |
+
}));
|
| 224 |
+
setSingleFieldError(field, validateCurrentForm());
|
| 225 |
+
}
|
| 226 |
|
| 227 |
const handleSubmit = async (
|
| 228 |
event: FormEvent<HTMLFormElement>,
|
|
|
|
| 230 |
event.preventDefault();
|
| 231 |
setBackendError(undefined);
|
| 232 |
setBackendStatus(undefined);
|
| 233 |
+
setSubmittedOnce(true);
|
| 234 |
+
|
| 235 |
+
const normalizedValues = normalizeRegisterValues(formValues);
|
| 236 |
+
const nextFieldErrors = validateCurrentForm(normalizedValues);
|
| 237 |
|
| 238 |
+
setFormValues(normalizedValues);
|
| 239 |
+
setFieldErrors(nextFieldErrors);
|
| 240 |
+
|
| 241 |
+
if (Object.keys(nextFieldErrors).length > 0) {
|
| 242 |
+
return;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
setIsProcessing(true);
|
| 246 |
|
| 247 |
try {
|
| 248 |
const response = await authApi.register({
|
| 249 |
+
email: normalizedValues.email,
|
| 250 |
+
password: normalizedValues.password,
|
| 251 |
+
name: normalizedValues.name,
|
| 252 |
role: content.apiRole,
|
| 253 |
+
identity_number: normalizedValues.identity_number,
|
| 254 |
});
|
| 255 |
|
| 256 |
setBackendStatus(response.message);
|
|
|
|
| 276 |
|
| 277 |
<h1 className="register-heading">{content.heading}</h1>
|
| 278 |
|
| 279 |
+
<form
|
| 280 |
+
className="register-form"
|
| 281 |
+
noValidate
|
| 282 |
+
onSubmit={handleSubmit}
|
| 283 |
+
>
|
| 284 |
<input type="hidden" name="role" value={role} />
|
| 285 |
|
| 286 |
<RegisterField
|
|
|
|
| 289 |
placeholder="Masukkan nama lengkap"
|
| 290 |
autoComplete="name"
|
| 291 |
autoFocus
|
| 292 |
+
error={fieldErrors.name}
|
| 293 |
+
onBlur={() => validateFieldOnBlur('name')}
|
| 294 |
+
onChange={(value) => updateField('name', value)}
|
| 295 |
+
pattern="[\p{L}\s.'-]+"
|
| 296 |
required
|
| 297 |
tabIndex={1}
|
| 298 |
+
value={formValues.name}
|
| 299 |
/>
|
| 300 |
<RegisterField
|
| 301 |
name="identity_number"
|
| 302 |
label={content.identifierLabel}
|
| 303 |
placeholder={content.identifierPlaceholder}
|
| 304 |
+
autoComplete="off"
|
| 305 |
+
error={fieldErrors.identity_number}
|
| 306 |
+
inputMode="text"
|
| 307 |
+
onBlur={() =>
|
| 308 |
+
validateFieldOnBlur('identity_number')
|
| 309 |
+
}
|
| 310 |
+
onChange={(value) =>
|
| 311 |
+
updateField(
|
| 312 |
+
'identity_number',
|
| 313 |
+
value.toUpperCase(),
|
| 314 |
+
)
|
| 315 |
+
}
|
| 316 |
+
pattern="(?:\d{5,30}|[A-Z]{2,10}\d{3,30})"
|
| 317 |
required
|
| 318 |
tabIndex={2}
|
| 319 |
+
value={formValues.identity_number}
|
| 320 |
/>
|
| 321 |
<RegisterField
|
| 322 |
name="email"
|
|
|
|
| 324 |
label="Email"
|
| 325 |
placeholder="contoh@kampus.ac.id"
|
| 326 |
autoComplete="email"
|
| 327 |
+
error={fieldErrors.email}
|
| 328 |
+
inputMode="email"
|
| 329 |
+
onBlur={() => validateFieldOnBlur('email')}
|
| 330 |
+
onChange={(value) =>
|
| 331 |
+
updateField('email', value)
|
| 332 |
+
}
|
| 333 |
required
|
| 334 |
tabIndex={3}
|
| 335 |
+
value={formValues.email}
|
| 336 |
/>
|
| 337 |
<RegisterField
|
| 338 |
name="password"
|
|
|
|
| 340 |
label="Password"
|
| 341 |
placeholder="Minimal 6 karakter"
|
| 342 |
autoComplete="new-password"
|
| 343 |
+
error={fieldErrors.password}
|
| 344 |
+
onBlur={() => validateFieldOnBlur('password')}
|
| 345 |
+
onChange={(value) =>
|
| 346 |
+
updateField('password', value)
|
| 347 |
+
}
|
| 348 |
required
|
| 349 |
tabIndex={4}
|
| 350 |
+
value={formValues.password}
|
| 351 |
/>
|
| 352 |
|
| 353 |
<Button
|
resources/js/components/dosen/dosen-shell.tsx
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { router } from '@inertiajs/react';
|
| 2 |
+
import { LayoutDashboard, LogOut, MessageSquare, Settings } from 'lucide-react';
|
| 3 |
+
import type { CSSProperties, ReactNode } from 'react';
|
| 4 |
+
import { useSyncExternalStore } from 'react';
|
| 5 |
+
|
| 6 |
+
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
| 7 |
+
import {
|
| 8 |
+
Sidebar,
|
| 9 |
+
SidebarContent,
|
| 10 |
+
SidebarFooter,
|
| 11 |
+
SidebarGroup,
|
| 12 |
+
SidebarGroupContent,
|
| 13 |
+
SidebarHeader,
|
| 14 |
+
SidebarInset,
|
| 15 |
+
SidebarMenu,
|
| 16 |
+
SidebarMenuButton,
|
| 17 |
+
SidebarMenuItem,
|
| 18 |
+
SidebarProvider,
|
| 19 |
+
SidebarRail,
|
| 20 |
+
SidebarTrigger,
|
| 21 |
+
useSidebar,
|
| 22 |
+
} from '@/components/ui/sidebar';
|
| 23 |
+
import { clearAuthSession, getStoredAuthUser } from '@/lib/auth';
|
| 24 |
+
import { dosen, login } from '@/routes';
|
| 25 |
+
import { pengaturan as dosenPengaturan } from '@/routes/dosen';
|
| 26 |
+
|
| 27 |
+
type DosenMenu = 'dashboard' | 'settings';
|
| 28 |
+
|
| 29 |
+
const lecturerSidebarStyle = {
|
| 30 |
+
'--sidebar-width': '212px',
|
| 31 |
+
'--sidebar-width-icon': '64px',
|
| 32 |
+
} as CSSProperties;
|
| 33 |
+
|
| 34 |
+
function getStoredLecturerName(): string {
|
| 35 |
+
return getStoredAuthUser()?.name ?? 'Dosen';
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function subscribeToAuthStorage(onStoreChange: () => void): () => void {
|
| 39 |
+
if (typeof window === 'undefined') {
|
| 40 |
+
return () => {};
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
window.addEventListener('storage', onStoreChange);
|
| 44 |
+
|
| 45 |
+
return () => window.removeEventListener('storage', onStoreChange);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function useStoredLecturerName(): string {
|
| 49 |
+
return useSyncExternalStore(
|
| 50 |
+
subscribeToAuthStorage,
|
| 51 |
+
getStoredLecturerName,
|
| 52 |
+
() => 'Dosen',
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function getInitials(name: string): string {
|
| 57 |
+
return name
|
| 58 |
+
.split(' ')
|
| 59 |
+
.filter(Boolean)
|
| 60 |
+
.slice(0, 2)
|
| 61 |
+
.map((part) => part[0]?.toUpperCase())
|
| 62 |
+
.join('');
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function handleLogout(): void {
|
| 66 |
+
clearAuthSession();
|
| 67 |
+
router.visit(login());
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function DosenSidebarBrand() {
|
| 71 |
+
const { state, toggleSidebar } = useSidebar();
|
| 72 |
+
|
| 73 |
+
const handleBrandClick = (): void => {
|
| 74 |
+
if (state === 'collapsed') {
|
| 75 |
+
toggleSidebar();
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<button
|
| 81 |
+
aria-label="Buka sidebar"
|
| 82 |
+
className="lecturer-brand"
|
| 83 |
+
onClick={handleBrandClick}
|
| 84 |
+
type="button"
|
| 85 |
+
>
|
| 86 |
+
<span className="lecturer-brand-mark">
|
| 87 |
+
<MessageSquare className="size-4" />
|
| 88 |
+
</span>
|
| 89 |
+
<span className="lecturer-brand-copy">RAG Hub</span>
|
| 90 |
+
</button>
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function DosenSidebar({ activeMenu }: { activeMenu: DosenMenu }) {
|
| 95 |
+
const lecturerName = useStoredLecturerName();
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<Sidebar className="lecturer-sidebar" collapsible="icon">
|
| 99 |
+
<SidebarHeader className="lecturer-sidebar-header">
|
| 100 |
+
<DosenSidebarBrand />
|
| 101 |
+
|
| 102 |
+
<SidebarTrigger className="lecturer-sidebar-trigger" />
|
| 103 |
+
</SidebarHeader>
|
| 104 |
+
|
| 105 |
+
<SidebarContent className="lecturer-sidebar-content">
|
| 106 |
+
<SidebarGroup className="lecturer-nav-group">
|
| 107 |
+
<SidebarGroupContent>
|
| 108 |
+
<SidebarMenu className="lecturer-nav-list">
|
| 109 |
+
<SidebarMenuItem>
|
| 110 |
+
<SidebarMenuButton
|
| 111 |
+
className="lecturer-nav-button"
|
| 112 |
+
isActive={activeMenu === 'dashboard'}
|
| 113 |
+
onClick={() => router.visit(dosen())}
|
| 114 |
+
tooltip="Dashboard KB"
|
| 115 |
+
>
|
| 116 |
+
<LayoutDashboard className="size-4" />
|
| 117 |
+
<span>Dashboard KB</span>
|
| 118 |
+
</SidebarMenuButton>
|
| 119 |
+
</SidebarMenuItem>
|
| 120 |
+
|
| 121 |
+
<SidebarMenuItem>
|
| 122 |
+
<SidebarMenuButton
|
| 123 |
+
className="lecturer-nav-button"
|
| 124 |
+
isActive={activeMenu === 'settings'}
|
| 125 |
+
onClick={() =>
|
| 126 |
+
router.visit(dosenPengaturan())
|
| 127 |
+
}
|
| 128 |
+
tooltip="Pengaturan"
|
| 129 |
+
>
|
| 130 |
+
<Settings className="size-4" />
|
| 131 |
+
<span>Pengaturan</span>
|
| 132 |
+
</SidebarMenuButton>
|
| 133 |
+
</SidebarMenuItem>
|
| 134 |
+
</SidebarMenu>
|
| 135 |
+
</SidebarGroupContent>
|
| 136 |
+
</SidebarGroup>
|
| 137 |
+
</SidebarContent>
|
| 138 |
+
|
| 139 |
+
<SidebarFooter className="lecturer-sidebar-footer">
|
| 140 |
+
<SidebarMenu>
|
| 141 |
+
<SidebarMenuItem>
|
| 142 |
+
<SidebarMenuButton
|
| 143 |
+
className="lecturer-user-button"
|
| 144 |
+
tooltip={lecturerName}
|
| 145 |
+
>
|
| 146 |
+
<Avatar className="lecturer-user-avatar">
|
| 147 |
+
<AvatarFallback>
|
| 148 |
+
{getInitials(lecturerName) || 'DS'}
|
| 149 |
+
</AvatarFallback>
|
| 150 |
+
</Avatar>
|
| 151 |
+
<span className="lecturer-user-copy">
|
| 152 |
+
<span className="lecturer-user-name">
|
| 153 |
+
{lecturerName}
|
| 154 |
+
</span>
|
| 155 |
+
<span className="lecturer-user-role">
|
| 156 |
+
Dosen
|
| 157 |
+
</span>
|
| 158 |
+
</span>
|
| 159 |
+
</SidebarMenuButton>
|
| 160 |
+
</SidebarMenuItem>
|
| 161 |
+
|
| 162 |
+
<SidebarMenuItem>
|
| 163 |
+
<SidebarMenuButton
|
| 164 |
+
className="lecturer-logout-button"
|
| 165 |
+
onClick={handleLogout}
|
| 166 |
+
tooltip="Logout"
|
| 167 |
+
>
|
| 168 |
+
<LogOut className="size-4" />
|
| 169 |
+
<span>Logout</span>
|
| 170 |
+
</SidebarMenuButton>
|
| 171 |
+
</SidebarMenuItem>
|
| 172 |
+
</SidebarMenu>
|
| 173 |
+
</SidebarFooter>
|
| 174 |
+
|
| 175 |
+
<SidebarRail />
|
| 176 |
+
</Sidebar>
|
| 177 |
+
);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
export function DosenShell({
|
| 181 |
+
activeMenu = 'dashboard',
|
| 182 |
+
children,
|
| 183 |
+
}: {
|
| 184 |
+
activeMenu?: DosenMenu;
|
| 185 |
+
children: ReactNode;
|
| 186 |
+
}) {
|
| 187 |
+
return (
|
| 188 |
+
<SidebarProvider
|
| 189 |
+
className="lecturer-dashboard-shell"
|
| 190 |
+
defaultOpen
|
| 191 |
+
style={lecturerSidebarStyle}
|
| 192 |
+
>
|
| 193 |
+
<DosenSidebar activeMenu={activeMenu} />
|
| 194 |
+
|
| 195 |
+
<SidebarInset className="lecturer-workspace">
|
| 196 |
+
{children}
|
| 197 |
+
</SidebarInset>
|
| 198 |
+
</SidebarProvider>
|
| 199 |
+
);
|
| 200 |
+
}
|
resources/js/components/student/question-composer.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { BookOpen, Send, Sparkles } from 'lucide-react';
|
| 2 |
-
import type { FormEvent } from 'react';
|
| 3 |
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
import { Card, CardContent } from '@/components/ui/card';
|
|
@@ -15,7 +15,6 @@ import { cn } from '@/lib/utils';
|
|
| 15 |
|
| 16 |
export function QuestionComposer({
|
| 17 |
courses,
|
| 18 |
-
isCourseLocked = false,
|
| 19 |
isLoadingCourses,
|
| 20 |
isProcessing,
|
| 21 |
onCourseChange,
|
|
@@ -26,7 +25,6 @@ export function QuestionComposer({
|
|
| 26 |
variant = 'empty',
|
| 27 |
}: {
|
| 28 |
courses: CourseResponse[];
|
| 29 |
-
isCourseLocked?: boolean;
|
| 30 |
isLoadingCourses: boolean;
|
| 31 |
isProcessing: boolean;
|
| 32 |
onCourseChange: (courseId: string) => void;
|
|
@@ -36,6 +34,29 @@ export function QuestionComposer({
|
|
| 36 |
selectedCourseId: string;
|
| 37 |
variant?: 'dock' | 'empty';
|
| 38 |
}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
return (
|
| 40 |
<Card
|
| 41 |
className={cn(
|
|
@@ -52,11 +73,7 @@ export function QuestionComposer({
|
|
| 52 |
</span>
|
| 53 |
|
| 54 |
<Select
|
| 55 |
-
disabled={
|
| 56 |
-
isCourseLocked ||
|
| 57 |
-
isLoadingCourses ||
|
| 58 |
-
courses.length === 0
|
| 59 |
-
}
|
| 60 |
onValueChange={onCourseChange}
|
| 61 |
value={selectedCourseId}
|
| 62 |
>
|
|
@@ -86,6 +103,7 @@ export function QuestionComposer({
|
|
| 86 |
<textarea
|
| 87 |
aria-label="Pertanyaan"
|
| 88 |
className="text-base"
|
|
|
|
| 89 |
onChange={(event) =>
|
| 90 |
onQuestionChange(event.target.value)
|
| 91 |
}
|
|
@@ -96,11 +114,7 @@ export function QuestionComposer({
|
|
| 96 |
|
| 97 |
<Button
|
| 98 |
className="student-floating-send"
|
| 99 |
-
disabled={
|
| 100 |
-
isProcessing ||
|
| 101 |
-
selectedCourseId.length === 0 ||
|
| 102 |
-
question.trim().length === 0
|
| 103 |
-
}
|
| 104 |
size="icon"
|
| 105 |
type="submit"
|
| 106 |
>
|
|
|
|
| 1 |
import { BookOpen, Send, Sparkles } from 'lucide-react';
|
| 2 |
+
import type { FormEvent, KeyboardEvent } from 'react';
|
| 3 |
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
import { Card, CardContent } from '@/components/ui/card';
|
|
|
|
| 15 |
|
| 16 |
export function QuestionComposer({
|
| 17 |
courses,
|
|
|
|
| 18 |
isLoadingCourses,
|
| 19 |
isProcessing,
|
| 20 |
onCourseChange,
|
|
|
|
| 25 |
variant = 'empty',
|
| 26 |
}: {
|
| 27 |
courses: CourseResponse[];
|
|
|
|
| 28 |
isLoadingCourses: boolean;
|
| 29 |
isProcessing: boolean;
|
| 30 |
onCourseChange: (courseId: string) => void;
|
|
|
|
| 34 |
selectedCourseId: string;
|
| 35 |
variant?: 'dock' | 'empty';
|
| 36 |
}) {
|
| 37 |
+
const canSubmit =
|
| 38 |
+
!isProcessing &&
|
| 39 |
+
selectedCourseId.length > 0 &&
|
| 40 |
+
question.trim().length > 0;
|
| 41 |
+
|
| 42 |
+
function handleQuestionKeyDown(
|
| 43 |
+
event: KeyboardEvent<HTMLTextAreaElement>,
|
| 44 |
+
): void {
|
| 45 |
+
if (
|
| 46 |
+
event.key !== 'Enter' ||
|
| 47 |
+
event.shiftKey ||
|
| 48 |
+
event.nativeEvent.isComposing
|
| 49 |
+
) {
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
event.preventDefault();
|
| 54 |
+
|
| 55 |
+
if (canSubmit) {
|
| 56 |
+
event.currentTarget.form?.requestSubmit();
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
return (
|
| 61 |
<Card
|
| 62 |
className={cn(
|
|
|
|
| 73 |
</span>
|
| 74 |
|
| 75 |
<Select
|
| 76 |
+
disabled={isLoadingCourses || courses.length === 0}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
onValueChange={onCourseChange}
|
| 78 |
value={selectedCourseId}
|
| 79 |
>
|
|
|
|
| 103 |
<textarea
|
| 104 |
aria-label="Pertanyaan"
|
| 105 |
className="text-base"
|
| 106 |
+
onKeyDown={handleQuestionKeyDown}
|
| 107 |
onChange={(event) =>
|
| 108 |
onQuestionChange(event.target.value)
|
| 109 |
}
|
|
|
|
| 114 |
|
| 115 |
<Button
|
| 116 |
className="student-floating-send"
|
| 117 |
+
disabled={!canSubmit}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
size="icon"
|
| 119 |
type="submit"
|
| 120 |
>
|
resources/js/components/student/student-shell.tsx
CHANGED
|
@@ -12,6 +12,14 @@ import { useState, useSyncExternalStore } from 'react';
|
|
| 12 |
import AppearanceToggle from '@/components/appearance-toggle';
|
| 13 |
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
| 14 |
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
import {
|
| 16 |
Sidebar,
|
| 17 |
SidebarContent,
|
|
@@ -39,6 +47,11 @@ const sidebarStyle = {
|
|
| 39 |
'--sidebar-width-icon': '64px',
|
| 40 |
} as CSSProperties;
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
export function getStoredStudentName(): string {
|
| 43 |
return getStoredAuthUser()?.name ?? 'Mahasiswa';
|
| 44 |
}
|
|
@@ -131,188 +144,289 @@ function StudentSidebar({
|
|
| 131 |
activeSessionId?: string;
|
| 132 |
chatSessions: ChatSessionResponse[];
|
| 133 |
isLoading: boolean;
|
| 134 |
-
onDeleteSession?: (
|
|
|
|
|
|
|
| 135 |
studentName: string;
|
| 136 |
}) {
|
| 137 |
const [isHistoryOpen, setIsHistoryOpen] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
const { state } = useSidebar();
|
| 139 |
const isSidebarCollapsed = state === 'collapsed';
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
return (
|
| 142 |
-
<
|
| 143 |
-
<
|
| 144 |
-
<
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
<
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
<
|
| 153 |
-
<
|
| 154 |
-
<
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
<span className="student-history-
|
| 161 |
-
|
|
|
|
|
|
|
| 162 |
</span>
|
| 163 |
-
</
|
| 164 |
-
</
|
| 165 |
-
</
|
| 166 |
-
</
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
<
|
| 184 |
-
</
|
| 185 |
-
</SidebarGroupLabel>
|
| 186 |
-
|
| 187 |
-
{isHistoryOpen && (
|
| 188 |
-
<SidebarGroupContent>
|
| 189 |
-
<SidebarMenu className="student-history-list">
|
| 190 |
-
{isLoading && (
|
| 191 |
-
<SidebarMenuItem>
|
| 192 |
-
<SidebarMenuButton
|
| 193 |
-
className="student-history-menu-button"
|
| 194 |
-
disabled
|
| 195 |
-
>
|
| 196 |
-
<Clock3 className="student-history-menu-icon size-4" />
|
| 197 |
-
<span className="student-history-copy">
|
| 198 |
-
<span className="student-history-title">
|
| 199 |
-
Memuat riwayat
|
| 200 |
-
</span>
|
| 201 |
-
</span>
|
| 202 |
-
</SidebarMenuButton>
|
| 203 |
-
</SidebarMenuItem>
|
| 204 |
-
)}
|
| 205 |
-
|
| 206 |
-
{chatSessions.map((session) => {
|
| 207 |
-
const sessionTime =
|
| 208 |
-
formatSessionTime(
|
| 209 |
-
session.last_message_at ??
|
| 210 |
-
session.updated_at ??
|
| 211 |
-
session.created_at,
|
| 212 |
-
);
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
>
|
| 219 |
<SidebarMenuButton
|
| 220 |
className="student-history-menu-button"
|
| 221 |
-
|
| 222 |
-
session.uuid_id ===
|
| 223 |
-
activeSessionId
|
| 224 |
-
}
|
| 225 |
-
onClick={() =>
|
| 226 |
-
router.visit(
|
| 227 |
-
showMahasiswaSession(
|
| 228 |
-
session.uuid_id,
|
| 229 |
-
),
|
| 230 |
-
)
|
| 231 |
-
}
|
| 232 |
-
tooltip={session.title}
|
| 233 |
-
type="button"
|
| 234 |
>
|
| 235 |
<Clock3 className="student-history-menu-icon size-4" />
|
| 236 |
<span className="student-history-copy">
|
| 237 |
<span className="student-history-title">
|
| 238 |
-
|
| 239 |
</span>
|
| 240 |
-
{sessionTime && (
|
| 241 |
-
<span className="student-history-meta">
|
| 242 |
-
{
|
| 243 |
-
sessionTime
|
| 244 |
-
}
|
| 245 |
-
</span>
|
| 246 |
-
)}
|
| 247 |
</span>
|
| 248 |
</SidebarMenuButton>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
type="button"
|
| 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 |
</span>
|
| 297 |
-
</
|
| 298 |
-
</
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
>
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
<SidebarRail />
|
| 315 |
-
</Sidebar>
|
| 316 |
);
|
| 317 |
}
|
| 318 |
|
|
@@ -328,7 +442,9 @@ export function StudentShell({
|
|
| 328 |
chatSessions: ChatSessionResponse[];
|
| 329 |
children: ReactNode;
|
| 330 |
isLoadingSessions: boolean;
|
| 331 |
-
onDeleteSession?: (
|
|
|
|
|
|
|
| 332 |
studentName: string;
|
| 333 |
}) {
|
| 334 |
return (
|
|
|
|
| 12 |
import AppearanceToggle from '@/components/appearance-toggle';
|
| 13 |
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
| 14 |
import { Button } from '@/components/ui/button';
|
| 15 |
+
import {
|
| 16 |
+
Dialog,
|
| 17 |
+
DialogContent,
|
| 18 |
+
DialogDescription,
|
| 19 |
+
DialogFooter,
|
| 20 |
+
DialogHeader,
|
| 21 |
+
DialogTitle,
|
| 22 |
+
} from '@/components/ui/dialog';
|
| 23 |
import {
|
| 24 |
Sidebar,
|
| 25 |
SidebarContent,
|
|
|
|
| 47 |
'--sidebar-width-icon': '64px',
|
| 48 |
} as CSSProperties;
|
| 49 |
|
| 50 |
+
export type DeleteSessionResult = {
|
| 51 |
+
ok: boolean;
|
| 52 |
+
message?: string;
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
export function getStoredStudentName(): string {
|
| 56 |
return getStoredAuthUser()?.name ?? 'Mahasiswa';
|
| 57 |
}
|
|
|
|
| 144 |
activeSessionId?: string;
|
| 145 |
chatSessions: ChatSessionResponse[];
|
| 146 |
isLoading: boolean;
|
| 147 |
+
onDeleteSession?: (
|
| 148 |
+
sessionId: string,
|
| 149 |
+
) => DeleteSessionResult | Promise<DeleteSessionResult>;
|
| 150 |
studentName: string;
|
| 151 |
}) {
|
| 152 |
const [isHistoryOpen, setIsHistoryOpen] = useState(true);
|
| 153 |
+
const [deleteDialogMessage, setDeleteDialogMessage] = useState<string>();
|
| 154 |
+
const [isDeletingSession, setIsDeletingSession] = useState(false);
|
| 155 |
+
const [sessionToDelete, setSessionToDelete] =
|
| 156 |
+
useState<ChatSessionResponse>();
|
| 157 |
const { state } = useSidebar();
|
| 158 |
const isSidebarCollapsed = state === 'collapsed';
|
| 159 |
|
| 160 |
+
function handleDeleteDialogOpenChange(open: boolean): void {
|
| 161 |
+
if (isDeletingSession) {
|
| 162 |
+
return;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
if (!open) {
|
| 166 |
+
setSessionToDelete(undefined);
|
| 167 |
+
setDeleteDialogMessage(undefined);
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
async function handleConfirmDeleteSession(): Promise<void> {
|
| 172 |
+
if (!sessionToDelete || !onDeleteSession) {
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
setIsDeletingSession(true);
|
| 177 |
+
setDeleteDialogMessage(undefined);
|
| 178 |
+
|
| 179 |
+
try {
|
| 180 |
+
const result = await onDeleteSession(sessionToDelete.uuid_id);
|
| 181 |
+
|
| 182 |
+
if (result.ok) {
|
| 183 |
+
setSessionToDelete(undefined);
|
| 184 |
+
|
| 185 |
+
return;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
setDeleteDialogMessage(result.message);
|
| 189 |
+
} finally {
|
| 190 |
+
setIsDeletingSession(false);
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
return (
|
| 195 |
+
<>
|
| 196 |
+
<Sidebar className="student-sidebar" collapsible="icon">
|
| 197 |
+
<SidebarHeader className="student-sidebar-top">
|
| 198 |
+
<StudentSidebarLogo />
|
| 199 |
+
<SidebarTrigger className="student-sidebar-trigger" />
|
| 200 |
+
</SidebarHeader>
|
| 201 |
+
|
| 202 |
+
<SidebarContent className="student-sidebar-content">
|
| 203 |
+
<SidebarGroup className="student-history-group">
|
| 204 |
+
{isSidebarCollapsed ? (
|
| 205 |
+
<SidebarGroupContent>
|
| 206 |
+
<SidebarMenu className="student-history-list">
|
| 207 |
+
<SidebarMenuItem>
|
| 208 |
+
<SidebarMenuButton
|
| 209 |
+
className="student-history-menu-button"
|
| 210 |
+
tooltip="Riwayat Chat"
|
| 211 |
+
>
|
| 212 |
+
<Clock3 className="student-history-menu-icon size-4" />
|
| 213 |
+
<span className="student-history-copy">
|
| 214 |
+
<span className="student-history-title">
|
| 215 |
+
Riwayat Chat
|
| 216 |
+
</span>
|
| 217 |
</span>
|
| 218 |
+
</SidebarMenuButton>
|
| 219 |
+
</SidebarMenuItem>
|
| 220 |
+
</SidebarMenu>
|
| 221 |
+
</SidebarGroupContent>
|
| 222 |
+
) : (
|
| 223 |
+
<>
|
| 224 |
+
<SidebarGroupLabel asChild>
|
| 225 |
+
<button
|
| 226 |
+
aria-expanded={isHistoryOpen}
|
| 227 |
+
className="student-history-label"
|
| 228 |
+
onClick={() =>
|
| 229 |
+
setIsHistoryOpen(
|
| 230 |
+
(currentState) => !currentState,
|
| 231 |
+
)
|
| 232 |
+
}
|
| 233 |
+
type="button"
|
| 234 |
+
>
|
| 235 |
+
<Clock3 className="size-4" />
|
| 236 |
+
<span>Riwayat Chat</span>
|
| 237 |
+
<ChevronDown className="student-history-chevron" />
|
| 238 |
+
</button>
|
| 239 |
+
</SidebarGroupLabel>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
+
{isHistoryOpen && (
|
| 242 |
+
<SidebarGroupContent>
|
| 243 |
+
<SidebarMenu className="student-history-list">
|
| 244 |
+
{isLoading && (
|
| 245 |
+
<SidebarMenuItem>
|
| 246 |
<SidebarMenuButton
|
| 247 |
className="student-history-menu-button"
|
| 248 |
+
disabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
>
|
| 250 |
<Clock3 className="student-history-menu-icon size-4" />
|
| 251 |
<span className="student-history-copy">
|
| 252 |
<span className="student-history-title">
|
| 253 |
+
Memuat riwayat
|
| 254 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
</span>
|
| 256 |
</SidebarMenuButton>
|
| 257 |
+
</SidebarMenuItem>
|
| 258 |
+
)}
|
| 259 |
+
|
| 260 |
+
{chatSessions.map((session) => {
|
| 261 |
+
const sessionTime =
|
| 262 |
+
formatSessionTime(
|
| 263 |
+
session.last_message_at ??
|
| 264 |
+
session.updated_at ??
|
| 265 |
+
session.created_at,
|
| 266 |
+
);
|
| 267 |
|
| 268 |
+
return (
|
| 269 |
+
<SidebarMenuItem
|
| 270 |
+
className="student-history-menu-item"
|
| 271 |
+
key={session.uuid_id}
|
| 272 |
+
>
|
| 273 |
+
<SidebarMenuButton
|
| 274 |
+
className="student-history-menu-button"
|
| 275 |
+
isActive={
|
| 276 |
+
session.uuid_id ===
|
| 277 |
+
activeSessionId
|
| 278 |
+
}
|
| 279 |
+
onClick={() =>
|
| 280 |
+
router.visit(
|
| 281 |
+
showMahasiswaSession(
|
| 282 |
+
session.uuid_id,
|
| 283 |
+
),
|
| 284 |
+
)
|
| 285 |
+
}
|
| 286 |
+
tooltip={
|
| 287 |
+
session.title
|
| 288 |
+
}
|
| 289 |
type="button"
|
| 290 |
>
|
| 291 |
+
<Clock3 className="student-history-menu-icon size-4" />
|
| 292 |
+
<span className="student-history-copy">
|
| 293 |
+
<span className="student-history-title">
|
| 294 |
+
{
|
| 295 |
+
session.title
|
| 296 |
+
}
|
| 297 |
+
</span>
|
| 298 |
+
{sessionTime && (
|
| 299 |
+
<span className="student-history-meta">
|
| 300 |
+
{
|
| 301 |
+
sessionTime
|
| 302 |
+
}
|
| 303 |
+
</span>
|
| 304 |
+
)}
|
| 305 |
+
</span>
|
| 306 |
+
</SidebarMenuButton>
|
| 307 |
+
|
| 308 |
+
{onDeleteSession && (
|
| 309 |
+
<button
|
| 310 |
+
aria-label={`Hapus sesi ${session.title}`}
|
| 311 |
+
className="student-history-delete"
|
| 312 |
+
onClick={(
|
| 313 |
+
event,
|
| 314 |
+
) => {
|
| 315 |
+
event.stopPropagation();
|
| 316 |
+
setSessionToDelete(
|
| 317 |
+
session,
|
| 318 |
+
);
|
| 319 |
+
setDeleteDialogMessage(
|
| 320 |
+
undefined,
|
| 321 |
+
);
|
| 322 |
+
}}
|
| 323 |
+
type="button"
|
| 324 |
+
>
|
| 325 |
+
<Trash2 className="size-3" />
|
| 326 |
+
</button>
|
| 327 |
+
)}
|
| 328 |
+
</SidebarMenuItem>
|
| 329 |
+
);
|
| 330 |
+
})}
|
| 331 |
+
</SidebarMenu>
|
| 332 |
+
</SidebarGroupContent>
|
| 333 |
+
)}
|
| 334 |
+
</>
|
| 335 |
+
)}
|
| 336 |
+
</SidebarGroup>
|
| 337 |
+
</SidebarContent>
|
| 338 |
+
|
| 339 |
+
<SidebarFooter className="student-sidebar-footer">
|
| 340 |
+
<SidebarMenu>
|
| 341 |
+
<SidebarMenuItem>
|
| 342 |
+
<SidebarMenuButton
|
| 343 |
+
className="student-user-menu-button"
|
| 344 |
+
tooltip={studentName}
|
| 345 |
+
>
|
| 346 |
+
<Avatar className="student-user-avatar">
|
| 347 |
+
<AvatarFallback>
|
| 348 |
+
{getInitials(studentName) || 'AK'}
|
| 349 |
+
</AvatarFallback>
|
| 350 |
+
</Avatar>
|
| 351 |
+
<span className="student-user-copy">
|
| 352 |
+
<span className="student-user-name">
|
| 353 |
+
{studentName}
|
| 354 |
+
</span>
|
| 355 |
+
<span className="student-user-role">
|
| 356 |
+
Mahasiswa
|
| 357 |
+
</span>
|
| 358 |
</span>
|
| 359 |
+
</SidebarMenuButton>
|
| 360 |
+
</SidebarMenuItem>
|
| 361 |
+
|
| 362 |
+
<SidebarMenuItem>
|
| 363 |
+
<SidebarMenuButton
|
| 364 |
+
className="student-logout-button"
|
| 365 |
+
onClick={handleLogout}
|
| 366 |
+
tooltip="Logout"
|
| 367 |
+
>
|
| 368 |
+
<LogOut className="size-4" />
|
| 369 |
+
<span>Logout</span>
|
| 370 |
+
</SidebarMenuButton>
|
| 371 |
+
</SidebarMenuItem>
|
| 372 |
+
</SidebarMenu>
|
| 373 |
+
</SidebarFooter>
|
| 374 |
+
|
| 375 |
+
<SidebarRail />
|
| 376 |
+
</Sidebar>
|
| 377 |
+
|
| 378 |
+
<Dialog
|
| 379 |
+
onOpenChange={handleDeleteDialogOpenChange}
|
| 380 |
+
open={Boolean(sessionToDelete)}
|
| 381 |
+
>
|
| 382 |
+
<DialogContent className="student-delete-dialog">
|
| 383 |
+
<DialogHeader>
|
| 384 |
+
<DialogTitle>Hapus Riwayat Chat</DialogTitle>
|
| 385 |
+
<DialogDescription>
|
| 386 |
+
Sesi chat ini akan dihapus dari riwayat.
|
| 387 |
+
</DialogDescription>
|
| 388 |
+
</DialogHeader>
|
| 389 |
+
|
| 390 |
+
<div className="student-delete-dialog-body">
|
| 391 |
+
<p className="student-delete-copy">
|
| 392 |
+
Hapus sesi{' '}
|
| 393 |
+
<strong>
|
| 394 |
+
{sessionToDelete?.title ?? 'chat ini'}
|
| 395 |
+
</strong>
|
| 396 |
+
?
|
| 397 |
+
</p>
|
| 398 |
+
|
| 399 |
+
{deleteDialogMessage && (
|
| 400 |
+
<p className="student-delete-error" role="alert">
|
| 401 |
+
{deleteDialogMessage}
|
| 402 |
+
</p>
|
| 403 |
+
)}
|
| 404 |
+
</div>
|
| 405 |
+
|
| 406 |
+
<DialogFooter className="student-delete-dialog-footer">
|
| 407 |
+
<Button
|
| 408 |
+
className="student-delete-cancel"
|
| 409 |
+
disabled={isDeletingSession}
|
| 410 |
+
onClick={() => handleDeleteDialogOpenChange(false)}
|
| 411 |
+
type="button"
|
| 412 |
+
variant="outline"
|
| 413 |
+
>
|
| 414 |
+
Batal
|
| 415 |
+
</Button>
|
| 416 |
+
<Button
|
| 417 |
+
className="student-delete-confirm"
|
| 418 |
+
disabled={isDeletingSession}
|
| 419 |
+
onClick={() => {
|
| 420 |
+
void handleConfirmDeleteSession();
|
| 421 |
+
}}
|
| 422 |
+
type="button"
|
| 423 |
>
|
| 424 |
+
{isDeletingSession ? 'Menghapus...' : 'Hapus'}
|
| 425 |
+
</Button>
|
| 426 |
+
</DialogFooter>
|
| 427 |
+
</DialogContent>
|
| 428 |
+
</Dialog>
|
| 429 |
+
</>
|
|
|
|
|
|
|
|
|
|
| 430 |
);
|
| 431 |
}
|
| 432 |
|
|
|
|
| 442 |
chatSessions: ChatSessionResponse[];
|
| 443 |
children: ReactNode;
|
| 444 |
isLoadingSessions: boolean;
|
| 445 |
+
onDeleteSession?: (
|
| 446 |
+
sessionId: string,
|
| 447 |
+
) => DeleteSessionResult | Promise<DeleteSessionResult>;
|
| 448 |
studentName: string;
|
| 449 |
}) {
|
| 450 |
return (
|
resources/js/lib/rag.ts
CHANGED
|
@@ -24,6 +24,46 @@ export type CourseListResponse = {
|
|
| 24 |
limit: number;
|
| 25 |
};
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
export type ChatSessionResponse = {
|
| 28 |
uuid_id: string;
|
| 29 |
user_id: string;
|
|
@@ -74,6 +114,12 @@ export type ChatMessageResponse = {
|
|
| 74 |
created_at: string;
|
| 75 |
};
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
export type ChatQueryResponse = {
|
| 78 |
message_id: string;
|
| 79 |
role?: string;
|
|
@@ -106,11 +152,17 @@ type SendConfiguredChatMessagePayload = {
|
|
| 106 |
};
|
| 107 |
|
| 108 |
const DIRECT_CHAT_STORAGE_PREFIX = 'sevima_raghub_direct_messages';
|
|
|
|
|
|
|
| 109 |
|
| 110 |
function getDirectChatStorageKey(sessionId: string): string {
|
| 111 |
return `${DIRECT_CHAT_STORAGE_PREFIX}:${sessionId}`;
|
| 112 |
}
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
function getBrowserSessionStorage(): Storage | null {
|
| 115 |
if (typeof window === 'undefined') {
|
| 116 |
return null;
|
|
@@ -197,6 +249,50 @@ export function clearDirectChatMessages(sessionId: string): void {
|
|
| 197 |
getBrowserSessionStorage()?.removeItem(getDirectChatStorageKey(sessionId));
|
| 198 |
}
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
export const ragApi = {
|
| 201 |
listCourses({ page = 1, limit = 100, signal }: ListCoursesOptions = {}) {
|
| 202 |
return api.get<CourseListResponse>('/courses', {
|
|
@@ -205,6 +301,38 @@ export const ragApi = {
|
|
| 205 |
});
|
| 206 |
},
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
listChatSessions({
|
| 209 |
page = 1,
|
| 210 |
limit = 20,
|
|
@@ -225,6 +353,26 @@ export const ragApi = {
|
|
| 225 |
});
|
| 226 |
},
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
getChatSession(
|
| 229 |
sessionId: string,
|
| 230 |
{ signal, token }: AuthenticatedRequestOptions = {},
|
|
|
|
| 24 |
limit: number;
|
| 25 |
};
|
| 26 |
|
| 27 |
+
export type CreateCourseRequest = {
|
| 28 |
+
title: string;
|
| 29 |
+
description?: string;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
export type UpdateCourseRequest = {
|
| 33 |
+
title?: string;
|
| 34 |
+
description?: string;
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
export type DocumentStatus = 'processing' | 'ready' | 'failed';
|
| 38 |
+
|
| 39 |
+
export type DocumentResponse = {
|
| 40 |
+
id: string;
|
| 41 |
+
filename: string;
|
| 42 |
+
file_type: string;
|
| 43 |
+
file_size: number;
|
| 44 |
+
status: DocumentStatus;
|
| 45 |
+
course_id: string;
|
| 46 |
+
course_name?: string;
|
| 47 |
+
uploaded_by: string;
|
| 48 |
+
uploader_name?: string;
|
| 49 |
+
summary?: string;
|
| 50 |
+
chunk_count?: number;
|
| 51 |
+
error?: string;
|
| 52 |
+
created_at: string;
|
| 53 |
+
updated_at: string;
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
export type DocumentListResponse = {
|
| 57 |
+
data: DocumentResponse[];
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
export type UploadDocumentResponse = {
|
| 61 |
+
document_id: string;
|
| 62 |
+
filename: string;
|
| 63 |
+
status: DocumentStatus;
|
| 64 |
+
message: string;
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
export type ChatSessionResponse = {
|
| 68 |
uuid_id: string;
|
| 69 |
user_id: string;
|
|
|
|
| 114 |
created_at: string;
|
| 115 |
};
|
| 116 |
|
| 117 |
+
export type PendingInitialChat = {
|
| 118 |
+
content: string;
|
| 119 |
+
courseId: string;
|
| 120 |
+
userMessage: ChatMessageResponse;
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
export type ChatQueryResponse = {
|
| 124 |
message_id: string;
|
| 125 |
role?: string;
|
|
|
|
| 152 |
};
|
| 153 |
|
| 154 |
const DIRECT_CHAT_STORAGE_PREFIX = 'sevima_raghub_direct_messages';
|
| 155 |
+
const PENDING_INITIAL_CHAT_STORAGE_PREFIX =
|
| 156 |
+
'sevima_raghub_pending_initial_chat';
|
| 157 |
|
| 158 |
function getDirectChatStorageKey(sessionId: string): string {
|
| 159 |
return `${DIRECT_CHAT_STORAGE_PREFIX}:${sessionId}`;
|
| 160 |
}
|
| 161 |
|
| 162 |
+
function getPendingInitialChatStorageKey(sessionId: string): string {
|
| 163 |
+
return `${PENDING_INITIAL_CHAT_STORAGE_PREFIX}:${sessionId}`;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
function getBrowserSessionStorage(): Storage | null {
|
| 167 |
if (typeof window === 'undefined') {
|
| 168 |
return null;
|
|
|
|
| 249 |
getBrowserSessionStorage()?.removeItem(getDirectChatStorageKey(sessionId));
|
| 250 |
}
|
| 251 |
|
| 252 |
+
export function getPendingInitialChat(
|
| 253 |
+
sessionId: string,
|
| 254 |
+
): PendingInitialChat | undefined {
|
| 255 |
+
const rawPendingChat = getBrowserSessionStorage()?.getItem(
|
| 256 |
+
getPendingInitialChatStorageKey(sessionId),
|
| 257 |
+
);
|
| 258 |
+
|
| 259 |
+
if (!rawPendingChat) {
|
| 260 |
+
return undefined;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
try {
|
| 264 |
+
const pendingChat = JSON.parse(rawPendingChat) as PendingInitialChat;
|
| 265 |
+
|
| 266 |
+
if (
|
| 267 |
+
typeof pendingChat.content === 'string' &&
|
| 268 |
+
typeof pendingChat.courseId === 'string' &&
|
| 269 |
+
typeof pendingChat.userMessage?.uuid_id === 'string'
|
| 270 |
+
) {
|
| 271 |
+
return pendingChat;
|
| 272 |
+
}
|
| 273 |
+
} catch {
|
| 274 |
+
return undefined;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
return undefined;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
export function savePendingInitialChat(
|
| 281 |
+
sessionId: string,
|
| 282 |
+
pendingChat: PendingInitialChat,
|
| 283 |
+
): void {
|
| 284 |
+
getBrowserSessionStorage()?.setItem(
|
| 285 |
+
getPendingInitialChatStorageKey(sessionId),
|
| 286 |
+
JSON.stringify(pendingChat),
|
| 287 |
+
);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
export function clearPendingInitialChat(sessionId: string): void {
|
| 291 |
+
getBrowserSessionStorage()?.removeItem(
|
| 292 |
+
getPendingInitialChatStorageKey(sessionId),
|
| 293 |
+
);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
export const ragApi = {
|
| 297 |
listCourses({ page = 1, limit = 100, signal }: ListCoursesOptions = {}) {
|
| 298 |
return api.get<CourseListResponse>('/courses', {
|
|
|
|
| 301 |
});
|
| 302 |
},
|
| 303 |
|
| 304 |
+
getCourse(
|
| 305 |
+
courseId: string,
|
| 306 |
+
{ signal, token }: AuthenticatedRequestOptions = {},
|
| 307 |
+
) {
|
| 308 |
+
return api.get<CourseResponse>(`/courses/${courseId}`, {
|
| 309 |
+
signal,
|
| 310 |
+
token,
|
| 311 |
+
});
|
| 312 |
+
},
|
| 313 |
+
|
| 314 |
+
createCourse(payload: CreateCourseRequest, token?: string) {
|
| 315 |
+
return api.post<CourseResponse>('/courses', payload, {
|
| 316 |
+
token,
|
| 317 |
+
});
|
| 318 |
+
},
|
| 319 |
+
|
| 320 |
+
updateCourse(
|
| 321 |
+
courseId: string,
|
| 322 |
+
payload: UpdateCourseRequest,
|
| 323 |
+
token?: string,
|
| 324 |
+
) {
|
| 325 |
+
return api.put<CourseResponse>(`/courses/${courseId}`, payload, {
|
| 326 |
+
token,
|
| 327 |
+
});
|
| 328 |
+
},
|
| 329 |
+
|
| 330 |
+
deleteCourse(courseId: string, token?: string) {
|
| 331 |
+
return api.delete<unknown>(`/courses/${courseId}`, {
|
| 332 |
+
token,
|
| 333 |
+
});
|
| 334 |
+
},
|
| 335 |
+
|
| 336 |
listChatSessions({
|
| 337 |
page = 1,
|
| 338 |
limit = 20,
|
|
|
|
| 353 |
});
|
| 354 |
},
|
| 355 |
|
| 356 |
+
listCourseDocuments(
|
| 357 |
+
courseId: string,
|
| 358 |
+
{ signal, token }: AuthenticatedRequestOptions = {},
|
| 359 |
+
) {
|
| 360 |
+
return api.get<DocumentListResponse>(`/courses/${courseId}/documents`, {
|
| 361 |
+
signal,
|
| 362 |
+
token,
|
| 363 |
+
});
|
| 364 |
+
},
|
| 365 |
+
|
| 366 |
+
uploadDocument(courseId: string, file: File, token?: string) {
|
| 367 |
+
const formData = new FormData();
|
| 368 |
+
formData.append('file', file);
|
| 369 |
+
formData.append('course_id', courseId);
|
| 370 |
+
|
| 371 |
+
return api.post<UploadDocumentResponse>('/documents/upload', formData, {
|
| 372 |
+
token,
|
| 373 |
+
});
|
| 374 |
+
},
|
| 375 |
+
|
| 376 |
getChatSession(
|
| 377 |
sessionId: string,
|
| 378 |
{ signal, token }: AuthenticatedRequestOptions = {},
|
resources/js/pages/auth/login.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|
| 8 |
import { Input } from '@/components/ui/input';
|
| 9 |
import { Label } from '@/components/ui/label';
|
| 10 |
import { authApi, getAuthErrorMessage, saveAuthSession } from '@/lib/auth';
|
| 11 |
-
import { dashboard, home, mahasiswa, register } from '@/routes';
|
| 12 |
|
| 13 |
type LoginProps = {
|
| 14 |
canRegister?: boolean;
|
|
@@ -37,9 +37,19 @@ export default function Login({ canRegister = true, status }: LoginProps) {
|
|
| 37 |
|
| 38 |
saveAuthSession(response, remember);
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
} catch (error) {
|
| 44 |
setBackendError(getAuthErrorMessage(error));
|
| 45 |
} finally {
|
|
|
|
| 8 |
import { Input } from '@/components/ui/input';
|
| 9 |
import { Label } from '@/components/ui/label';
|
| 10 |
import { authApi, getAuthErrorMessage, saveAuthSession } from '@/lib/auth';
|
| 11 |
+
import { dashboard, dosen, home, mahasiswa, register } from '@/routes';
|
| 12 |
|
| 13 |
type LoginProps = {
|
| 14 |
canRegister?: boolean;
|
|
|
|
| 37 |
|
| 38 |
saveAuthSession(response, remember);
|
| 39 |
|
| 40 |
+
if (response.user.role === 'student') {
|
| 41 |
+
router.visit(mahasiswa());
|
| 42 |
+
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (response.user.role === 'lecturer') {
|
| 47 |
+
router.visit(dosen());
|
| 48 |
+
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
router.visit(dashboard());
|
| 53 |
} catch (error) {
|
| 54 |
setBackendError(getAuthErrorMessage(error));
|
| 55 |
} finally {
|
resources/js/pages/dosen-detail.tsx
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Head, router } from '@inertiajs/react';
|
| 2 |
+
import {
|
| 3 |
+
AlertCircle,
|
| 4 |
+
ArrowLeft,
|
| 5 |
+
CheckCircle2,
|
| 6 |
+
FileText,
|
| 7 |
+
LoaderCircle,
|
| 8 |
+
Pencil,
|
| 9 |
+
Trash2,
|
| 10 |
+
Upload,
|
| 11 |
+
} from 'lucide-react';
|
| 12 |
+
import type { ChangeEvent, DragEvent, FormEvent } from 'react';
|
| 13 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
| 14 |
+
|
| 15 |
+
import { DosenShell } from '@/components/dosen/dosen-shell';
|
| 16 |
+
import { Button } from '@/components/ui/button';
|
| 17 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 18 |
+
import {
|
| 19 |
+
Dialog,
|
| 20 |
+
DialogContent,
|
| 21 |
+
DialogDescription,
|
| 22 |
+
DialogFooter,
|
| 23 |
+
DialogHeader,
|
| 24 |
+
DialogTitle,
|
| 25 |
+
} from '@/components/ui/dialog';
|
| 26 |
+
import { Input } from '@/components/ui/input';
|
| 27 |
+
import { Label } from '@/components/ui/label';
|
| 28 |
+
import { getStoredAuthToken } from '@/lib/auth';
|
| 29 |
+
import type {
|
| 30 |
+
CourseResponse,
|
| 31 |
+
DocumentResponse,
|
| 32 |
+
DocumentStatus,
|
| 33 |
+
} from '@/lib/rag';
|
| 34 |
+
import { getRagErrorMessage, ragApi } from '@/lib/rag';
|
| 35 |
+
import { dosen } from '@/routes';
|
| 36 |
+
|
| 37 |
+
type DosenDetailProps = {
|
| 38 |
+
courseId: string;
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
type CourseForm = {
|
| 42 |
+
title: string;
|
| 43 |
+
description: string;
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const emptyCourseForm: CourseForm = {
|
| 47 |
+
description: '',
|
| 48 |
+
title: '',
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const acceptedDocumentTypes = [
|
| 52 |
+
'.pdf',
|
| 53 |
+
'.ppt',
|
| 54 |
+
'.pptx',
|
| 55 |
+
'.txt',
|
| 56 |
+
'application/pdf',
|
| 57 |
+
'application/vnd.ms-powerpoint',
|
| 58 |
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
| 59 |
+
'text/plain',
|
| 60 |
+
].join(',');
|
| 61 |
+
|
| 62 |
+
function formatDate(value?: string): string {
|
| 63 |
+
if (!value) {
|
| 64 |
+
return '-';
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const datePart = value.split('T')[0];
|
| 68 |
+
|
| 69 |
+
return datePart || value;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function formatFileSize(bytes: number): string {
|
| 73 |
+
if (!Number.isFinite(bytes) || bytes <= 0) {
|
| 74 |
+
return '-';
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const units = ['B', 'KB', 'MB', 'GB'];
|
| 78 |
+
let size = bytes;
|
| 79 |
+
let unitIndex = 0;
|
| 80 |
+
|
| 81 |
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
| 82 |
+
size /= 1024;
|
| 83 |
+
unitIndex += 1;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function getStatusLabel(status: DocumentStatus): string {
|
| 90 |
+
if (status === 'failed') {
|
| 91 |
+
return 'Gagal diproses';
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
if (status === 'processing') {
|
| 95 |
+
return 'Sedang diproses';
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return 'Tersedia untuk Q&A';
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function DocumentStatusIcon({ status }: { status: DocumentStatus }) {
|
| 102 |
+
if (status === 'failed') {
|
| 103 |
+
return <AlertCircle className="size-4" />;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if (status === 'processing') {
|
| 107 |
+
return <LoaderCircle className="size-4 animate-spin" />;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return <CheckCircle2 className="size-4" />;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function DocumentCard({ document }: { document: DocumentResponse }) {
|
| 114 |
+
const statusTone = document.status;
|
| 115 |
+
|
| 116 |
+
return (
|
| 117 |
+
<Card className="lecturer-document-card" data-status={statusTone}>
|
| 118 |
+
<CardContent className="lecturer-document-content">
|
| 119 |
+
<span
|
| 120 |
+
className="lecturer-document-icon"
|
| 121 |
+
data-status={statusTone}
|
| 122 |
+
>
|
| 123 |
+
<FileText className="size-5" />
|
| 124 |
+
</span>
|
| 125 |
+
|
| 126 |
+
<div className="lecturer-document-copy">
|
| 127 |
+
<h3>{document.filename}</h3>
|
| 128 |
+
<p className="lecturer-document-meta">
|
| 129 |
+
<span>{formatFileSize(document.file_size)}</span>
|
| 130 |
+
<span aria-hidden="true">·</span>
|
| 131 |
+
<span>{formatDate(document.created_at)}</span>
|
| 132 |
+
{document.chunk_count !== undefined && (
|
| 133 |
+
<>
|
| 134 |
+
<span aria-hidden="true">·</span>
|
| 135 |
+
<span>{document.chunk_count} chunks</span>
|
| 136 |
+
</>
|
| 137 |
+
)}
|
| 138 |
+
</p>
|
| 139 |
+
|
| 140 |
+
<p
|
| 141 |
+
className="lecturer-document-status"
|
| 142 |
+
data-status={statusTone}
|
| 143 |
+
>
|
| 144 |
+
<DocumentStatusIcon status={document.status} />
|
| 145 |
+
{getStatusLabel(document.status)}
|
| 146 |
+
</p>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div className="lecturer-document-actions">
|
| 150 |
+
<button
|
| 151 |
+
aria-label={`Hapus ${document.filename}`}
|
| 152 |
+
className="lecturer-document-delete"
|
| 153 |
+
type="button"
|
| 154 |
+
>
|
| 155 |
+
<Trash2 className="size-4" />
|
| 156 |
+
</button>
|
| 157 |
+
</div>
|
| 158 |
+
</CardContent>
|
| 159 |
+
</Card>
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
export default function DosenDetail({ courseId }: DosenDetailProps) {
|
| 164 |
+
const [course, setCourse] = useState<CourseResponse>();
|
| 165 |
+
const [documents, setDocuments] = useState<DocumentResponse[]>([]);
|
| 166 |
+
const [deleteCourseMessage, setDeleteCourseMessage] = useState<string>();
|
| 167 |
+
const [editCourseMessage, setEditCourseMessage] = useState<string>();
|
| 168 |
+
const [editForm, setEditForm] = useState<CourseForm>(emptyCourseForm);
|
| 169 |
+
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
| 170 |
+
const [isDeletingCourse, setIsDeletingCourse] = useState(false);
|
| 171 |
+
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
| 172 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 173 |
+
const [isUpdatingCourse, setIsUpdatingCourse] = useState(false);
|
| 174 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 175 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 176 |
+
|
| 177 |
+
const loadCourseDetail = useCallback(
|
| 178 |
+
async (signal?: AbortSignal): Promise<void> => {
|
| 179 |
+
setIsLoading(true);
|
| 180 |
+
|
| 181 |
+
const token = getStoredAuthToken() ?? undefined;
|
| 182 |
+
|
| 183 |
+
const [courseResult, documentsResult] = await Promise.allSettled([
|
| 184 |
+
ragApi.getCourse(courseId, {
|
| 185 |
+
signal,
|
| 186 |
+
token,
|
| 187 |
+
}),
|
| 188 |
+
ragApi.listCourseDocuments(courseId, {
|
| 189 |
+
signal,
|
| 190 |
+
token,
|
| 191 |
+
}),
|
| 192 |
+
]);
|
| 193 |
+
|
| 194 |
+
if (signal?.aborted) {
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
if (courseResult.status === 'fulfilled') {
|
| 199 |
+
setCourse(courseResult.value);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
if (documentsResult.status === 'fulfilled') {
|
| 203 |
+
setDocuments(documentsResult.value.data);
|
| 204 |
+
} else {
|
| 205 |
+
setDocuments([]);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
setIsLoading(false);
|
| 209 |
+
},
|
| 210 |
+
[courseId],
|
| 211 |
+
);
|
| 212 |
+
|
| 213 |
+
useEffect(() => {
|
| 214 |
+
const controller = new AbortController();
|
| 215 |
+
|
| 216 |
+
void Promise.resolve().then(() => loadCourseDetail(controller.signal));
|
| 217 |
+
|
| 218 |
+
return () => {
|
| 219 |
+
controller.abort();
|
| 220 |
+
};
|
| 221 |
+
}, [loadCourseDetail]);
|
| 222 |
+
|
| 223 |
+
async function handleUploadDocument(file: File): Promise<void> {
|
| 224 |
+
setIsUploading(true);
|
| 225 |
+
|
| 226 |
+
try {
|
| 227 |
+
await ragApi.uploadDocument(
|
| 228 |
+
courseId,
|
| 229 |
+
file,
|
| 230 |
+
getStoredAuthToken() ?? undefined,
|
| 231 |
+
);
|
| 232 |
+
|
| 233 |
+
await loadCourseDetail();
|
| 234 |
+
} catch {
|
| 235 |
+
// Backend upload messages are intentionally hidden in this view.
|
| 236 |
+
} finally {
|
| 237 |
+
setIsUploading(false);
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function handleEditDialogOpenChange(open: boolean): void {
|
| 242 |
+
if (isUpdatingCourse) {
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
setIsEditDialogOpen(open);
|
| 247 |
+
setEditCourseMessage(undefined);
|
| 248 |
+
|
| 249 |
+
if (open) {
|
| 250 |
+
setEditForm({
|
| 251 |
+
description: course?.description ?? '',
|
| 252 |
+
title: course?.title ?? '',
|
| 253 |
+
});
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
async function handleUpdateCourseSubmit(
|
| 258 |
+
event: FormEvent<HTMLFormElement>,
|
| 259 |
+
): Promise<void> {
|
| 260 |
+
event.preventDefault();
|
| 261 |
+
setEditCourseMessage(undefined);
|
| 262 |
+
setIsUpdatingCourse(true);
|
| 263 |
+
|
| 264 |
+
try {
|
| 265 |
+
const updatedCourse = await ragApi.updateCourse(
|
| 266 |
+
courseId,
|
| 267 |
+
{
|
| 268 |
+
description: editForm.description.trim(),
|
| 269 |
+
title: editForm.title.trim(),
|
| 270 |
+
},
|
| 271 |
+
getStoredAuthToken() ?? undefined,
|
| 272 |
+
);
|
| 273 |
+
|
| 274 |
+
setCourse(updatedCourse);
|
| 275 |
+
setIsEditDialogOpen(false);
|
| 276 |
+
} catch (error) {
|
| 277 |
+
setEditCourseMessage(getRagErrorMessage(error));
|
| 278 |
+
} finally {
|
| 279 |
+
setIsUpdatingCourse(false);
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
function handleDeleteDialogOpenChange(open: boolean): void {
|
| 284 |
+
if (isDeletingCourse) {
|
| 285 |
+
return;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
setIsDeleteDialogOpen(open);
|
| 289 |
+
setDeleteCourseMessage(undefined);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
async function handleDeleteCourse(): Promise<void> {
|
| 293 |
+
setDeleteCourseMessage(undefined);
|
| 294 |
+
setIsDeletingCourse(true);
|
| 295 |
+
|
| 296 |
+
try {
|
| 297 |
+
await ragApi.deleteCourse(
|
| 298 |
+
courseId,
|
| 299 |
+
getStoredAuthToken() ?? undefined,
|
| 300 |
+
);
|
| 301 |
+
router.visit(dosen());
|
| 302 |
+
} catch (error) {
|
| 303 |
+
setDeleteCourseMessage(getRagErrorMessage(error));
|
| 304 |
+
} finally {
|
| 305 |
+
setIsDeletingCourse(false);
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function handleFileInputChange(event: ChangeEvent<HTMLInputElement>): void {
|
| 310 |
+
const file = event.target.files?.[0];
|
| 311 |
+
event.target.value = '';
|
| 312 |
+
|
| 313 |
+
if (file) {
|
| 314 |
+
void handleUploadDocument(file);
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function handleUploadCardClick(): void {
|
| 319 |
+
if (!isUploading) {
|
| 320 |
+
fileInputRef.current?.click();
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
function handleUploadCardDrop(event: DragEvent<HTMLButtonElement>): void {
|
| 325 |
+
event.preventDefault();
|
| 326 |
+
|
| 327 |
+
if (isUploading) {
|
| 328 |
+
return;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
const file = event.dataTransfer.files[0];
|
| 332 |
+
|
| 333 |
+
if (file) {
|
| 334 |
+
void handleUploadDocument(file);
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
const title =
|
| 339 |
+
course?.title ??
|
| 340 |
+
(isLoading ? 'Memuat mata kuliah...' : 'Detail mata kuliah');
|
| 341 |
+
const description =
|
| 342 |
+
course?.description ??
|
| 343 |
+
(isLoading
|
| 344 |
+
? 'Mengambil detail mata kuliah dari backend.'
|
| 345 |
+
: 'Tidak ada deskripsi mata kuliah.');
|
| 346 |
+
|
| 347 |
+
return (
|
| 348 |
+
<>
|
| 349 |
+
<Head title={course ? course.title : 'Detail Mata Kuliah'} />
|
| 350 |
+
|
| 351 |
+
<DosenShell>
|
| 352 |
+
<main className="lecturer-detail-page">
|
| 353 |
+
<section className="lecturer-detail-header">
|
| 354 |
+
<button
|
| 355 |
+
aria-label="Kembali ke dashboard dosen"
|
| 356 |
+
className="lecturer-detail-back"
|
| 357 |
+
onClick={() => router.visit(dosen())}
|
| 358 |
+
type="button"
|
| 359 |
+
>
|
| 360 |
+
<ArrowLeft className="size-6" />
|
| 361 |
+
</button>
|
| 362 |
+
|
| 363 |
+
<div className="lecturer-detail-heading">
|
| 364 |
+
<div className="lecturer-detail-title-row">
|
| 365 |
+
<h1>{title}</h1>
|
| 366 |
+
</div>
|
| 367 |
+
<p>{description}</p>
|
| 368 |
+
</div>
|
| 369 |
+
|
| 370 |
+
<div className="lecturer-detail-actions">
|
| 371 |
+
<Button
|
| 372 |
+
className="lecturer-edit-button"
|
| 373 |
+
disabled={!course || isLoading}
|
| 374 |
+
onClick={() => handleEditDialogOpenChange(true)}
|
| 375 |
+
variant="outline"
|
| 376 |
+
>
|
| 377 |
+
<Pencil className="size-5" />
|
| 378 |
+
Edit
|
| 379 |
+
</Button>
|
| 380 |
+
<Button
|
| 381 |
+
className="lecturer-delete-button"
|
| 382 |
+
disabled={!course || isLoading}
|
| 383 |
+
onClick={() =>
|
| 384 |
+
handleDeleteDialogOpenChange(true)
|
| 385 |
+
}
|
| 386 |
+
variant="ghost"
|
| 387 |
+
>
|
| 388 |
+
<Trash2 className="size-5" />
|
| 389 |
+
</Button>
|
| 390 |
+
</div>
|
| 391 |
+
</section>
|
| 392 |
+
|
| 393 |
+
<section className="lecturer-detail-content">
|
| 394 |
+
<input
|
| 395 |
+
accept={acceptedDocumentTypes}
|
| 396 |
+
className="lecturer-upload-input"
|
| 397 |
+
disabled={isUploading}
|
| 398 |
+
onChange={handleFileInputChange}
|
| 399 |
+
ref={fileInputRef}
|
| 400 |
+
type="file"
|
| 401 |
+
/>
|
| 402 |
+
|
| 403 |
+
<button
|
| 404 |
+
className="lecturer-upload-card"
|
| 405 |
+
data-uploading={isUploading}
|
| 406 |
+
disabled={isUploading}
|
| 407 |
+
onClick={handleUploadCardClick}
|
| 408 |
+
onDragOver={(event) => event.preventDefault()}
|
| 409 |
+
onDrop={handleUploadCardDrop}
|
| 410 |
+
type="button"
|
| 411 |
+
>
|
| 412 |
+
<span className="lecturer-upload-icon">
|
| 413 |
+
{isUploading ? (
|
| 414 |
+
<LoaderCircle className="size-7 animate-spin" />
|
| 415 |
+
) : (
|
| 416 |
+
<Upload className="size-7" />
|
| 417 |
+
)}
|
| 418 |
+
</span>
|
| 419 |
+
<span className="lecturer-upload-title">
|
| 420 |
+
{isUploading
|
| 421 |
+
? 'Mengupload dokumen...'
|
| 422 |
+
: 'Upload Dokumen Baru'}
|
| 423 |
+
</span>
|
| 424 |
+
<span className="lecturer-upload-description">
|
| 425 |
+
Drag & drop file PDF, PPT, atau TXT di sini.
|
| 426 |
+
Maks 100MB per file. Sistem otomatis mengekstrak
|
| 427 |
+
teks dan membuat embedding.
|
| 428 |
+
</span>
|
| 429 |
+
</button>
|
| 430 |
+
|
| 431 |
+
<section className="lecturer-document-section">
|
| 432 |
+
<h2 className="lecturer-document-heading">
|
| 433 |
+
<FileText className="size-5" />
|
| 434 |
+
<span>
|
| 435 |
+
Dokumen dalam Knowledge Base (
|
| 436 |
+
{documents.length})
|
| 437 |
+
</span>
|
| 438 |
+
</h2>
|
| 439 |
+
|
| 440 |
+
<div className="lecturer-document-list">
|
| 441 |
+
{isLoading && (
|
| 442 |
+
<p className="lecturer-detail-empty">
|
| 443 |
+
Memuat dokumen...
|
| 444 |
+
</p>
|
| 445 |
+
)}
|
| 446 |
+
|
| 447 |
+
{!isLoading && documents.length === 0 && (
|
| 448 |
+
<p className="lecturer-detail-empty">
|
| 449 |
+
Belum ada dokumen pada knowledge base
|
| 450 |
+
ini.
|
| 451 |
+
</p>
|
| 452 |
+
)}
|
| 453 |
+
|
| 454 |
+
{!isLoading &&
|
| 455 |
+
documents.map((document) => (
|
| 456 |
+
<DocumentCard
|
| 457 |
+
document={document}
|
| 458 |
+
key={document.id}
|
| 459 |
+
/>
|
| 460 |
+
))}
|
| 461 |
+
</div>
|
| 462 |
+
</section>
|
| 463 |
+
</section>
|
| 464 |
+
</main>
|
| 465 |
+
|
| 466 |
+
<Dialog
|
| 467 |
+
onOpenChange={handleEditDialogOpenChange}
|
| 468 |
+
open={isEditDialogOpen}
|
| 469 |
+
>
|
| 470 |
+
<DialogContent className="lecturer-course-dialog">
|
| 471 |
+
<DialogHeader>
|
| 472 |
+
<DialogTitle>Edit Mata Kuliah</DialogTitle>
|
| 473 |
+
<DialogDescription>
|
| 474 |
+
Perbarui nama dan deskripsi knowledge base.
|
| 475 |
+
</DialogDescription>
|
| 476 |
+
</DialogHeader>
|
| 477 |
+
|
| 478 |
+
<form
|
| 479 |
+
className="lecturer-course-form"
|
| 480 |
+
onSubmit={(event) => {
|
| 481 |
+
void handleUpdateCourseSubmit(event);
|
| 482 |
+
}}
|
| 483 |
+
>
|
| 484 |
+
<div className="lecturer-form-field">
|
| 485 |
+
<Label
|
| 486 |
+
className="lecturer-form-label"
|
| 487 |
+
htmlFor="edit-course-title"
|
| 488 |
+
>
|
| 489 |
+
Nama Mata Kuliah
|
| 490 |
+
</Label>
|
| 491 |
+
<Input
|
| 492 |
+
className="lecturer-form-input"
|
| 493 |
+
disabled={isUpdatingCourse}
|
| 494 |
+
id="edit-course-title"
|
| 495 |
+
onChange={(event) =>
|
| 496 |
+
setEditForm((current) => ({
|
| 497 |
+
...current,
|
| 498 |
+
title: event.target.value,
|
| 499 |
+
}))
|
| 500 |
+
}
|
| 501 |
+
placeholder="Masukkan nama mata kuliah"
|
| 502 |
+
value={editForm.title}
|
| 503 |
+
/>
|
| 504 |
+
</div>
|
| 505 |
+
|
| 506 |
+
<div className="lecturer-form-field">
|
| 507 |
+
<Label
|
| 508 |
+
className="lecturer-form-label"
|
| 509 |
+
htmlFor="edit-course-description"
|
| 510 |
+
>
|
| 511 |
+
Deskripsi
|
| 512 |
+
</Label>
|
| 513 |
+
<textarea
|
| 514 |
+
className="lecturer-form-textarea"
|
| 515 |
+
disabled={isUpdatingCourse}
|
| 516 |
+
id="edit-course-description"
|
| 517 |
+
onChange={(event) =>
|
| 518 |
+
setEditForm((current) => ({
|
| 519 |
+
...current,
|
| 520 |
+
description: event.target.value,
|
| 521 |
+
}))
|
| 522 |
+
}
|
| 523 |
+
placeholder="Masukkan deskripsi mata kuliah"
|
| 524 |
+
value={editForm.description}
|
| 525 |
+
/>
|
| 526 |
+
</div>
|
| 527 |
+
|
| 528 |
+
{editCourseMessage && (
|
| 529 |
+
<p className="lecturer-status" role="alert">
|
| 530 |
+
{editCourseMessage}
|
| 531 |
+
</p>
|
| 532 |
+
)}
|
| 533 |
+
|
| 534 |
+
<DialogFooter className="lecturer-course-footer">
|
| 535 |
+
<Button
|
| 536 |
+
className="lecturer-cancel-button"
|
| 537 |
+
disabled={isUpdatingCourse}
|
| 538 |
+
onClick={() =>
|
| 539 |
+
handleEditDialogOpenChange(false)
|
| 540 |
+
}
|
| 541 |
+
type="button"
|
| 542 |
+
variant="outline"
|
| 543 |
+
>
|
| 544 |
+
Batal
|
| 545 |
+
</Button>
|
| 546 |
+
<Button
|
| 547 |
+
className="lecturer-create-button"
|
| 548 |
+
disabled={isUpdatingCourse}
|
| 549 |
+
type="submit"
|
| 550 |
+
>
|
| 551 |
+
{isUpdatingCourse
|
| 552 |
+
? 'Menyimpan...'
|
| 553 |
+
: 'Simpan'}
|
| 554 |
+
</Button>
|
| 555 |
+
</DialogFooter>
|
| 556 |
+
</form>
|
| 557 |
+
</DialogContent>
|
| 558 |
+
</Dialog>
|
| 559 |
+
|
| 560 |
+
<Dialog
|
| 561 |
+
onOpenChange={handleDeleteDialogOpenChange}
|
| 562 |
+
open={isDeleteDialogOpen}
|
| 563 |
+
>
|
| 564 |
+
<DialogContent className="lecturer-course-dialog">
|
| 565 |
+
<DialogHeader>
|
| 566 |
+
<DialogTitle>Hapus Mata Kuliah</DialogTitle>
|
| 567 |
+
<DialogDescription>
|
| 568 |
+
Tindakan ini akan menghapus knowledge base dari
|
| 569 |
+
backend.
|
| 570 |
+
</DialogDescription>
|
| 571 |
+
</DialogHeader>
|
| 572 |
+
|
| 573 |
+
<div className="lecturer-course-form">
|
| 574 |
+
<p className="lecturer-delete-copy">
|
| 575 |
+
Mata kuliah{' '}
|
| 576 |
+
<strong>{course?.title ?? title}</strong> akan
|
| 577 |
+
dihapus. Pastikan data ini memang sudah tidak
|
| 578 |
+
dipakai.
|
| 579 |
+
</p>
|
| 580 |
+
|
| 581 |
+
{deleteCourseMessage && (
|
| 582 |
+
<p className="lecturer-status" role="alert">
|
| 583 |
+
{deleteCourseMessage}
|
| 584 |
+
</p>
|
| 585 |
+
)}
|
| 586 |
+
|
| 587 |
+
<DialogFooter className="lecturer-course-footer">
|
| 588 |
+
<Button
|
| 589 |
+
className="lecturer-cancel-button"
|
| 590 |
+
disabled={isDeletingCourse}
|
| 591 |
+
onClick={() =>
|
| 592 |
+
handleDeleteDialogOpenChange(false)
|
| 593 |
+
}
|
| 594 |
+
type="button"
|
| 595 |
+
variant="outline"
|
| 596 |
+
>
|
| 597 |
+
Batal
|
| 598 |
+
</Button>
|
| 599 |
+
<Button
|
| 600 |
+
className="lecturer-danger-action-button"
|
| 601 |
+
disabled={isDeletingCourse}
|
| 602 |
+
onClick={() => {
|
| 603 |
+
void handleDeleteCourse();
|
| 604 |
+
}}
|
| 605 |
+
type="button"
|
| 606 |
+
>
|
| 607 |
+
{isDeletingCourse
|
| 608 |
+
? 'Menghapus...'
|
| 609 |
+
: 'Hapus'}
|
| 610 |
+
</Button>
|
| 611 |
+
</DialogFooter>
|
| 612 |
+
</div>
|
| 613 |
+
</DialogContent>
|
| 614 |
+
</Dialog>
|
| 615 |
+
</DosenShell>
|
| 616 |
+
</>
|
| 617 |
+
);
|
| 618 |
+
}
|
resources/js/pages/dosen.tsx
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Head, router } from '@inertiajs/react';
|
| 2 |
+
import type { LucideIcon } from 'lucide-react';
|
| 3 |
+
import { AlertTriangle, Database, FileText, Plus, Search } from 'lucide-react';
|
| 4 |
+
import type { FormEvent } from 'react';
|
| 5 |
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
| 6 |
+
|
| 7 |
+
import AppearanceToggle from '@/components/appearance-toggle';
|
| 8 |
+
import { DosenShell } from '@/components/dosen/dosen-shell';
|
| 9 |
+
import { Button } from '@/components/ui/button';
|
| 10 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 11 |
+
import {
|
| 12 |
+
Dialog,
|
| 13 |
+
DialogContent,
|
| 14 |
+
DialogDescription,
|
| 15 |
+
DialogFooter,
|
| 16 |
+
DialogHeader,
|
| 17 |
+
DialogTitle,
|
| 18 |
+
} from '@/components/ui/dialog';
|
| 19 |
+
import { Input } from '@/components/ui/input';
|
| 20 |
+
import { Label } from '@/components/ui/label';
|
| 21 |
+
import { getStoredAuthToken } from '@/lib/auth';
|
| 22 |
+
import type { CourseResponse, DocumentResponse } from '@/lib/rag';
|
| 23 |
+
import { getRagErrorMessage, ragApi } from '@/lib/rag';
|
| 24 |
+
import { show as showDosenCourse } from '@/routes/dosen';
|
| 25 |
+
|
| 26 |
+
type KnowledgeBaseRow = {
|
| 27 |
+
id: string;
|
| 28 |
+
name: string;
|
| 29 |
+
documentCount: number;
|
| 30 |
+
failedDocumentCount: number;
|
| 31 |
+
hasError?: boolean;
|
| 32 |
+
updatedAt: string;
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
type Metric = {
|
| 36 |
+
label: string;
|
| 37 |
+
value: string;
|
| 38 |
+
description?: string;
|
| 39 |
+
icon: LucideIcon;
|
| 40 |
+
tone: 'primary' | 'danger';
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
type CourseForm = {
|
| 44 |
+
title: string;
|
| 45 |
+
description: string;
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const emptyCourseForm: CourseForm = {
|
| 49 |
+
description: '',
|
| 50 |
+
title: '',
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
function isAbortError(error: unknown): boolean {
|
| 54 |
+
return error instanceof DOMException && error.name === 'AbortError';
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function formatUpdatedAt(value?: string): string {
|
| 58 |
+
if (!value) {
|
| 59 |
+
return '-';
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const datePart = value.split('T')[0];
|
| 63 |
+
|
| 64 |
+
return datePart || value;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function getLatestUpdatedAt(documents: DocumentResponse[]): string | undefined {
|
| 68 |
+
return documents
|
| 69 |
+
.map((document) => document.updated_at)
|
| 70 |
+
.filter(Boolean)
|
| 71 |
+
.sort()
|
| 72 |
+
.at(-1);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function buildKnowledgeBaseRow(
|
| 76 |
+
course: CourseResponse,
|
| 77 |
+
documents: DocumentResponse[],
|
| 78 |
+
): KnowledgeBaseRow {
|
| 79 |
+
const failedDocumentCount = documents.filter(
|
| 80 |
+
(document) => document.status === 'failed' || Boolean(document.error),
|
| 81 |
+
).length;
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
documentCount: documents.length,
|
| 85 |
+
failedDocumentCount,
|
| 86 |
+
hasError: failedDocumentCount > 0,
|
| 87 |
+
id: String(course.id),
|
| 88 |
+
name: course.title,
|
| 89 |
+
updatedAt: formatUpdatedAt(getLatestUpdatedAt(documents)),
|
| 90 |
+
};
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function MetricCard({ metric }: { metric: Metric }) {
|
| 94 |
+
const Icon = metric.icon;
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<Card className="lecturer-metric-card">
|
| 98 |
+
<CardContent className="lecturer-metric-content">
|
| 99 |
+
<span className="lecturer-metric-icon" data-tone={metric.tone}>
|
| 100 |
+
<Icon className="size-6" />
|
| 101 |
+
</span>
|
| 102 |
+
|
| 103 |
+
<div className="lecturer-metric-copy">
|
| 104 |
+
<p>{metric.label}</p>
|
| 105 |
+
<div className="lecturer-metric-value-row">
|
| 106 |
+
<strong>{metric.value}</strong>
|
| 107 |
+
{metric.description && (
|
| 108 |
+
<span>{metric.description}</span>
|
| 109 |
+
)}
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</CardContent>
|
| 113 |
+
</Card>
|
| 114 |
+
);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
export default function Dosen() {
|
| 118 |
+
const [backendMessage, setBackendMessage] = useState<string>();
|
| 119 |
+
const [courseForm, setCourseForm] = useState<CourseForm>(emptyCourseForm);
|
| 120 |
+
const [createCourseMessage, setCreateCourseMessage] = useState<string>();
|
| 121 |
+
const [isCourseDialogOpen, setIsCourseDialogOpen] = useState(false);
|
| 122 |
+
const [isCreatingCourse, setIsCreatingCourse] = useState(false);
|
| 123 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 124 |
+
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseRow[]>(
|
| 125 |
+
[],
|
| 126 |
+
);
|
| 127 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 128 |
+
|
| 129 |
+
const loadKnowledgeBases = useCallback(async (signal?: AbortSignal) => {
|
| 130 |
+
setIsLoading(true);
|
| 131 |
+
setBackendMessage(undefined);
|
| 132 |
+
|
| 133 |
+
try {
|
| 134 |
+
const token = getStoredAuthToken() ?? undefined;
|
| 135 |
+
const coursesResponse = await ragApi.listCourses({
|
| 136 |
+
limit: 100,
|
| 137 |
+
signal,
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
if (signal?.aborted) {
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const documentResults = await Promise.allSettled(
|
| 145 |
+
coursesResponse.data.map((course) =>
|
| 146 |
+
ragApi.listCourseDocuments(String(course.id), {
|
| 147 |
+
signal,
|
| 148 |
+
token,
|
| 149 |
+
}),
|
| 150 |
+
),
|
| 151 |
+
);
|
| 152 |
+
|
| 153 |
+
if (signal?.aborted) {
|
| 154 |
+
return;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
let documentErrorMessage: string | undefined;
|
| 158 |
+
|
| 159 |
+
const rows = coursesResponse.data.map((course, index) => {
|
| 160 |
+
const documentResult = documentResults[index];
|
| 161 |
+
|
| 162 |
+
if (documentResult?.status === 'fulfilled') {
|
| 163 |
+
return buildKnowledgeBaseRow(
|
| 164 |
+
course,
|
| 165 |
+
documentResult.value.data,
|
| 166 |
+
);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
if (documentResult && !isAbortError(documentResult.reason)) {
|
| 170 |
+
documentErrorMessage ??= getRagErrorMessage(
|
| 171 |
+
documentResult.reason,
|
| 172 |
+
);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
return buildKnowledgeBaseRow(course, []);
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
setKnowledgeBases(rows);
|
| 179 |
+
setBackendMessage(documentErrorMessage);
|
| 180 |
+
} catch (error) {
|
| 181 |
+
if (!isAbortError(error)) {
|
| 182 |
+
setKnowledgeBases([]);
|
| 183 |
+
setBackendMessage(getRagErrorMessage(error));
|
| 184 |
+
}
|
| 185 |
+
} finally {
|
| 186 |
+
if (!signal?.aborted) {
|
| 187 |
+
setIsLoading(false);
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
}, []);
|
| 191 |
+
|
| 192 |
+
useEffect(() => {
|
| 193 |
+
const controller = new AbortController();
|
| 194 |
+
|
| 195 |
+
void Promise.resolve().then(() =>
|
| 196 |
+
loadKnowledgeBases(controller.signal),
|
| 197 |
+
);
|
| 198 |
+
|
| 199 |
+
return () => {
|
| 200 |
+
controller.abort();
|
| 201 |
+
};
|
| 202 |
+
}, [loadKnowledgeBases]);
|
| 203 |
+
|
| 204 |
+
function handleCourseDialogOpenChange(open: boolean): void {
|
| 205 |
+
setIsCourseDialogOpen(open);
|
| 206 |
+
|
| 207 |
+
if (!open) {
|
| 208 |
+
setCourseForm(emptyCourseForm);
|
| 209 |
+
setCreateCourseMessage(undefined);
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
async function handleCreateCourseSubmit(
|
| 214 |
+
event: FormEvent<HTMLFormElement>,
|
| 215 |
+
): Promise<void> {
|
| 216 |
+
event.preventDefault();
|
| 217 |
+
setCreateCourseMessage(undefined);
|
| 218 |
+
setIsCreatingCourse(true);
|
| 219 |
+
|
| 220 |
+
try {
|
| 221 |
+
const token = getStoredAuthToken() ?? undefined;
|
| 222 |
+
|
| 223 |
+
await ragApi.createCourse(
|
| 224 |
+
{
|
| 225 |
+
description: courseForm.description || undefined,
|
| 226 |
+
title: courseForm.title,
|
| 227 |
+
},
|
| 228 |
+
token,
|
| 229 |
+
);
|
| 230 |
+
|
| 231 |
+
handleCourseDialogOpenChange(false);
|
| 232 |
+
await loadKnowledgeBases();
|
| 233 |
+
} catch (error) {
|
| 234 |
+
setCreateCourseMessage(getRagErrorMessage(error));
|
| 235 |
+
} finally {
|
| 236 |
+
setIsCreatingCourse(false);
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const metrics = useMemo<Metric[]>(() => {
|
| 241 |
+
const failedDocumentCount = knowledgeBases.reduce(
|
| 242 |
+
(total, item) => total + item.failedDocumentCount,
|
| 243 |
+
0,
|
| 244 |
+
);
|
| 245 |
+
const documentCount = knowledgeBases.reduce(
|
| 246 |
+
(total, item) => total + item.documentCount,
|
| 247 |
+
0,
|
| 248 |
+
);
|
| 249 |
+
|
| 250 |
+
return [
|
| 251 |
+
{
|
| 252 |
+
icon: Database,
|
| 253 |
+
label: 'Total Knowledge Base',
|
| 254 |
+
tone: 'primary',
|
| 255 |
+
value: isLoading ? '...' : String(knowledgeBases.length),
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
icon: FileText,
|
| 259 |
+
label: 'Total Dokumen',
|
| 260 |
+
description: 'Chunked',
|
| 261 |
+
tone: 'primary',
|
| 262 |
+
value: isLoading ? '...' : String(documentCount),
|
| 263 |
+
},
|
| 264 |
+
{
|
| 265 |
+
icon: AlertTriangle,
|
| 266 |
+
label: 'Processing Error',
|
| 267 |
+
description: 'dokumen',
|
| 268 |
+
tone: 'danger',
|
| 269 |
+
value: isLoading ? '...' : String(failedDocumentCount),
|
| 270 |
+
},
|
| 271 |
+
];
|
| 272 |
+
}, [isLoading, knowledgeBases]);
|
| 273 |
+
|
| 274 |
+
const filteredKnowledgeBases = useMemo(() => {
|
| 275 |
+
const normalizedQuery = searchQuery.trim().toLowerCase();
|
| 276 |
+
|
| 277 |
+
if (!normalizedQuery) {
|
| 278 |
+
return knowledgeBases;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
return knowledgeBases.filter((item) =>
|
| 282 |
+
item.name.toLowerCase().includes(normalizedQuery),
|
| 283 |
+
);
|
| 284 |
+
}, [knowledgeBases, searchQuery]);
|
| 285 |
+
|
| 286 |
+
return (
|
| 287 |
+
<>
|
| 288 |
+
<Head title="Dashboard Knowledge Base" />
|
| 289 |
+
|
| 290 |
+
<DosenShell>
|
| 291 |
+
<main className="lecturer-page">
|
| 292 |
+
<section className="lecturer-page-header">
|
| 293 |
+
<div>
|
| 294 |
+
<h1>Dashboard Knowledge Base</h1>
|
| 295 |
+
<p>
|
| 296 |
+
Kelola sumber dokumen RAG untuk berbagai mata
|
| 297 |
+
kuliah Anda.
|
| 298 |
+
</p>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
<div className="lecturer-header-actions">
|
| 302 |
+
<AppearanceToggle className="lecturer-theme-toggle" />
|
| 303 |
+
<Button
|
| 304 |
+
className="lecturer-create-button"
|
| 305 |
+
onClick={() =>
|
| 306 |
+
handleCourseDialogOpenChange(true)
|
| 307 |
+
}
|
| 308 |
+
>
|
| 309 |
+
<Plus className="size-4" />
|
| 310 |
+
Buat KB Baru
|
| 311 |
+
</Button>
|
| 312 |
+
</div>
|
| 313 |
+
</section>
|
| 314 |
+
|
| 315 |
+
<section className="lecturer-metric-grid">
|
| 316 |
+
{metrics.map((metric) => (
|
| 317 |
+
<MetricCard key={metric.label} metric={metric} />
|
| 318 |
+
))}
|
| 319 |
+
</section>
|
| 320 |
+
|
| 321 |
+
<Card className="lecturer-table-card">
|
| 322 |
+
<CardContent className="lecturer-table-content">
|
| 323 |
+
<div className="lecturer-table-heading">
|
| 324 |
+
<h2>Daftar Mata Kuliah / Topik</h2>
|
| 325 |
+
<div className="lecturer-search-field">
|
| 326 |
+
<Search aria-hidden className="size-4" />
|
| 327 |
+
<Input
|
| 328 |
+
aria-label="Cari mata kuliah"
|
| 329 |
+
className="lecturer-search-input"
|
| 330 |
+
onChange={(event) =>
|
| 331 |
+
setSearchQuery(event.target.value)
|
| 332 |
+
}
|
| 333 |
+
placeholder="Cari mata kuliah..."
|
| 334 |
+
type="search"
|
| 335 |
+
value={searchQuery}
|
| 336 |
+
/>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
|
| 340 |
+
{backendMessage && (
|
| 341 |
+
<p className="lecturer-status" role="alert">
|
| 342 |
+
{backendMessage}
|
| 343 |
+
</p>
|
| 344 |
+
)}
|
| 345 |
+
|
| 346 |
+
<div className="lecturer-table-wrap">
|
| 347 |
+
<table className="lecturer-table">
|
| 348 |
+
<thead>
|
| 349 |
+
<tr>
|
| 350 |
+
<th>Nama Knowledge Base</th>
|
| 351 |
+
<th>Dokumen</th>
|
| 352 |
+
<th>Update Terakhir</th>
|
| 353 |
+
<th>Aksi</th>
|
| 354 |
+
</tr>
|
| 355 |
+
</thead>
|
| 356 |
+
<tbody>
|
| 357 |
+
{isLoading && (
|
| 358 |
+
<tr>
|
| 359 |
+
<td
|
| 360 |
+
className="lecturer-table-empty"
|
| 361 |
+
colSpan={4}
|
| 362 |
+
>
|
| 363 |
+
Memuat knowledge base...
|
| 364 |
+
</td>
|
| 365 |
+
</tr>
|
| 366 |
+
)}
|
| 367 |
+
|
| 368 |
+
{!isLoading &&
|
| 369 |
+
knowledgeBases.length === 0 && (
|
| 370 |
+
<tr>
|
| 371 |
+
<td
|
| 372 |
+
className="lecturer-table-empty"
|
| 373 |
+
colSpan={4}
|
| 374 |
+
>
|
| 375 |
+
Belum ada knowledge
|
| 376 |
+
base.
|
| 377 |
+
</td>
|
| 378 |
+
</tr>
|
| 379 |
+
)}
|
| 380 |
+
|
| 381 |
+
{!isLoading &&
|
| 382 |
+
knowledgeBases.length > 0 &&
|
| 383 |
+
filteredKnowledgeBases.length ===
|
| 384 |
+
0 && (
|
| 385 |
+
<tr>
|
| 386 |
+
<td
|
| 387 |
+
className="lecturer-table-empty"
|
| 388 |
+
colSpan={4}
|
| 389 |
+
>
|
| 390 |
+
Mata kuliah tidak
|
| 391 |
+
ditemukan.
|
| 392 |
+
</td>
|
| 393 |
+
</tr>
|
| 394 |
+
)}
|
| 395 |
+
|
| 396 |
+
{!isLoading &&
|
| 397 |
+
filteredKnowledgeBases.map(
|
| 398 |
+
(item) => (
|
| 399 |
+
<tr key={item.id}>
|
| 400 |
+
<td>
|
| 401 |
+
<span className="lecturer-kb-name">
|
| 402 |
+
{item.name}
|
| 403 |
+
</span>
|
| 404 |
+
</td>
|
| 405 |
+
<td>
|
| 406 |
+
<span className="lecturer-doc-pill">
|
| 407 |
+
<FileText className="size-3.5" />
|
| 408 |
+
{
|
| 409 |
+
item.documentCount
|
| 410 |
+
}
|
| 411 |
+
{item.hasError && (
|
| 412 |
+
<span className="lecturer-doc-error" />
|
| 413 |
+
)}
|
| 414 |
+
</span>
|
| 415 |
+
</td>
|
| 416 |
+
<td>
|
| 417 |
+
{item.updatedAt}
|
| 418 |
+
</td>
|
| 419 |
+
<td>
|
| 420 |
+
<button
|
| 421 |
+
className="lecturer-manage-button"
|
| 422 |
+
onClick={() =>
|
| 423 |
+
router.visit(
|
| 424 |
+
showDosenCourse(
|
| 425 |
+
item.id,
|
| 426 |
+
),
|
| 427 |
+
)
|
| 428 |
+
}
|
| 429 |
+
type="button"
|
| 430 |
+
>
|
| 431 |
+
Kelola
|
| 432 |
+
</button>
|
| 433 |
+
</td>
|
| 434 |
+
</tr>
|
| 435 |
+
),
|
| 436 |
+
)}
|
| 437 |
+
</tbody>
|
| 438 |
+
</table>
|
| 439 |
+
</div>
|
| 440 |
+
</CardContent>
|
| 441 |
+
</Card>
|
| 442 |
+
</main>
|
| 443 |
+
|
| 444 |
+
<Dialog
|
| 445 |
+
onOpenChange={handleCourseDialogOpenChange}
|
| 446 |
+
open={isCourseDialogOpen}
|
| 447 |
+
>
|
| 448 |
+
<DialogContent className="lecturer-course-dialog">
|
| 449 |
+
<DialogHeader>
|
| 450 |
+
<DialogTitle>Tambah Mata Kuliah</DialogTitle>
|
| 451 |
+
<DialogDescription>
|
| 452 |
+
Buat knowledge base baru untuk dokumen RAG mata
|
| 453 |
+
kuliah.
|
| 454 |
+
</DialogDescription>
|
| 455 |
+
</DialogHeader>
|
| 456 |
+
|
| 457 |
+
<form
|
| 458 |
+
className="lecturer-course-form"
|
| 459 |
+
onSubmit={(event) => {
|
| 460 |
+
void handleCreateCourseSubmit(event);
|
| 461 |
+
}}
|
| 462 |
+
>
|
| 463 |
+
<div className="lecturer-form-field">
|
| 464 |
+
<Label
|
| 465 |
+
className="lecturer-form-label"
|
| 466 |
+
htmlFor="course-title"
|
| 467 |
+
>
|
| 468 |
+
Nama Mata Kuliah
|
| 469 |
+
</Label>
|
| 470 |
+
<Input
|
| 471 |
+
className="lecturer-form-input"
|
| 472 |
+
disabled={isCreatingCourse}
|
| 473 |
+
id="course-title"
|
| 474 |
+
onChange={(event) =>
|
| 475 |
+
setCourseForm((current) => ({
|
| 476 |
+
...current,
|
| 477 |
+
title: event.target.value,
|
| 478 |
+
}))
|
| 479 |
+
}
|
| 480 |
+
placeholder="Masukkan nama mata kuliah"
|
| 481 |
+
value={courseForm.title}
|
| 482 |
+
/>
|
| 483 |
+
</div>
|
| 484 |
+
|
| 485 |
+
<div className="lecturer-form-field">
|
| 486 |
+
<Label
|
| 487 |
+
className="lecturer-form-label"
|
| 488 |
+
htmlFor="course-description"
|
| 489 |
+
>
|
| 490 |
+
Deskripsi
|
| 491 |
+
</Label>
|
| 492 |
+
<textarea
|
| 493 |
+
className="lecturer-form-textarea"
|
| 494 |
+
disabled={isCreatingCourse}
|
| 495 |
+
id="course-description"
|
| 496 |
+
onChange={(event) =>
|
| 497 |
+
setCourseForm((current) => ({
|
| 498 |
+
...current,
|
| 499 |
+
description: event.target.value,
|
| 500 |
+
}))
|
| 501 |
+
}
|
| 502 |
+
placeholder="Masukkan deskripsi mata kuliah"
|
| 503 |
+
value={courseForm.description}
|
| 504 |
+
/>
|
| 505 |
+
</div>
|
| 506 |
+
|
| 507 |
+
{createCourseMessage && (
|
| 508 |
+
<p className="lecturer-status" role="alert">
|
| 509 |
+
{createCourseMessage}
|
| 510 |
+
</p>
|
| 511 |
+
)}
|
| 512 |
+
|
| 513 |
+
<DialogFooter className="lecturer-course-footer">
|
| 514 |
+
<Button
|
| 515 |
+
className="lecturer-cancel-button"
|
| 516 |
+
disabled={isCreatingCourse}
|
| 517 |
+
onClick={() =>
|
| 518 |
+
handleCourseDialogOpenChange(false)
|
| 519 |
+
}
|
| 520 |
+
type="button"
|
| 521 |
+
variant="outline"
|
| 522 |
+
>
|
| 523 |
+
Batal
|
| 524 |
+
</Button>
|
| 525 |
+
<Button
|
| 526 |
+
className="lecturer-create-button"
|
| 527 |
+
disabled={isCreatingCourse}
|
| 528 |
+
type="submit"
|
| 529 |
+
>
|
| 530 |
+
{isCreatingCourse
|
| 531 |
+
? 'Menyimpan...'
|
| 532 |
+
: 'Simpan'}
|
| 533 |
+
</Button>
|
| 534 |
+
</DialogFooter>
|
| 535 |
+
</form>
|
| 536 |
+
</DialogContent>
|
| 537 |
+
</Dialog>
|
| 538 |
+
</DosenShell>
|
| 539 |
+
</>
|
| 540 |
+
);
|
| 541 |
+
}
|
resources/js/pages/dosen/pengaturan.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Head } from '@inertiajs/react';
|
| 2 |
+
|
| 3 |
+
import { DosenShell } from '@/components/dosen/dosen-shell';
|
| 4 |
+
|
| 5 |
+
export default function PengaturanDosen() {
|
| 6 |
+
return (
|
| 7 |
+
<>
|
| 8 |
+
<Head title="Pengaturan" />
|
| 9 |
+
|
| 10 |
+
<DosenShell activeMenu="settings">
|
| 11 |
+
<main className="lecturer-page">
|
| 12 |
+
<section className="lecturer-page-header">
|
| 13 |
+
<div>
|
| 14 |
+
<h1>Pengaturan</h1>
|
| 15 |
+
<p>Halaman ini masih kosong.</p>
|
| 16 |
+
</div>
|
| 17 |
+
</section>
|
| 18 |
+
</main>
|
| 19 |
+
</DosenShell>
|
| 20 |
+
</>
|
| 21 |
+
);
|
| 22 |
+
}
|
resources/js/pages/mahasiswa-chat.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { FormEvent } from 'react';
|
|
| 4 |
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 5 |
|
| 6 |
import { QuestionComposer } from '@/components/student/question-composer';
|
|
|
|
| 7 |
import {
|
| 8 |
formatSessionTime,
|
| 9 |
isAbortError,
|
|
@@ -20,7 +21,9 @@ import type {
|
|
| 20 |
import {
|
| 21 |
appendDirectChatMessages,
|
| 22 |
clearDirectChatMessages,
|
|
|
|
| 23 |
createLocalUserMessage,
|
|
|
|
| 24 |
getRagErrorMessage,
|
| 25 |
getStoredDirectChatMessages,
|
| 26 |
isDirectChatMode,
|
|
@@ -95,16 +98,41 @@ function ChatMessage({ message }: { message: ChatMessageResponse }) {
|
|
| 95 |
);
|
| 96 |
}
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
const [backendMessage, setBackendMessage] = useState<string>();
|
| 100 |
const [chatSessions, setChatSessions] = useState<ChatSessionResponse[]>([]);
|
| 101 |
const [courses, setCourses] = useState<CourseResponse[]>([]);
|
| 102 |
const [currentSession, setCurrentSession] = useState<ChatSessionResponse>();
|
| 103 |
const [isLoading, setIsLoading] = useState(true);
|
| 104 |
-
const [isProcessing, setIsProcessing] = useState(
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
const [question, setQuestion] = useState('');
|
| 107 |
-
const [selectedCourseId, setSelectedCourseId] = useState(
|
|
|
|
|
|
|
| 108 |
const studentName = useStoredStudentName();
|
| 109 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 110 |
|
|
@@ -130,22 +158,27 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
|
| 130 |
messagesEndRef.current?.scrollIntoView({ block: 'end' });
|
| 131 |
}, [messages, isProcessing]);
|
| 132 |
|
| 133 |
-
const
|
| 134 |
-
(response: ChatHistoryResponse):
|
| 135 |
if (isDirectChatMode()) {
|
| 136 |
-
|
| 137 |
...response.data,
|
| 138 |
...getStoredDirectChatMessages(sessionId),
|
| 139 |
-
]
|
| 140 |
-
|
| 141 |
-
return;
|
| 142 |
}
|
| 143 |
|
| 144 |
-
|
| 145 |
},
|
| 146 |
[sessionId],
|
| 147 |
);
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
useEffect(() => {
|
| 150 |
const controller = new AbortController();
|
| 151 |
|
|
@@ -196,7 +229,16 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
|
| 196 |
}
|
| 197 |
|
| 198 |
if (historyResult.status === 'fulfilled') {
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
} else if (!isAbortError(historyResult.reason)) {
|
| 201 |
setBackendMessage(getRagErrorMessage(historyResult.reason));
|
| 202 |
}
|
|
@@ -209,9 +251,98 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
|
| 209 |
return () => {
|
| 210 |
controller.abort();
|
| 211 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
}, [applyHistoryResponse, sessionId]);
|
| 213 |
|
| 214 |
-
const handleDeleteSession = async (
|
|
|
|
|
|
|
| 215 |
try {
|
| 216 |
const token = getStoredAuthToken() ?? undefined;
|
| 217 |
await ragApi.deleteChatSession(targetSessionId, token);
|
|
@@ -226,8 +357,13 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
|
| 226 |
if (targetSessionId === sessionId) {
|
| 227 |
router.visit(mahasiswa());
|
| 228 |
}
|
|
|
|
|
|
|
| 229 |
} catch (error) {
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
| 231 |
}
|
| 232 |
};
|
| 233 |
|
|
@@ -300,9 +436,7 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
|
| 300 |
activeSessionId={sessionId}
|
| 301 |
chatSessions={chatSessions}
|
| 302 |
isLoadingSessions={isLoading}
|
| 303 |
-
onDeleteSession={
|
| 304 |
-
void handleDeleteSession(targetSessionId);
|
| 305 |
-
}}
|
| 306 |
studentName={studentName}
|
| 307 |
>
|
| 308 |
<div className="student-chat-stage student-chat-stage--session">
|
|
@@ -313,7 +447,7 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
|
| 313 |
</p>
|
| 314 |
)}
|
| 315 |
|
| 316 |
-
{isLoading ? (
|
| 317 |
<p className="student-chat-loading">
|
| 318 |
Memuat percakapan...
|
| 319 |
</p>
|
|
@@ -332,7 +466,7 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
|
| 332 |
<Bot className="size-4" />
|
| 333 |
</span>
|
| 334 |
<div className="student-message-bubble student-message-bubble--typing">
|
| 335 |
-
|
| 336 |
</div>
|
| 337 |
</article>
|
| 338 |
)}
|
|
@@ -345,7 +479,6 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
|
| 345 |
<div className="student-chat-composer">
|
| 346 |
<QuestionComposer
|
| 347 |
courses={composerCourses}
|
| 348 |
-
isCourseLocked
|
| 349 |
isLoadingCourses={isLoading}
|
| 350 |
isProcessing={isProcessing}
|
| 351 |
onCourseChange={setSelectedCourseId}
|
|
|
|
| 4 |
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 5 |
|
| 6 |
import { QuestionComposer } from '@/components/student/question-composer';
|
| 7 |
+
import type { DeleteSessionResult } from '@/components/student/student-shell';
|
| 8 |
import {
|
| 9 |
formatSessionTime,
|
| 10 |
isAbortError,
|
|
|
|
| 21 |
import {
|
| 22 |
appendDirectChatMessages,
|
| 23 |
clearDirectChatMessages,
|
| 24 |
+
clearPendingInitialChat,
|
| 25 |
createLocalUserMessage,
|
| 26 |
+
getPendingInitialChat,
|
| 27 |
getRagErrorMessage,
|
| 28 |
getStoredDirectChatMessages,
|
| 29 |
isDirectChatMode,
|
|
|
|
| 98 |
);
|
| 99 |
}
|
| 100 |
|
| 101 |
+
function appendUniqueMessages(
|
| 102 |
+
currentMessages: ChatMessageResponse[],
|
| 103 |
+
nextMessages: ChatMessageResponse[],
|
| 104 |
+
): ChatMessageResponse[] {
|
| 105 |
+
const existingIds = new Set(
|
| 106 |
+
currentMessages.map((message) => message.uuid_id),
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
return [
|
| 110 |
+
...currentMessages,
|
| 111 |
+
...nextMessages.filter((message) => !existingIds.has(message.uuid_id)),
|
| 112 |
+
];
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
|
| 116 |
+
const [initialPendingChat] = useState(() =>
|
| 117 |
+
getPendingInitialChat(sessionId),
|
| 118 |
+
);
|
| 119 |
+
const pendingInitialChatRef = useRef(initialPendingChat);
|
| 120 |
+
const hasStartedPendingInitialChatRef = useRef(false);
|
| 121 |
const [backendMessage, setBackendMessage] = useState<string>();
|
| 122 |
const [chatSessions, setChatSessions] = useState<ChatSessionResponse[]>([]);
|
| 123 |
const [courses, setCourses] = useState<CourseResponse[]>([]);
|
| 124 |
const [currentSession, setCurrentSession] = useState<ChatSessionResponse>();
|
| 125 |
const [isLoading, setIsLoading] = useState(true);
|
| 126 |
+
const [isProcessing, setIsProcessing] = useState(
|
| 127 |
+
Boolean(initialPendingChat),
|
| 128 |
+
);
|
| 129 |
+
const [messages, setMessages] = useState<ChatMessageResponse[]>(() =>
|
| 130 |
+
initialPendingChat ? [initialPendingChat.userMessage] : [],
|
| 131 |
+
);
|
| 132 |
const [question, setQuestion] = useState('');
|
| 133 |
+
const [selectedCourseId, setSelectedCourseId] = useState(
|
| 134 |
+
initialPendingChat?.courseId ?? '',
|
| 135 |
+
);
|
| 136 |
const studentName = useStoredStudentName();
|
| 137 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 138 |
|
|
|
|
| 158 |
messagesEndRef.current?.scrollIntoView({ block: 'end' });
|
| 159 |
}, [messages, isProcessing]);
|
| 160 |
|
| 161 |
+
const getHistoryMessages = useCallback(
|
| 162 |
+
(response: ChatHistoryResponse): ChatMessageResponse[] => {
|
| 163 |
if (isDirectChatMode()) {
|
| 164 |
+
return [
|
| 165 |
...response.data,
|
| 166 |
...getStoredDirectChatMessages(sessionId),
|
| 167 |
+
];
|
|
|
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
+
return response.data;
|
| 171 |
},
|
| 172 |
[sessionId],
|
| 173 |
);
|
| 174 |
|
| 175 |
+
const applyHistoryResponse = useCallback(
|
| 176 |
+
(response: ChatHistoryResponse): void => {
|
| 177 |
+
setMessages(getHistoryMessages(response));
|
| 178 |
+
},
|
| 179 |
+
[getHistoryMessages],
|
| 180 |
+
);
|
| 181 |
+
|
| 182 |
useEffect(() => {
|
| 183 |
const controller = new AbortController();
|
| 184 |
|
|
|
|
| 229 |
}
|
| 230 |
|
| 231 |
if (historyResult.status === 'fulfilled') {
|
| 232 |
+
const historyMessages = getHistoryMessages(historyResult.value);
|
| 233 |
+
const pendingInitialChat = pendingInitialChatRef.current;
|
| 234 |
+
|
| 235 |
+
setMessages(
|
| 236 |
+
pendingInitialChat
|
| 237 |
+
? appendUniqueMessages(historyMessages, [
|
| 238 |
+
pendingInitialChat.userMessage,
|
| 239 |
+
])
|
| 240 |
+
: historyMessages,
|
| 241 |
+
);
|
| 242 |
} else if (!isAbortError(historyResult.reason)) {
|
| 243 |
setBackendMessage(getRagErrorMessage(historyResult.reason));
|
| 244 |
}
|
|
|
|
| 251 |
return () => {
|
| 252 |
controller.abort();
|
| 253 |
};
|
| 254 |
+
}, [getHistoryMessages, sessionId]);
|
| 255 |
+
|
| 256 |
+
useEffect(() => {
|
| 257 |
+
const pendingInitialChat = pendingInitialChatRef.current;
|
| 258 |
+
|
| 259 |
+
if (!pendingInitialChat || hasStartedPendingInitialChatRef.current) {
|
| 260 |
+
return;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
const activePendingInitialChat = pendingInitialChat;
|
| 264 |
+
let isCancelled = false;
|
| 265 |
+
hasStartedPendingInitialChatRef.current = true;
|
| 266 |
+
setIsProcessing(true);
|
| 267 |
+
setQuestion('');
|
| 268 |
+
setSelectedCourseId(activePendingInitialChat.courseId);
|
| 269 |
+
setMessages((currentMessages) =>
|
| 270 |
+
appendUniqueMessages(currentMessages, [
|
| 271 |
+
activePendingInitialChat.userMessage,
|
| 272 |
+
]),
|
| 273 |
+
);
|
| 274 |
+
|
| 275 |
+
async function sendPendingInitialChat(): Promise<void> {
|
| 276 |
+
try {
|
| 277 |
+
const token = getStoredAuthToken() ?? undefined;
|
| 278 |
+
const assistantMessage = await ragApi.sendConfiguredChatMessage(
|
| 279 |
+
{
|
| 280 |
+
content: activePendingInitialChat.content,
|
| 281 |
+
courseId: activePendingInitialChat.courseId,
|
| 282 |
+
sessionId,
|
| 283 |
+
token,
|
| 284 |
+
},
|
| 285 |
+
);
|
| 286 |
+
|
| 287 |
+
if (isCancelled) {
|
| 288 |
+
return;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
clearPendingInitialChat(sessionId);
|
| 292 |
+
pendingInitialChatRef.current = undefined;
|
| 293 |
+
|
| 294 |
+
if (isDirectChatMode()) {
|
| 295 |
+
appendDirectChatMessages(sessionId, [
|
| 296 |
+
activePendingInitialChat.userMessage,
|
| 297 |
+
assistantMessage,
|
| 298 |
+
]);
|
| 299 |
+
setMessages((currentMessages) =>
|
| 300 |
+
appendUniqueMessages(currentMessages, [
|
| 301 |
+
assistantMessage,
|
| 302 |
+
]),
|
| 303 |
+
);
|
| 304 |
+
|
| 305 |
+
return;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
const history = await ragApi.getChatHistory(sessionId, {
|
| 309 |
+
token,
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
if (!isCancelled) {
|
| 313 |
+
applyHistoryResponse(history);
|
| 314 |
+
}
|
| 315 |
+
} catch (error) {
|
| 316 |
+
if (!isCancelled) {
|
| 317 |
+
clearPendingInitialChat(sessionId);
|
| 318 |
+
pendingInitialChatRef.current = undefined;
|
| 319 |
+
setMessages((currentMessages) =>
|
| 320 |
+
currentMessages.filter(
|
| 321 |
+
(message) =>
|
| 322 |
+
message.uuid_id !==
|
| 323 |
+
activePendingInitialChat.userMessage.uuid_id,
|
| 324 |
+
),
|
| 325 |
+
);
|
| 326 |
+
setQuestion(activePendingInitialChat.content);
|
| 327 |
+
setBackendMessage(getRagErrorMessage(error));
|
| 328 |
+
}
|
| 329 |
+
} finally {
|
| 330 |
+
if (!isCancelled) {
|
| 331 |
+
setIsProcessing(false);
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
void sendPendingInitialChat();
|
| 337 |
+
|
| 338 |
+
return () => {
|
| 339 |
+
isCancelled = true;
|
| 340 |
+
};
|
| 341 |
}, [applyHistoryResponse, sessionId]);
|
| 342 |
|
| 343 |
+
const handleDeleteSession = async (
|
| 344 |
+
targetSessionId: string,
|
| 345 |
+
): Promise<DeleteSessionResult> => {
|
| 346 |
try {
|
| 347 |
const token = getStoredAuthToken() ?? undefined;
|
| 348 |
await ragApi.deleteChatSession(targetSessionId, token);
|
|
|
|
| 357 |
if (targetSessionId === sessionId) {
|
| 358 |
router.visit(mahasiswa());
|
| 359 |
}
|
| 360 |
+
|
| 361 |
+
return { ok: true };
|
| 362 |
} catch (error) {
|
| 363 |
+
const message = getRagErrorMessage(error);
|
| 364 |
+
setBackendMessage(message);
|
| 365 |
+
|
| 366 |
+
return { message, ok: false };
|
| 367 |
}
|
| 368 |
};
|
| 369 |
|
|
|
|
| 436 |
activeSessionId={sessionId}
|
| 437 |
chatSessions={chatSessions}
|
| 438 |
isLoadingSessions={isLoading}
|
| 439 |
+
onDeleteSession={handleDeleteSession}
|
|
|
|
|
|
|
| 440 |
studentName={studentName}
|
| 441 |
>
|
| 442 |
<div className="student-chat-stage student-chat-stage--session">
|
|
|
|
| 447 |
</p>
|
| 448 |
)}
|
| 449 |
|
| 450 |
+
{isLoading && messages.length === 0 && !isProcessing ? (
|
| 451 |
<p className="student-chat-loading">
|
| 452 |
Memuat percakapan...
|
| 453 |
</p>
|
|
|
|
| 466 |
<Bot className="size-4" />
|
| 467 |
</span>
|
| 468 |
<div className="student-message-bubble student-message-bubble--typing">
|
| 469 |
+
Thinking...
|
| 470 |
</div>
|
| 471 |
</article>
|
| 472 |
)}
|
|
|
|
| 479 |
<div className="student-chat-composer">
|
| 480 |
<QuestionComposer
|
| 481 |
courses={composerCourses}
|
|
|
|
| 482 |
isLoadingCourses={isLoading}
|
| 483 |
isProcessing={isProcessing}
|
| 484 |
onCourseChange={setSelectedCourseId}
|
resources/js/pages/mahasiswa.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { FormEvent } from 'react';
|
|
| 4 |
import { useEffect, useState } from 'react';
|
| 5 |
|
| 6 |
import { QuestionComposer } from '@/components/student/question-composer';
|
|
|
|
| 7 |
import {
|
| 8 |
isAbortError,
|
| 9 |
StudentShell,
|
|
@@ -12,11 +13,11 @@ import {
|
|
| 12 |
import { getStoredAuthToken } from '@/lib/auth';
|
| 13 |
import type { ChatSessionResponse, CourseResponse } from '@/lib/rag';
|
| 14 |
import {
|
| 15 |
-
|
| 16 |
createLocalUserMessage,
|
| 17 |
getRagErrorMessage,
|
| 18 |
-
isDirectChatMode,
|
| 19 |
ragApi,
|
|
|
|
| 20 |
} from '@/lib/rag';
|
| 21 |
import { show as showMahasiswaSession } from '@/routes/mahasiswa';
|
| 22 |
|
|
@@ -75,6 +76,29 @@ export default function Mahasiswa() {
|
|
| 75 |
};
|
| 76 |
}, []);
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
const handleQuestionSubmit = async (
|
| 79 |
event: FormEvent<HTMLFormElement>,
|
| 80 |
): Promise<void> => {
|
|
@@ -100,20 +124,12 @@ export default function Mahasiswa() {
|
|
| 100 |
);
|
| 101 |
|
| 102 |
const userMessage = createLocalUserMessage(trimmedQuestion);
|
| 103 |
-
|
| 104 |
content: trimmedQuestion,
|
| 105 |
courseId: selectedCourseId,
|
| 106 |
-
|
| 107 |
-
token,
|
| 108 |
});
|
| 109 |
|
| 110 |
-
if (isDirectChatMode()) {
|
| 111 |
-
appendDirectChatMessages(session.uuid_id, [
|
| 112 |
-
userMessage,
|
| 113 |
-
assistantMessage,
|
| 114 |
-
]);
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
router.visit(showMahasiswaSession(session.uuid_id));
|
| 118 |
} catch (error) {
|
| 119 |
setBackendMessage(getRagErrorMessage(error));
|
|
@@ -129,6 +145,7 @@ export default function Mahasiswa() {
|
|
| 129 |
<StudentShell
|
| 130 |
chatSessions={chatSessions}
|
| 131 |
isLoadingSessions={isLoading}
|
|
|
|
| 132 |
studentName={studentName}
|
| 133 |
>
|
| 134 |
<div className="student-chat-stage">
|
|
|
|
| 4 |
import { useEffect, useState } from 'react';
|
| 5 |
|
| 6 |
import { QuestionComposer } from '@/components/student/question-composer';
|
| 7 |
+
import type { DeleteSessionResult } from '@/components/student/student-shell';
|
| 8 |
import {
|
| 9 |
isAbortError,
|
| 10 |
StudentShell,
|
|
|
|
| 13 |
import { getStoredAuthToken } from '@/lib/auth';
|
| 14 |
import type { ChatSessionResponse, CourseResponse } from '@/lib/rag';
|
| 15 |
import {
|
| 16 |
+
clearDirectChatMessages,
|
| 17 |
createLocalUserMessage,
|
| 18 |
getRagErrorMessage,
|
|
|
|
| 19 |
ragApi,
|
| 20 |
+
savePendingInitialChat,
|
| 21 |
} from '@/lib/rag';
|
| 22 |
import { show as showMahasiswaSession } from '@/routes/mahasiswa';
|
| 23 |
|
|
|
|
| 76 |
};
|
| 77 |
}, []);
|
| 78 |
|
| 79 |
+
const handleDeleteSession = async (
|
| 80 |
+
targetSessionId: string,
|
| 81 |
+
): Promise<DeleteSessionResult> => {
|
| 82 |
+
try {
|
| 83 |
+
const token = getStoredAuthToken() ?? undefined;
|
| 84 |
+
|
| 85 |
+
await ragApi.deleteChatSession(targetSessionId, token);
|
| 86 |
+
clearDirectChatMessages(targetSessionId);
|
| 87 |
+
setChatSessions((currentSessions) =>
|
| 88 |
+
currentSessions.filter(
|
| 89 |
+
(session) => session.uuid_id !== targetSessionId,
|
| 90 |
+
),
|
| 91 |
+
);
|
| 92 |
+
|
| 93 |
+
return { ok: true };
|
| 94 |
+
} catch (error) {
|
| 95 |
+
const message = getRagErrorMessage(error);
|
| 96 |
+
setBackendMessage(message);
|
| 97 |
+
|
| 98 |
+
return { message, ok: false };
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
const handleQuestionSubmit = async (
|
| 103 |
event: FormEvent<HTMLFormElement>,
|
| 104 |
): Promise<void> => {
|
|
|
|
| 124 |
);
|
| 125 |
|
| 126 |
const userMessage = createLocalUserMessage(trimmedQuestion);
|
| 127 |
+
savePendingInitialChat(session.uuid_id, {
|
| 128 |
content: trimmedQuestion,
|
| 129 |
courseId: selectedCourseId,
|
| 130 |
+
userMessage,
|
|
|
|
| 131 |
});
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
router.visit(showMahasiswaSession(session.uuid_id));
|
| 134 |
} catch (error) {
|
| 135 |
setBackendMessage(getRagErrorMessage(error));
|
|
|
|
| 145 |
<StudentShell
|
| 146 |
chatSessions={chatSessions}
|
| 147 |
isLoadingSessions={isLoading}
|
| 148 |
+
onDeleteSession={handleDeleteSession}
|
| 149 |
studentName={studentName}
|
| 150 |
>
|
| 151 |
<div className="student-chat-stage">
|
routes/web.php
CHANGED
|
@@ -20,4 +20,12 @@
|
|
| 20 |
'sessionId' => $uuid,
|
| 21 |
]))->name('mahasiswa.show');
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
require __DIR__.'/settings.php';
|
|
|
|
| 20 |
'sessionId' => $uuid,
|
| 21 |
]))->name('mahasiswa.show');
|
| 22 |
|
| 23 |
+
Route::inertia('dosen', 'dosen')->name('dosen');
|
| 24 |
+
|
| 25 |
+
Route::inertia('dosen/pengaturan', 'dosen/pengaturan')->name('dosen.pengaturan');
|
| 26 |
+
|
| 27 |
+
Route::get('dosen/{courseId}', fn (string $courseId) => Inertia::render('dosen-detail', [
|
| 28 |
+
'courseId' => $courseId,
|
| 29 |
+
]))->name('dosen.show');
|
| 30 |
+
|
| 31 |
require __DIR__.'/settings.php';
|
tests/Feature/DosenDashboardTest.php
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
|
| 3 |
+
use Inertia\Testing\AssertableInertia as Assert;
|
| 4 |
+
|
| 5 |
+
test('dosen dashboard page can be rendered', function () {
|
| 6 |
+
$this->get(route('dosen'))
|
| 7 |
+
->assertOk()
|
| 8 |
+
->assertInertia(fn (Assert $page) => $page->component('dosen'));
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
test('dosen settings page can be rendered', function () {
|
| 12 |
+
$this->get(route('dosen.pengaturan'))
|
| 13 |
+
->assertOk()
|
| 14 |
+
->assertInertia(fn (Assert $page) => $page->component('dosen/pengaturan'));
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
test('dosen course detail page can be rendered', function () {
|
| 18 |
+
$this->get(route('dosen.show', ['courseId' => '101']))
|
| 19 |
+
->assertOk()
|
| 20 |
+
->assertInertia(fn (Assert $page) => $page
|
| 21 |
+
->component('dosen-detail')
|
| 22 |
+
->where('courseId', '101'));
|
| 23 |
+
});
|