Download index.html from Hectozar/Learn: direct link, hf CLI and curl.
- Browser
- Download file 132 kB
-
https://huggingface.co/spaces/Hectozar/Learn/resolve/main/index.html
- Command line
-
hf download hf://spaces/Hectozar/Learn/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Hectozar/Learn/resolve/main/index.html
132 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>QuizIZhere | Premium Green LMS (Cloud Enabled)</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| /* ========================================= | |
| DESIGN SYSTEM & VARIABLES (EMERALD GREEN) | |
| ========================================= */ | |
| :root { | |
| /* Core Green Palette */ | |
| --primary: #10B981; | |
| --primary-hover: #059669; | |
| --primary-light: #D1FAE5; | |
| /* Secondary Teals & Blues */ | |
| --secondary: #0F766E; | |
| --secondary-light: #CCFBF1; | |
| /* Feedback Colors */ | |
| --danger: #EF4444; | |
| --danger-light: #FEE2E2; | |
| --warning: #F59E0B; | |
| --warning-light: #FEF3C7; | |
| /* Structural Backgrounds */ | |
| --bg-base: #F3F4F6; | |
| --bg-surface: #FFFFFF; | |
| --bg-offset: #F9FAFB; | |
| /* Typography Colors */ | |
| --text-main: #064E3B; | |
| --text-muted: #4B5563; | |
| --border: #E5E7EB; | |
| /* Layout & Spacing */ | |
| --sidebar-width: 280px; | |
| --radius-sm: 8px; | |
| --radius-md: 16px; | |
| --radius-lg: 24px; | |
| /* Premium Shadows */ | |
| --shadow-sm: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); | |
| --shadow-md: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); | |
| --shadow-lg: 0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04); | |
| --shadow-glow: 0 0 20px rgba(16, 185, 129, 0.4); | |
| /* Fonts and Transitions */ | |
| --font-family: 'Plus Jakarta Sans', sans-serif; | |
| --transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| /* ========================================= | |
| GLOBAL RESETS | |
| ========================================= */ | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| font-family: var(--font-family); | |
| } | |
| body { | |
| background-color: var(--bg-base); | |
| color: var(--text-main); | |
| font-size: 15px; | |
| overflow: hidden; | |
| } | |
| .hidden { | |
| display: none ; | |
| } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #A7F3D0; | |
| border-radius: 999px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #34D399; | |
| } | |
| /* Base Typography */ | |
| h1 { | |
| font-size: 2.5rem; | |
| font-weight: 800; | |
| color: var(--text-main); | |
| letter-spacing: -0.02em; | |
| } | |
| h2 { | |
| font-size: 1.75rem; | |
| font-weight: 700; | |
| color: var(--text-main); | |
| } | |
| h3 { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| } | |
| p { | |
| color: var(--text-muted); | |
| line-height: 1.6; | |
| } | |
| /* ========================================= | |
| LAYOUTS | |
| ========================================= */ | |
| .app-container { | |
| display: flex; | |
| height: 100vh; | |
| width: 100vw; | |
| background: linear-gradient(135deg, #ECFDF5 0%, #E5E7EB 100%); | |
| } | |
| .sidebar { | |
| width: var(--sidebar-width); | |
| background: rgba(255, 255, 255, 0.9); | |
| backdrop-filter: blur(12px); | |
| border-right: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| padding: 2rem 1.5rem; | |
| z-index: 40; | |
| box-shadow: var(--shadow-sm); | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| font-size: 1.8rem; | |
| font-weight: 800; | |
| color: var(--primary); | |
| margin-bottom: 3rem; | |
| text-shadow: 0 2px 4px rgba(5, 150, 105, 0.2); | |
| } | |
| .brand svg { | |
| width: 36px; | |
| height: 36px; | |
| } | |
| .nav-menu { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| flex: 1; | |
| } | |
| .nav-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| padding: 1rem 1.25rem; | |
| border-radius: var(--radius-md); | |
| color: var(--text-muted); | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: var(--transition); | |
| border: none; | |
| background: transparent; | |
| text-align: left; | |
| width: 100%; | |
| font-size: 1rem; | |
| } | |
| .nav-item svg { | |
| width: 24px; | |
| height: 24px; | |
| stroke-width: 2; | |
| transition: var(--transition); | |
| } | |
| .nav-item:hover { | |
| background-color: var(--primary-light); | |
| color: var(--primary); | |
| transform: translateX(4px); | |
| } | |
| .nav-item.active { | |
| background-color: var(--primary); | |
| color: white; | |
| box-shadow: var(--shadow-md); | |
| } | |
| .main-content { | |
| flex: 1; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| position: relative; | |
| padding: 3rem 4rem; | |
| scroll-behavior: smooth; | |
| } | |
| .page-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-end; | |
| margin-bottom: 3rem; | |
| background: transparent; | |
| } | |
| .container-center { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| padding: 2rem; | |
| background: linear-gradient(135deg, var(--primary-light) 0%, #A7F3D0 100%); | |
| } | |
| /* ========================================= | |
| COMPONENTS & CARDS | |
| ========================================= */ | |
| .card { | |
| background: rgba(255, 255, 255, 0.98); | |
| backdrop-filter: blur(8px); | |
| border-radius: var(--radius-lg); | |
| padding: 2.5rem; | |
| box-shadow: var(--shadow-md); | |
| border: 1px solid rgba(255,255,255,0.8); | |
| margin-bottom: 1.5rem; | |
| transition: var(--transition); | |
| } | |
| .card:hover { | |
| box-shadow: var(--shadow-lg); | |
| } | |
| .card-auth { | |
| width: 100%; | |
| max-width: 500px; | |
| border-top: 6px solid var(--primary); | |
| } | |
| /* Buttons */ | |
| .btn { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.5rem; | |
| padding: 0.8rem 1.75rem; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| border-radius: 999px; | |
| cursor: pointer; | |
| border: 2px solid transparent; | |
| transition: var(--transition); | |
| outline: none; | |
| } | |
| .btn:active { | |
| transform: scale(0.97); | |
| } | |
| .btn:disabled { | |
| opacity: 0.6; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, var(--primary) 0%, #065F46 100%); | |
| color: white; | |
| box-shadow: 0 4px 15px rgba(5, 150, 105, 0.4); | |
| border: none; | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| box-shadow: var(--shadow-glow); | |
| transform: translateY(-2px); | |
| } | |
| .btn-secondary { | |
| background-color: white; | |
| color: var(--primary); | |
| border-color: var(--primary-light); | |
| box-shadow: var(--shadow-sm); | |
| } | |
| .btn-secondary:hover:not(:disabled) { | |
| background-color: var(--primary-light); | |
| border-color: var(--primary); | |
| } | |
| .btn-danger { | |
| background-color: white; | |
| color: var(--danger); | |
| border-color: var(--danger-light); | |
| } | |
| .btn-danger:hover:not(:disabled) { | |
| background-color: var(--danger); | |
| color: white; | |
| } | |
| .btn-success { | |
| background: linear-gradient(135deg, var(--secondary) 0%, #1D4ED8 100%); | |
| color: white; | |
| border: none; | |
| box-shadow: 0 4px 15px rgba(37, 99, 235, 0.4); | |
| } | |
| .btn-block { | |
| width: 100%; | |
| padding: 1rem; | |
| font-size: 1.1rem; | |
| } | |
| .btn-sm { | |
| padding: 0.5rem 1rem; | |
| font-size: 0.85rem; | |
| } | |
| /* Forms */ | |
| .input-group { | |
| margin-bottom: 1.5rem; | |
| width: 100%; | |
| } | |
| .input-group label { | |
| display: block; | |
| font-weight: 700; | |
| margin-bottom: 0.5rem; | |
| font-size: 0.9rem; | |
| color: var(--text-main); | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .form-control { | |
| width: 100%; | |
| padding: 1rem 1.25rem; | |
| border: 2px solid var(--border); | |
| border-radius: var(--radius-md); | |
| font-size: 1rem; | |
| transition: var(--transition); | |
| background-color: var(--bg-surface); | |
| color: var(--text-main); | |
| } | |
| .form-control:focus { | |
| outline: none; | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 4px var(--primary-light); | |
| background-color: white; | |
| } | |
| textarea.form-control { | |
| min-height: 120px; | |
| resize: vertical; | |
| } | |
| /* Layout Helpers */ | |
| .flex-row { | |
| display: flex; | |
| gap: 1rem; | |
| align-items: center; | |
| } | |
| .flex-between { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .grid-cards { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | |
| gap: 1.5rem; | |
| } | |
| /* Tabs */ | |
| .tabs { | |
| display: flex; | |
| gap: 1rem; | |
| border-bottom: 2px solid var(--border); | |
| margin-bottom: 2.5rem; | |
| flex-wrap: wrap; | |
| } | |
| .tab { | |
| padding: 1rem 1.5rem; | |
| font-weight: 700; | |
| color: var(--text-muted); | |
| cursor: pointer; | |
| border-bottom: 3px solid transparent; | |
| margin-bottom: -2px; | |
| transition: var(--transition); | |
| border-radius: var(--radius-md) var(--radius-md) 0 0; | |
| } | |
| .tab:hover { | |
| color: var(--primary); | |
| background: var(--bg-offset); | |
| } | |
| .tab.active { | |
| color: var(--primary); | |
| border-bottom-color: var(--primary); | |
| background: var(--bg-surface); | |
| } | |
| /* Badges */ | |
| .badge { | |
| display: inline-flex; | |
| align-items: center; | |
| padding: 0.4rem 1rem; | |
| border-radius: 999px; | |
| font-size: 0.85rem; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .badge-primary { | |
| background-color: var(--primary-light); | |
| color: var(--primary-hover); | |
| } | |
| .badge-success { | |
| background-color: var(--secondary-light); | |
| color: var(--secondary); | |
| } | |
| .badge-warning { | |
| background-color: var(--warning-light); | |
| color: #B45309; | |
| } | |
| .badge-danger { | |
| background-color: var(--danger-light); | |
| color: var(--danger); | |
| } | |
| .badge-code { | |
| background: #1F2937; | |
| color: #10B981; | |
| font-family: monospace; | |
| font-size: 1.1rem; | |
| letter-spacing: 0.1em; | |
| } | |
| /* List Elements */ | |
| .list-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 1.5rem; | |
| background: var(--bg-surface); | |
| border: 2px solid var(--border); | |
| border-radius: var(--radius-md); | |
| transition: var(--transition); | |
| margin-bottom: 1rem; | |
| } | |
| .list-item:hover { | |
| border-color: var(--primary); | |
| box-shadow: var(--shadow-md); | |
| transform: translateY(-2px); | |
| } | |
| .question-card { | |
| background: var(--bg-surface); | |
| border: 2px solid var(--border); | |
| border-radius: var(--radius-md); | |
| padding: 2rem; | |
| margin-bottom: 1.5rem; | |
| transition: var(--transition); | |
| } | |
| .question-card:focus-within { | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 4px var(--primary-light); | |
| } | |
| .mc-option { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| padding: 1rem 1.5rem; | |
| border: 2px solid var(--border); | |
| border-radius: var(--radius-md); | |
| cursor: pointer; | |
| transition: var(--transition); | |
| margin-bottom: 0.75rem; | |
| background: var(--bg-offset); | |
| } | |
| .mc-option:hover { | |
| background-color: white; | |
| border-color: var(--primary-light); | |
| } | |
| .mc-option input[type="radio"], | |
| .mc-option input[type="checkbox"] { | |
| width: 1.25rem; | |
| height: 1.25rem; | |
| accent-color: var(--primary); | |
| cursor: pointer; | |
| } | |
| .mc-option.selected { | |
| background-color: var(--primary-light); | |
| border-color: var(--primary); | |
| box-shadow: inset 0 0 0 1px var(--primary); | |
| } | |
| .mc-option.wrong { | |
| background-color: var(--danger-light); | |
| border-color: var(--danger); | |
| } | |
| /* Class Banner Decorator */ | |
| .class-banner { | |
| background: linear-gradient(135deg, var(--primary) 0%, #064E3B 100%); | |
| color: white; | |
| border-radius: var(--radius-lg); | |
| padding: 3rem; | |
| margin-bottom: 2rem; | |
| box-shadow: var(--shadow-lg); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .class-banner::after { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| right: 0; | |
| width: 300px; | |
| height: 300px; | |
| background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%); | |
| border-radius: 50%; | |
| transform: translate(30%, -30%); | |
| } | |
| /* ========================================= | |
| TOASTS & MODALS | |
| ========================================= */ | |
| #toast-container { | |
| position: fixed; | |
| bottom: 2rem; | |
| right: 2rem; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| z-index: 9999; | |
| } | |
| .toast { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| background: var(--text-main); | |
| color: white; | |
| padding: 1.25rem 2rem; | |
| border-radius: var(--radius-md); | |
| font-weight: 600; | |
| box-shadow: var(--shadow-lg); | |
| transform: translateX(120%); | |
| transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); | |
| } | |
| .toast.show { | |
| transform: translateX(0); | |
| } | |
| .toast.success { | |
| border-left: 8px solid var(--primary); | |
| } | |
| .toast.error { | |
| border-left: 8px solid var(--danger); | |
| } | |
| .modal-backdrop { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(6, 78, 59, 0.7); | |
| backdrop-filter: blur(6px); | |
| z-index: 9000; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: var(--transition); | |
| } | |
| .modal-backdrop.active { | |
| opacity: 1; | |
| pointer-events: auto; | |
| } | |
| .modal { | |
| background: var(--bg-surface); | |
| width: 100%; | |
| max-width: 550px; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| border-radius: var(--radius-lg); | |
| padding: 3rem; | |
| transform: scale(0.95) translateY(20px); | |
| transition: var(--transition); | |
| box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); | |
| } | |
| .modal.modal-lg { | |
| max-width: 850px; | |
| } | |
| .modal-backdrop.active .modal { | |
| transform: scale(1) translateY(0); | |
| } | |
| /* CONFETTI CANVAS */ | |
| #confetti-canvas { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| pointer-events: none; | |
| z-index: 9999; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="confetti-canvas"></canvas> | |
| <div id="toast-container"></div> | |
| <div id="modal-container" class="modal-backdrop"> | |
| <div class="modal"> | |
| <h2 id="modal-title" style="margin-bottom: 1rem;">Confirm</h2> | |
| <p id="modal-desc" style="margin-bottom: 2rem;">Proceed with action?</p> | |
| <div id="modal-input-wrapper" class="input-group hidden"> | |
| <input type="password" id="modal-input" class="form-control" placeholder="Enter value..."> | |
| </div> | |
| <div class="flex-row" style="justify-content: flex-end; margin-top: 1rem;"> | |
| <button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button> | |
| <button id="modal-confirm-btn" class="btn btn-primary">Confirm</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="grade-modal" class="modal-backdrop"> | |
| <div class="modal modal-lg"> | |
| <h2 id="grade-modal-title">Grade Submission</h2> | |
| <p id="grade-modal-student" style="font-weight: 600; color: var(--primary);">Student Name</p> | |
| <div id="grade-questions-container" style="margin-top: 2rem; border-top: 2px solid var(--border); padding-top: 2rem;"></div> | |
| <div class="flex-row" style="justify-content: flex-end; margin-top: 2rem;"> | |
| <button class="btn btn-secondary" onclick="document.getElementById('grade-modal').classList.remove('active')">Cancel</button> | |
| <button class="btn btn-primary" id="grade-save-btn">Save Grades & Feedback</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="view-auth" class="container-center"> | |
| <div class="card card-auth"> | |
| <div class="brand" style="justify-content: center; margin-bottom: 2rem; font-size: 2.5rem;"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 14l9-5-9-5-9 5 9 5z"/> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z"/> | |
| </svg> | |
| QuizIZhere LMS | |
| </div> | |
| <div class="tabs" style="justify-content: center;"> | |
| <div class="tab active" id="tab-login" onclick="Auth.switchTab('login')">Secure Login</div> | |
| <div class="tab" id="tab-register" onclick="Auth.switchTab('register')">Register</div> | |
| </div> | |
| <div id="form-login"> | |
| <div class="input-group"> | |
| <label>Username</label> | |
| <input type="text" id="login-user" class="form-control" placeholder="Enter username"> | |
| </div> | |
| <div class="input-group"> | |
| <label>Password</label> | |
| <input type="password" id="login-pass" class="form-control" placeholder="Enter password"> | |
| </div> | |
| <button class="btn btn-primary btn-block" onclick="Auth.login()" style="margin-top: 1rem;">Access Portal</button> | |
| </div> | |
| <div id="form-register" class="hidden"> | |
| <div class="input-group"> | |
| <label>Account Type</label> | |
| <select id="reg-role" class="form-control" onchange="Auth.toggleTeacherCode(this.value)"> | |
| <option value="student">Student / Learner</option> | |
| <option value="teacher">Instructor / Administrator</option> | |
| </select> | |
| </div> | |
| <div class="input-group hidden" id="reg-code-group"> | |
| <label style="color: var(--warning);">Administrator Access Code</label> | |
| <input type="password" id="reg-code" class="form-control" placeholder="Enter code (42)"> | |
| </div> | |
| <div class="input-group"> | |
| <label>Full Name</label> | |
| <input type="text" id="reg-name" class="form-control" placeholder="e.g. Jane Doe"> | |
| </div> | |
| <div class="input-group"> | |
| <label>Username</label> | |
| <input type="text" id="reg-user" class="form-control" placeholder="Choose a username"> | |
| </div> | |
| <div class="input-group"> | |
| <label>Password</label> | |
| <input type="password" id="reg-pass" class="form-control" placeholder="Create a strong password"> | |
| </div> | |
| <button class="btn btn-primary btn-block" onclick="Auth.register()" style="margin-top: 1rem;">Create Account</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="view-app" class="app-container hidden"> | |
| <aside class="sidebar"> | |
| <div class="brand"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 14l9-5-9-5-9 5 9 5z"/> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z"/> | |
| </svg> | |
| QuizIZhere | |
| </div> | |
| <div id="nav-student" class="nav-menu hidden"> | |
| <button class="nav-item active" onclick="Router.navigate('student-dash', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg> My Classes | |
| </button> | |
| <button class="nav-item" onclick="Router.navigate('student-history', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> Grades & Reports | |
| </button> | |
| <button class="nav-item" onclick="Router.navigate('settings', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg> Settings | |
| </button> | |
| </div> | |
| <div id="nav-teacher" class="nav-menu hidden"> | |
| <button class="nav-item active" onclick="Router.navigate('teacher-classes', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg> Classes | |
| </button> | |
| <button class="nav-item" onclick="Router.navigate('teacher-bank', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg> Assessment Bank | |
| </button> | |
| <button class="nav-item" onclick="Router.navigate('teacher-grading', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg> Grading Center | |
| </button> | |
| <button class="nav-item" onclick="Router.navigate('settings', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg> Settings | |
| </button> | |
| </div> | |
| <div style="flex: 1;"></div> | |
| <div style="border-top: 2px solid var(--border); padding-top: 1.5rem; margin-top: 1.5rem;"> | |
| <p id="nav-user-name" style="font-weight: 800; color: var(--text-main); margin-bottom: 0.25rem;">User Name</p> | |
| <p id="nav-user-role" style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.1em; font-weight:700; margin-bottom: 1rem; color: var(--primary);">Role</p> | |
| <button class="nav-item" onclick="Auth.logout()"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg> Sign Out | |
| </button> | |
| </div> | |
| </aside> | |
| <main class="main-content"> | |
| <div id="page-settings" class="page hidden" style="max-width: 800px; margin: 0 auto;"> | |
| <div class="page-header"> | |
| <div> | |
| <h1>Account Settings</h1> | |
| <p>Update your personal information and security credentials.</p> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3 style="margin-bottom: 1.5rem; border-bottom: 2px solid var(--border); padding-bottom: 1rem; color: var(--primary);">Profile Details</h3> | |
| <div class="input-group"> | |
| <label>Full Name</label> | |
| <input type="text" id="set-name" class="form-control"> | |
| </div> | |
| <div class="input-group"> | |
| <label>Username</label> | |
| <input type="text" id="set-user" class="form-control"> | |
| </div> | |
| <h3 style="margin-top: 2.5rem; margin-bottom: 1.5rem; border-bottom: 2px solid var(--border); padding-bottom: 1rem; color: var(--primary);">Security</h3> | |
| <div class="input-group"> | |
| <label>New Password (leave blank to keep current)</label> | |
| <input type="password" id="set-pass" class="form-control" placeholder="Enter new password"> | |
| </div> | |
| <button class="btn btn-primary" style="margin-top: 1rem; width: 100%; font-size: 1.1rem; padding: 1rem;" onclick="SettingsCtrl.save()">Save All Changes</button> | |
| </div> | |
| </div> | |
| <div id="page-teacher-classes" class="page hidden" style="max-width: 1200px; margin: 0 auto;"> | |
| <div class="page-header"> | |
| <div> | |
| <h1>My Classes</h1> | |
| <p>Manage your classroom rosters and assignments.</p> | |
| </div> | |
| <button class="btn btn-primary" onclick="ClassCtrl.showCreateModal()">+ Create New Class</button> | |
| </div> | |
| <div id="teacher-class-list" class="grid-cards"></div> | |
| </div> | |
| <div id="page-teacher-class-detail" class="page hidden" style="max-width: 1200px; margin: 0 auto;"> | |
| <div class="page-header" style="margin-bottom: 1.5rem;"> | |
| <button class="btn btn-secondary btn-sm" onclick="Router.navigate('teacher-classes', document.querySelector('#nav-teacher .nav-item:nth-child(1)'))">← Back to Classes</button> | |
| <div class="flex-row"> | |
| <button class="btn btn-secondary btn-sm" onclick="ClassCtrl.editClassName()">✏️ Edit Name</button> | |
| <button class="btn btn-danger btn-sm" onclick="ClassCtrl.deleteClass()">🗑️ Delete Class</button> | |
| </div> | |
| </div> | |
| <div class="class-banner"> | |
| <h1 id="tc-detail-name" style="color:white; margin-bottom: 0.5rem; font-size: 3rem;">Class Name</h1> | |
| <p style="color: var(--primary-light); font-size: 1.2rem; margin-bottom: 1.5rem;">Class Code: <span id="tc-detail-code" class="badge badge-code" style="background: white; color: var(--primary);">CODE</span></p> | |
| </div> | |
| <div class="tabs"> | |
| <div class="tab active" id="tab-tcd-assignments" onclick="ClassCtrl.switchDetailTab('assignments')">Assigned Quizzes</div> | |
| <div class="tab" id="tab-tcd-roster" onclick="ClassCtrl.switchDetailTab('roster')">Student Roster</div> | |
| </div> | |
| <div id="tcd-view-assignments"> | |
| <div class="flex-between" style="margin-bottom: 1.5rem;"> | |
| <h3>Active Assessments</h3> | |
| <button class="btn btn-success" onclick="ClassCtrl.showAssignModal()">+ Assign Quiz to Class</button> | |
| </div> | |
| <div id="tcd-assignments-list" class="data-list"></div> | |
| </div> | |
| <div id="tcd-view-roster" class="hidden"> | |
| <h3 style="margin-bottom: 1.5rem;">Enrolled Students</h3> | |
| <div id="tcd-roster-list" class="grid-cards"></div> | |
| </div> | |
| </div> | |
| <div id="page-teacher-bank" class="page hidden" style="max-width: 1200px; margin: 0 auto;"> | |
| <div class="page-header"> | |
| <div> | |
| <h1>Assessment Bank</h1> | |
| <p>Build and store quizzes here. Assign them to classes later.</p> | |
| </div> | |
| <button class="btn btn-primary" onclick="BuilderCtrl.init(null)">+ Create New Quiz</button> | |
| </div> | |
| <div id="teacher-quiz-list" class="grid-cards"></div> | |
| </div> | |
| <div id="page-teacher-builder" class="page hidden" style="max-width: 1000px; margin: 0 auto;"> | |
| <div class="page-header"> | |
| <div><h1 id="builder-header-title">Quiz Builder</h1></div> | |
| <div class="flex-row"> | |
| <button class="btn btn-secondary" onclick="BuilderCtrl.save()">Save to Bank</button> | |
| </div> | |
| </div> | |
| <div class="card" style="margin-bottom: 2rem;"> | |
| <div class="input-group" style="margin: 0;"> | |
| <label>Assessment Title</label> | |
| <input type="text" id="build-title" class="form-control" placeholder="e.g. Advanced Biology Midterm" style="font-size: 1.25rem; font-weight: 700;"> | |
| </div> | |
| </div> | |
| <div class="tabs"> | |
| <div class="tab active" id="tab-tb-editor" onclick="BuilderCtrl.switchTab('editor')">Questions Editor</div> | |
| <div class="tab" id="tab-tb-bulk" onclick="BuilderCtrl.switchTab('bulk')">✨ Bulk Import Text</div> | |
| <div class="tab" id="tab-tb-settings" onclick="BuilderCtrl.switchTab('settings')">Settings</div> | |
| </div> | |
| <div id="build-view-editor"> | |
| <div id="build-questions-list"></div> | |
| <button class="btn btn-secondary btn-block" style="border-style: dashed; padding: 2rem; margin-top: 1rem; border-width: 3px;" onclick="BuilderCtrl.addQuestion()">+ Add New Question</button> | |
| </div> | |
| <div id="build-view-bulk" class="hidden card"> | |
| <h3 style="margin-bottom: 1rem; color: var(--primary);">Bulk Import Questions</h3> | |
| <p style="margin-bottom: 1.5rem; font-size: 0.95rem; line-height: 1.6; color: var(--text-muted);"> | |
| Paste text to instantly generate questions.<br> | |
| <strong>Format 1 (Paragraphs):</strong> Put an asterisk (*) next to the correct answer. Separate questions with a blank line.<br> | |
| <strong>Format 2 (Colons):</strong> <code>Question : Correct : Wrong : Wrong</code> on each line. | |
| </p> | |
| <textarea id="bulk-import-text" class="form-control" style="min-height: 350px; font-family: monospace; font-size: 0.95rem; margin-bottom: 1.5rem; line-height: 1.5;" placeholder="Format 1 Example: What is the capital of France? London *Paris Berlin Format 2 Example: What is 2+2? : 4 : 3 : 5 : 6"></textarea> | |
| <button class="btn btn-primary btn-block" onclick="BuilderCtrl.processBulk()">Process & Generate Questions</button> | |
| </div> | |
| <div id="build-view-settings" class="hidden card"> | |
| <div class="flex-row" style="margin-bottom: 1.5rem;"> | |
| <div class="input-group" style="flex: 1; margin: 0;"><label>Time Limit (Mins, 0 = Unlimited)</label><input type="number" id="build-timer" class="form-control" value="0" min="0"></div> | |
| <div class="input-group" style="flex: 1; margin: 0;"><label>Access Password (Optional)</label><input type="text" id="build-password" class="form-control" placeholder="Leave blank for open access"></div> | |
| </div> | |
| <label class="mc-option" style="justify-content: flex-start; max-width: 400px;"><input type="checkbox" id="build-rand-q"><span style="font-weight: 700;">Randomize Question Order</span></label> | |
| <label class="mc-option" style="justify-content: flex-start; max-width: 400px;"><input type="checkbox" id="build-rand-o" checked><span style="font-weight: 700;">Randomize Options</span></label> | |
| </div> | |
| </div> | |
| <div id="page-teacher-grading" class="page hidden" style="max-width: 1200px; margin: 0 auto;"> | |
| <div class="page-header"> | |
| <div> | |
| <h1>Grading Center</h1> | |
| <p>Review student submissions across all classes.</p> | |
| </div> | |
| </div> | |
| <div id="teacher-subs-list" class="data-list"></div> | |
| </div> | |
| <div id="page-student-dash" class="page hidden" style="max-width: 1200px; margin: 0 auto;"> | |
| <div class="page-header"> | |
| <div> | |
| <h1>My Classes</h1> | |
| <p>Access your enrolled classrooms and assignments.</p> | |
| </div> | |
| <button class="btn btn-primary" onclick="StudentCtrl.showJoinModal()">+ Join a Class</button> | |
| </div> | |
| <div id="student-class-list" class="grid-cards"></div> | |
| </div> | |
| <div id="page-student-class-detail" class="page hidden" style="max-width: 1000px; margin: 0 auto;"> | |
| <div class="page-header" style="margin-bottom: 1.5rem;"> | |
| <button class="btn btn-secondary btn-sm" onclick="Router.navigate('student-dash', document.querySelector('#nav-student .nav-item:nth-child(1)'))">← Back to My Classes</button> | |
| </div> | |
| <div class="class-banner"> | |
| <h1 id="sc-detail-name" style="color:white; margin-bottom: 0.5rem; font-size: 3rem;">Class Name</h1> | |
| <p style="color: var(--primary-light); font-size: 1.2rem;">Instructor: <span id="sc-detail-teacher" style="font-weight: 700;">Teacher Name</span></p> | |
| </div> | |
| <h3 style="margin-bottom: 1.5rem;">Assessments</h3> | |
| <div id="sc-assignments-list" class="data-list"></div> | |
| </div> | |
| <div id="page-pre-test" class="page hidden" style="max-width: 700px; margin: 0 auto;"> | |
| <div class="card" style="text-align: center; border-top: 8px solid var(--primary); margin-top: 10vh; padding: 4rem 2rem;"> | |
| <h1 style="margin-bottom: 1.5rem; font-size: 2.5rem;" id="pre-title">Assessment Title</h1> | |
| <div style="background: var(--bg-offset); padding: 1.5rem; border-radius: var(--radius-md); margin-bottom: 2.5rem; border: 1px solid var(--border); display: inline-block; text-align: left;"> | |
| <p style="font-size: 1.1rem; margin-bottom: 0.5rem;"><strong>Class:</strong> <span id="pre-class"></span></p> | |
| <p style="font-size: 1.1rem; margin-bottom: 0;"><strong>Time Limit:</strong> <span id="pre-time" class="badge badge-warning"></span></p> | |
| </div> | |
| <div class="flex-row" style="justify-content: center; gap: 1rem;"> | |
| <button class="btn btn-secondary" onclick="Router.navigate('student-dash')">Cancel</button> | |
| <button class="btn btn-primary" style="font-size: 1.2rem; padding: 1rem 2.5rem;" onclick="TestCtrl.startSession()">Begin Assessment</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="page-take-test" class="page hidden" style="max-width: 900px; margin: 0 auto; padding-bottom: 5rem;"> | |
| <div class="card" style="position: sticky; top: -1rem; z-index: 10; margin-bottom: 2rem; padding: 1.5rem 2.5rem; display: flex; justify-content: space-between; align-items: center; box-shadow: var(--shadow-md); background: rgba(255,255,255,0.9); backdrop-filter: blur(10px);"> | |
| <div> | |
| <h2 id="live-test-title" style="margin:0; color:var(--primary);">Assessment</h2> | |
| <span id="live-test-progress" class="badge badge-primary" style="margin-top: 0.5rem;">Progress</span> | |
| </div> | |
| <div class="flex-row"> | |
| <div id="live-timer" class="badge badge-danger hidden" style="font-size: 1.2rem; padding: 0.8rem 1.2rem;"><span>00:00</span></div> | |
| <button class="btn btn-secondary" onclick="TestCtrl.pause()">⏸ Save & Pause</button> | |
| <button class="btn btn-success" onclick="TestCtrl.submit()">Submit</button> | |
| </div> | |
| </div> | |
| <div id="live-test-questions"></div> | |
| </div> | |
| <div id="page-student-history" class="page hidden" style="max-width: 1000px; margin: 0 auto;"> | |
| <div class="page-header"> | |
| <div> | |
| <h1>Grades & Reports</h1> | |
| <p>Review your performance across all classes.</p> | |
| </div> | |
| </div> | |
| <div id="student-history-list" class="data-list"></div> | |
| </div> | |
| <div id="page-review-test" class="page hidden" style="max-width: 900px; margin: 0 auto;"> | |
| <div class="page-header"> | |
| <div><h1 id="review-title">Review Mode</h1></div> | |
| <button class="btn btn-secondary" onclick="Router.navigate('student-history', null)">Back to Grades</button> | |
| </div> | |
| <div id="review-header-card" class="card" style="text-align: center; border-top: 8px solid var(--primary); padding: 3rem;"></div> | |
| <div id="review-questions"></div> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| /** | |
| * ============================================================================ | |
| * SYSTEM LOGGER | |
| * ============================================================================ | |
| * Description: | |
| * A centralized logging system to ensure professional, readable outputs | |
| * in the developer console. Essential for tracking cloud sync statuses. | |
| * ============================================================================ | |
| */ | |
| const SystemLogger = { | |
| info: (msg) => console.log(`%c[INFO] ${msg}`, 'color: #2563EB; font-weight: bold;'), | |
| success: (msg) => console.log(`%c[SUCCESS] ${msg}`, 'color: #10B981; font-weight: bold;'), | |
| warn: (msg) => console.warn(`%c[WARN] ${msg}`, 'color: #F59E0B; font-weight: bold;'), | |
| error: (msg, err = '') => console.error(`%c[ERROR] ${msg}`, 'color: #EF4444; font-weight: bold;', err) | |
| }; | |
| /** | |
| * ============================================================================ | |
| * CORE UTILITIES | |
| * ============================================================================ | |
| * Description: | |
| * Reusable helper functions utilized globally across all controllers. | |
| * Includes shuffling algorithms, percentage calculations, and deep cloning. | |
| * ============================================================================ | |
| */ | |
| const Utils = { | |
| id: () => Math.random().toString(36).substr(2, 9), | |
| classCode: () => Math.random().toString(36).substr(2, 6).toUpperCase(), | |
| shuffle: (array) => { | |
| let cur = array.length, rand; | |
| while (cur !== 0) { | |
| rand = Math.floor(Math.random() * cur); | |
| cur--; | |
| [array[cur], array[rand]] = [array[rand], array[cur]]; | |
| } | |
| return array; | |
| }, | |
| getPctColor: (score, total) => { | |
| if (total === 0) return 'badge-primary'; | |
| const p = score / total; | |
| if (p >= 0.8) return 'badge-success'; | |
| if (p >= 0.6) return 'badge-warning'; | |
| return 'badge-danger'; | |
| }, | |
| deepClone: (obj) => JSON.parse(JSON.stringify(obj)) | |
| }; | |
| /** | |
| * ============================================================================ | |
| * CLOUD-CONNECTED DATABASE MANAGER (v5) | |
| * ============================================================================ | |
| * Description: | |
| * Manages the synchronization between the local client and the Google | |
| * Apps Script backend. Uses caching for speed and uploads in the background. | |
| * ============================================================================ | |
| */ | |
| const DB = { | |
| data: { | |
| users: [], | |
| classes: [], | |
| quizzes: [], | |
| sessions: [], | |
| submissions: [] | |
| }, | |
| /** | |
| * Initializes the cloud connection. | |
| * Attempts to load the global database via google.script.run | |
| * and falls back to local storage if running in an offline or local environment. | |
| */ | |
| initCloud: (callback) => { | |
| SystemLogger.info("Initializing Cloud Database Connection..."); | |
| if (typeof google !== 'undefined' && google.script) { | |
| google.script.run | |
| .withSuccessHandler((cloudData) => { | |
| if (cloudData) { | |
| DB.data = JSON.parse(cloudData); | |
| localStorage.setItem('quiziz_db_v5', cloudData); // Buffer for next instant load | |
| SystemLogger.success("Cloud data loaded successfully."); | |
| } else { | |
| SystemLogger.warn("Cloud data is empty. Checking local fallback..."); | |
| const local = localStorage.getItem('quiziz_db_v5'); | |
| if (local) DB.data = JSON.parse(local); | |
| } | |
| callback(); // Tell the app it's ready to boot | |
| }) | |
| .loadDataFromCloud(); | |
| } else { | |
| SystemLogger.warn("Local Environment Detected. Bypassing Google Script Run."); | |
| const local = localStorage.getItem('quiziz_db_v5'); | |
| if (local) { | |
| try { | |
| DB.data = JSON.parse(local); | |
| SystemLogger.success("Local fallback loaded."); | |
| } catch(e) { | |
| SystemLogger.error("Failed to parse local fallback."); | |
| } | |
| } | |
| callback(); | |
| } | |
| }, | |
| /** | |
| * Loads data synchronously from the local cache to ensure | |
| * instant UI updates without waiting for network latency. | |
| */ | |
| load: () => { | |
| const s = localStorage.getItem('quiziz_db_v5'); | |
| if (s) { | |
| try { | |
| DB.data = JSON.parse(s); | |
| } catch(e) { | |
| SystemLogger.error("DB Local Load Error", e); | |
| } | |
| } | |
| }, | |
| /** | |
| * Saves data synchronously to the local cache, then asynchronously | |
| * triggers a payload upload to the Google Apps Script backend. | |
| */ | |
| save: () => { | |
| try { | |
| const jsonString = JSON.stringify(DB.data); | |
| // 1. Save locally for instant UI speed | |
| localStorage.setItem('quiziz_db_v5', jsonString); | |
| SystemLogger.info("Data buffered locally."); | |
| // 2. Upload to Google Drive Cloud in the background | |
| if (typeof google !== 'undefined' && google.script) { | |
| google.script.run | |
| .withSuccessHandler(() => SystemLogger.success("Data synced to Cloud successfully!")) | |
| .saveDataToCloud(jsonString); | |
| } | |
| } catch(e) { | |
| SystemLogger.error("DB Save Error: ", e); | |
| } | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * UI CONTROLLER | |
| * ============================================================================ | |
| * Description: | |
| * Manages Toast notifications and Modal dialog interactions dynamically. | |
| * ============================================================================ | |
| */ | |
| const UI = { | |
| toast: (msg, type = 'success') => { | |
| const container = document.getElementById('toast-container'); | |
| if (!container) return SystemLogger.warn("Toast container not found."); | |
| const toastEl = document.createElement('div'); | |
| toastEl.className = `toast ${type}`; | |
| toastEl.innerHTML = msg; | |
| container.appendChild(toastEl); | |
| // Animate in | |
| setTimeout(() => toastEl.classList.add('show'), 10); | |
| // Animate out and remove | |
| setTimeout(() => { | |
| toastEl.classList.remove('show'); | |
| setTimeout(() => toastEl.remove(), 400); | |
| }, 4000); | |
| }, | |
| openModal: (title, desc, onConfirm, inputPlaceholder = null) => { | |
| // Determine if password-type input is needed based on the placeholder string | |
| const isPasswordType = inputPlaceholder && inputPlaceholder.toLowerCase().includes("password"); | |
| document.getElementById('modal-title').innerText = title; | |
| document.getElementById('modal-desc').innerText = desc; | |
| const inputWrap = document.getElementById('modal-input-wrapper'); | |
| const inputEl = document.getElementById('modal-input'); | |
| inputEl.value = ''; | |
| inputEl.type = isPasswordType ? 'password' : 'text'; // Dynamically set input type | |
| if (inputPlaceholder) { | |
| inputWrap.classList.remove('hidden'); | |
| inputEl.placeholder = inputPlaceholder; | |
| } else { | |
| inputWrap.classList.add('hidden'); | |
| } | |
| const btn = document.getElementById('modal-confirm-btn'); | |
| const newBtn = btn.cloneNode(true); | |
| btn.parentNode.replaceChild(newBtn, btn); | |
| newBtn.onclick = () => { | |
| if (inputPlaceholder && !inputEl.value.trim()) { | |
| return UI.toast("Input is required to proceed.", "error"); | |
| } | |
| onConfirm(inputEl.value.trim()); | |
| UI.closeModal(); | |
| }; | |
| document.getElementById('modal-container').classList.add('active'); | |
| }, | |
| closeModal: () => { | |
| document.getElementById('modal-container').classList.remove('active'); | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * CONFETTI ENGINE | |
| * ============================================================================ | |
| * Description: | |
| * Renders a full-screen canvas confetti animation upon successful test completion. | |
| * Utilizes requestAnimationFrame for smooth 60fps rendering. | |
| * ============================================================================ | |
| */ | |
| const ConfettiEngine = { | |
| particles: [], canvas: null, ctx: null, w: 0, h: 0, active: false, | |
| colors: ['#10B981', '#0F766E', '#F59E0B', '#EF4444', '#34D399'], | |
| init: () => { | |
| ConfettiEngine.canvas = document.getElementById('confetti-canvas'); | |
| if (!ConfettiEngine.canvas) return; | |
| ConfettiEngine.ctx = ConfettiEngine.canvas.getContext('2d'); | |
| window.addEventListener('resize', ConfettiEngine.resize); | |
| ConfettiEngine.resize(); | |
| }, | |
| resize: () => { | |
| ConfettiEngine.w = window.innerWidth; | |
| ConfettiEngine.h = window.innerHeight; | |
| ConfettiEngine.canvas.width = ConfettiEngine.w; | |
| ConfettiEngine.canvas.height = ConfettiEngine.h; | |
| }, | |
| fire: () => { | |
| if (!ConfettiEngine.canvas) ConfettiEngine.init(); | |
| ConfettiEngine.active = true; | |
| ConfettiEngine.particles = []; | |
| for(let i=0; i<200; i++) { | |
| ConfettiEngine.particles.push({ | |
| x: ConfettiEngine.w / 2, | |
| y: ConfettiEngine.h + 20, | |
| r: Math.random() * 8 + 4, | |
| dx: Math.random() * 30 - 15, | |
| dy: Math.random() * -25 - 15, | |
| c: ConfettiEngine.colors[Math.floor(Math.random() * ConfettiEngine.colors.length)], | |
| tilt: Math.floor(Math.random() * 10) - 10, | |
| tiltAngleInc: (Math.random() * 0.07) + 0.05, | |
| tiltAngle: 0 | |
| }); | |
| } | |
| requestAnimationFrame(ConfettiEngine.loop); | |
| setTimeout(() => { ConfettiEngine.active = false; }, 3500); | |
| }, | |
| loop: () => { | |
| ConfettiEngine.ctx.clearRect(0, 0, ConfettiEngine.w, ConfettiEngine.h); | |
| let remaining = false; | |
| ConfettiEngine.particles.forEach((p) => { | |
| p.tiltAngle += p.tiltAngleInc; | |
| p.y += (Math.cos(p.tiltAngle) + 1 + p.r / 2) / 2; | |
| p.x += Math.sin(p.tiltAngle) * 2 + p.dx; | |
| p.dy += 0.25; | |
| p.y += p.dy; | |
| if (p.y <= ConfettiEngine.h + 20) remaining = true; | |
| ConfettiEngine.ctx.beginPath(); | |
| ConfettiEngine.ctx.lineWidth = p.r; | |
| ConfettiEngine.ctx.strokeStyle = p.c; | |
| ConfettiEngine.ctx.moveTo(p.x + p.tilt + p.r, p.y); | |
| ConfettiEngine.ctx.lineTo(p.x + p.tilt, p.y + p.tilt + p.r); | |
| ConfettiEngine.ctx.stroke(); | |
| }); | |
| if (remaining || ConfettiEngine.active) { | |
| requestAnimationFrame(ConfettiEngine.loop); | |
| } else { | |
| ConfettiEngine.ctx.clearRect(0, 0, ConfettiEngine.w, ConfettiEngine.h); | |
| } | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * ROUTING & APPLICATION STATE (Cloud Upgraded) | |
| * ============================================================================ | |
| * Description: | |
| * Manages the single-page application views, state, and user sessions. | |
| * Implements a blocking initialization sequence to ensure cloud data loads first. | |
| * ============================================================================ | |
| */ | |
| const AppState = { user: null }; | |
| const Router = { | |
| init: () => { | |
| SystemLogger.info("Application Initialization Started."); | |
| if (typeof UI !== 'undefined' && UI.toast) { | |
| UI.toast("Connecting to Global Database...", "success"); | |
| } | |
| // Synchronize with the cloud BEFORE booting the UI | |
| DB.initCloud(() => { | |
| const storedUser = localStorage.getItem('quiziz_user_v5'); | |
| if (storedUser) { | |
| try { | |
| AppState.user = JSON.parse(storedUser); | |
| Router.bootApp(); | |
| } catch (e) { | |
| SystemLogger.error("Failed to parse stored user data."); | |
| document.getElementById('view-auth').classList.remove('hidden'); | |
| } | |
| } else { | |
| document.getElementById('view-auth').classList.remove('hidden'); | |
| } | |
| ConfettiEngine.init(); | |
| }); | |
| }, | |
| bootApp: () => { | |
| document.getElementById('view-auth').classList.add('hidden'); | |
| document.getElementById('view-app').classList.remove('hidden'); | |
| document.getElementById('nav-user-name').innerText = AppState.user.name; | |
| document.getElementById('nav-user-role').innerText = AppState.user.role === 'teacher' ? 'Instructor' : 'Student'; | |
| document.getElementById('nav-student').classList.add('hidden'); | |
| document.getElementById('nav-teacher').classList.add('hidden'); | |
| if (AppState.user.role === 'student') { | |
| document.getElementById('nav-student').classList.remove('hidden'); | |
| Router.navigate('student-dash', document.querySelector('#nav-student .nav-item')); | |
| } else { | |
| document.getElementById('nav-teacher').classList.remove('hidden'); | |
| Router.navigate('teacher-classes', document.querySelector('#nav-teacher .nav-item')); | |
| } | |
| }, | |
| navigate: (pageId, navElement) => { | |
| SystemLogger.info(`Navigating to View: ${pageId}`); | |
| if (TestCtrl.liveSession && pageId !== 'take-test') { | |
| return UI.toast("You must pause or submit your assessment first.", "warning"); | |
| } | |
| document.querySelectorAll('.page').forEach(p => p.classList.add('hidden')); | |
| const targetPage = document.getElementById('page-' + pageId); | |
| if (targetPage) targetPage.classList.remove('hidden'); | |
| if (navElement) { | |
| document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active')); | |
| navElement.classList.add('active'); | |
| } | |
| // Call contextual render functions upon navigation | |
| if (pageId === 'teacher-classes') ClassCtrl.renderTeacherClasses(); | |
| else if (pageId === 'teacher-bank') BuilderCtrl.renderBank(); | |
| else if (pageId === 'teacher-grading') TeacherCtrl.renderGrading(); | |
| else if (pageId === 'student-dash') ClassCtrl.renderStudentClasses(); | |
| else if (pageId === 'student-history') StudentCtrl.renderHistory(); | |
| else if (pageId === 'settings') SettingsCtrl.init(); | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * AUTHENTICATION LOGIC | |
| * ============================================================================ | |
| * Description: | |
| * Manages user registration, verification against the cloud DB, and session tokens. | |
| * ============================================================================ | |
| */ | |
| const Auth = { | |
| switchTab: (tab) => { | |
| document.querySelectorAll('.card-auth .tab').forEach(t => t.classList.remove('active')); | |
| document.getElementById('tab-'+tab).classList.add('active'); | |
| document.getElementById('form-login').classList.add('hidden'); | |
| document.getElementById('form-register').classList.add('hidden'); | |
| document.getElementById('form-'+tab).classList.remove('hidden'); | |
| }, | |
| toggleTeacherCode: (val) => { | |
| const group = document.getElementById('reg-code-group'); | |
| if (val === 'teacher') { | |
| group.classList.remove('hidden'); | |
| } else { | |
| group.classList.add('hidden'); | |
| } | |
| }, | |
| register: () => { | |
| const role = document.getElementById('reg-role').value; | |
| const name = document.getElementById('reg-name').value.trim(); | |
| const user = document.getElementById('reg-user').value.trim(); | |
| const pass = document.getElementById('reg-pass').value; | |
| if (!name || !user || !pass) { | |
| return UI.toast("All fields are strictly required.", "error"); | |
| } | |
| if (DB.data.users.find(u => u.username.toLowerCase() === user.toLowerCase())) { | |
| return UI.toast("This Username is already taken.", "error"); | |
| } | |
| if (role === 'teacher' && document.getElementById('reg-code').value.trim() !== '42') { | |
| return UI.toast("Invalid Administrator Access Code.", "error"); | |
| } | |
| const newUser = { | |
| id: Utils.id(), | |
| role: role, | |
| name: name, | |
| username: user, | |
| password: pass | |
| }; | |
| DB.data.users.push(newUser); | |
| DB.save(); // Sync new user to cloud immediately | |
| AppState.user = newUser; | |
| localStorage.setItem('quiziz_user_v5', JSON.stringify(newUser)); | |
| UI.toast("Account securely created.", "success"); | |
| Router.bootApp(); | |
| }, | |
| login: () => { | |
| const user = document.getElementById('login-user').value.trim(); | |
| const pass = document.getElementById('login-pass').value; | |
| const foundUser = DB.data.users.find(u => u.username.toLowerCase() === user.toLowerCase() && u.password === pass); | |
| if (!foundUser) { | |
| return UI.toast("Invalid username or password.", "error"); | |
| } | |
| AppState.user = foundUser; | |
| localStorage.setItem('quiziz_user_v5', JSON.stringify(foundUser)); | |
| UI.toast("Welcome back.", "success"); | |
| Router.bootApp(); | |
| }, | |
| logout: () => { | |
| AppState.user = null; | |
| localStorage.removeItem('quiziz_user_v5'); | |
| document.getElementById('view-app').classList.add('hidden'); | |
| document.getElementById('view-auth').classList.remove('hidden'); | |
| document.querySelectorAll('input').forEach(i => i.value = ''); | |
| Auth.switchTab('login'); | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * SETTINGS CONTROLLER | |
| * ============================================================================ | |
| * Description: | |
| * Manages user profile updates, strictly verifying username availability globally. | |
| * ============================================================================ | |
| */ | |
| const SettingsCtrl = { | |
| init: () => { | |
| DB.load(); | |
| const nInput = document.getElementById('set-name'); | |
| const uInput = document.getElementById('set-user'); | |
| const pInput = document.getElementById('set-pass'); | |
| if(nInput) nInput.value = AppState.user.name; | |
| if(uInput) uInput.value = AppState.user.username; | |
| if(pInput) pInput.value = ''; | |
| }, | |
| save: () => { | |
| const newName = document.getElementById('set-name').value.trim(); | |
| const newUser = document.getElementById('set-user').value.trim(); | |
| const newPass = document.getElementById('set-pass').value; | |
| if (!newName || !newUser) { | |
| return UI.toast("Full Name and Username cannot be empty.", "error"); | |
| } | |
| DB.load(); | |
| const existingUser = DB.data.users.find(u => u.username.toLowerCase() === newUser.toLowerCase() && u.id !== AppState.user.id); | |
| if (existingUser) { | |
| return UI.toast("That Username is already assigned to someone else.", "error"); | |
| } | |
| const userIndex = DB.data.users.findIndex(u => u.id === AppState.user.id); | |
| if (userIndex >= 0) { | |
| DB.data.users[userIndex].name = newName; | |
| DB.data.users[userIndex].username = newUser; | |
| if (newPass && newPass.trim() !== '') { | |
| DB.data.users[userIndex].password = newPass; | |
| } | |
| DB.save(); | |
| AppState.user = DB.data.users[userIndex]; | |
| localStorage.setItem('quiziz_user_v5', JSON.stringify(AppState.user)); | |
| const nameEl = document.getElementById('nav-user-name'); | |
| if (nameEl) nameEl.innerText = AppState.user.name; | |
| UI.toast("Account settings have been saved successfully!", "success"); | |
| document.getElementById('set-pass').value = ''; | |
| } else { | |
| UI.toast("Critical Error: Active user not found in database.", "error"); | |
| } | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * CLASS MANAGEMENT CONTROLLER | |
| * ============================================================================ | |
| * Description: | |
| * Comprehensive management for Instructors and Students regarding classrooms, | |
| * enrollments, assignment delegation, and class editing features. | |
| * ============================================================================ | |
| */ | |
| const ClassCtrl = { | |
| currentClassId: null, | |
| /** | |
| * Renders the grid of classes owned by the active teacher. | |
| */ | |
| renderTeacherClasses: () => { | |
| DB.load(); | |
| const list = document.getElementById('teacher-class-list'); | |
| list.innerHTML = ''; | |
| const myClasses = DB.data.classes.filter(c => c.teacherId === AppState.user.id); | |
| if (myClasses.length === 0) { | |
| list.innerHTML = `<div class="card" style="text-align:center; grid-column: 1/-1;"><h3>No classes yet.</h3><p>Create a class to generate a join code for students.</p></div>`; | |
| return; | |
| } | |
| myClasses.forEach(cls => { | |
| const card = document.createElement('div'); | |
| card.className = 'card'; | |
| card.style.cursor = 'pointer'; | |
| card.onclick = () => ClassCtrl.openTeacherClassDetail(cls.id); | |
| card.innerHTML = ` | |
| <div style="background: var(--primary-light); padding: 1.5rem; margin: -2.5rem -2.5rem 1.5rem -2.5rem; border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-bottom: 2px solid var(--primary);"> | |
| <h2 style="color: var(--primary); margin: 0;">${cls.name}</h2> | |
| </div> | |
| <p style="font-size: 1.1rem; font-weight: 700;">Code: <span class="badge badge-code">${cls.code}</span></p> | |
| <div class="flex-row" style="margin-top: 1.5rem; color: var(--text-muted);"> | |
| <span>🧑🎓 ${cls.students.length} Students</span> | |
| <span>📝 ${cls.assignments.length} Quizzes</span> | |
| </div> | |
| `; | |
| list.appendChild(card); | |
| }); | |
| }, | |
| /** | |
| * Generates a new classroom object and synchronizes it globally. | |
| */ | |
| showCreateModal: () => { | |
| UI.openModal( | |
| "Create New Class", | |
| "Enter a distinct name for your class.", | |
| (name) => { | |
| const newClass = { | |
| id: Utils.id(), | |
| name: name, | |
| code: Utils.classCode(), | |
| teacherId: AppState.user.id, | |
| students: [], | |
| assignments: [] | |
| }; | |
| DB.data.classes.push(newClass); | |
| DB.save(); | |
| ClassCtrl.renderTeacherClasses(); | |
| UI.toast(`Class created! Your code is: ${newClass.code}`, "success"); | |
| }, | |
| "e.g. Biology 101 Period 2" | |
| ); | |
| }, | |
| /** | |
| * Routes the instructor to the detail management view of a specific class. | |
| * @param {string} classId - The unique identifier of the target class. | |
| */ | |
| openTeacherClassDetail: (classId) => { | |
| ClassCtrl.currentClassId = classId; | |
| DB.load(); | |
| const cls = DB.data.classes.find(c => c.id === classId); | |
| const nameEl = document.getElementById('tc-detail-name'); | |
| const codeEl = document.getElementById('tc-detail-code'); | |
| if(nameEl) nameEl.innerText = cls.name; | |
| if(codeEl) codeEl.innerText = cls.code; | |
| Router.navigate('teacher-class-detail'); | |
| ClassCtrl.switchDetailTab('assignments'); | |
| }, | |
| /** | |
| * Prompts the teacher to edit the name of their class. | |
| */ | |
| editClassName: () => { | |
| DB.load(); | |
| const cls = DB.data.classes.find(c => c.id === ClassCtrl.currentClassId); | |
| if (!cls) return UI.toast("Error: Class not found.", "error"); | |
| UI.openModal( | |
| "Edit Class Name", | |
| "Enter a new name for this class:", | |
| (newName) => { | |
| const trimmedName = newName.trim(); | |
| if (!trimmedName) { | |
| return UI.toast("Class name cannot be empty.", "error"); | |
| } | |
| cls.name = trimmedName; | |
| DB.save(); | |
| const headerEl = document.getElementById('tc-detail-name'); | |
| if(headerEl) headerEl.innerText = trimmedName; | |
| UI.toast("Class name updated successfully.", "success"); | |
| }, | |
| cls.name | |
| ); | |
| }, | |
| /** | |
| * Initiates the permanent deletion protocol for a classroom. | |
| * Requires exact string matching for safety. | |
| */ | |
| deleteClass: () => { | |
| UI.openModal( | |
| "Delete Class", | |
| "Are you absolutely sure? This permanently removes the roster and active assignments. Type 'DELETE' below to confirm.", | |
| (confirmation) => { | |
| if (confirmation.trim() !== 'DELETE') { | |
| return UI.toast("Action cancelled. You must type 'DELETE' exactly.", "error"); | |
| } | |
| DB.load(); | |
| DB.data.classes = DB.data.classes.filter(c => c.id !== ClassCtrl.currentClassId); | |
| DB.save(); | |
| UI.toast("Class successfully deleted.", "success"); | |
| Router.navigate('teacher-classes', document.querySelector('#nav-teacher .nav-item:nth-child(1)')); | |
| }, | |
| "Type DELETE" | |
| ); | |
| }, | |
| /** | |
| * Handles switching between inner tabs in the class detail view. | |
| * @param {string} tab - The target tab identifier (assignments or roster). | |
| */ | |
| switchDetailTab: (tab) => { | |
| ['assignments', 'roster'].forEach(t => { | |
| const tEl = document.getElementById('tab-tcd-'+t); | |
| const vEl = document.getElementById('tcd-view-'+t); | |
| if(tEl) tEl.classList.remove('active'); | |
| if(vEl) vEl.classList.add('hidden'); | |
| }); | |
| const atEl = document.getElementById('tab-tcd-'+tab); | |
| const avEl = document.getElementById('tcd-view-'+tab); | |
| if(atEl) atEl.classList.add('active'); | |
| if(avEl) avEl.classList.remove('hidden'); | |
| DB.load(); | |
| const cls = DB.data.classes.find(c => c.id === ClassCtrl.currentClassId); | |
| if (tab === 'assignments') { | |
| const list = document.getElementById('tcd-assignments-list'); | |
| list.innerHTML = ''; | |
| if (cls.assignments.length === 0) { | |
| list.innerHTML = `<div class="card" style="text-align:center;">No assessments assigned to this class.</div>`; | |
| } | |
| cls.assignments.forEach(qId => { | |
| const qz = DB.data.quizzes.find(q => q.id === qId); | |
| if (!qz) return; | |
| const el = document.createElement('div'); | |
| el.className = 'list-item'; | |
| el.innerHTML = ` | |
| <div> | |
| <h3 style="margin:0;">${qz.title}</h3> | |
| <p style="margin:0; font-size:0.9rem;" class="mt-2">${qz.questions.length} Questions | ${qz.timeLimit ? qz.timeLimit + ' Mins' : 'Unlimited'}</p> | |
| </div> | |
| <button class="btn btn-danger btn-sm" onclick="ClassCtrl.removeAssignment('${qId}')">Remove from Class</button> | |
| `; | |
| list.appendChild(el); | |
| }); | |
| } else if (tab === 'roster') { | |
| const list = document.getElementById('tcd-roster-list'); | |
| list.innerHTML = ''; | |
| if (cls.students.length === 0) { | |
| list.innerHTML = `<div class="card" style="text-align:center; grid-column:1/-1;">No students enrolled yet. Give them the Class Code!</div>`; | |
| } | |
| cls.students.forEach(sId => { | |
| const stu = DB.data.users.find(u => u.id === sId); | |
| if (!stu) return; | |
| const el = document.createElement('div'); | |
| el.className = 'card'; | |
| el.style.marginBottom = '0'; | |
| el.innerHTML = `<h3 style="margin:0; color:var(--primary);">${stu.name}</h3><p style="margin:0; font-size:0.9rem;">@${stu.username}</p>`; | |
| list.appendChild(el); | |
| }); | |
| } | |
| }, | |
| showAssignModal: () => { | |
| DB.load(); | |
| const myQuizzes = DB.data.quizzes.filter(q => q.author === AppState.user.id); | |
| if (myQuizzes.length === 0) { | |
| return UI.toast("You have no quizzes in your bank to assign.", "error"); | |
| } | |
| const cls = DB.data.classes.find(c => c.id === ClassCtrl.currentClassId); | |
| const unassigned = myQuizzes.filter(q => !cls.assignments.includes(q.id)); | |
| if (unassigned.length === 0) { | |
| return UI.toast("All your quizzes are already assigned here.", "error"); | |
| } | |
| let html = `<div style="display:flex; flex-direction:column; gap:1rem;">`; | |
| unassigned.forEach(q => { | |
| html += `<button class="btn btn-secondary" style="justify-content:flex-start;" onclick="ClassCtrl.assignQuiz('${q.id}')">${q.title}</button>`; | |
| }); | |
| html += `</div>`; | |
| document.getElementById('modal-title').innerText = "Assign Assessment"; | |
| document.getElementById('modal-desc').innerHTML = html; | |
| const wrap = document.getElementById('modal-input-wrapper'); | |
| const cBtn = document.getElementById('modal-confirm-btn'); | |
| if(wrap) wrap.classList.add('hidden'); | |
| if(cBtn) cBtn.classList.add('hidden'); | |
| document.getElementById('modal-container').classList.add('active'); | |
| }, | |
| assignQuiz: (quizId) => { | |
| DB.load(); | |
| const cls = DB.data.classes.find(c => c.id === ClassCtrl.currentClassId); | |
| if (!cls.assignments.includes(quizId)) { | |
| cls.assignments.push(quizId); | |
| } | |
| DB.save(); | |
| UI.closeModal(); | |
| const cBtn = document.getElementById('modal-confirm-btn'); | |
| if(cBtn) cBtn.classList.remove('hidden'); | |
| ClassCtrl.switchDetailTab('assignments'); | |
| UI.toast("Assessment assigned successfully.", "success"); | |
| }, | |
| removeAssignment: (quizId) => { | |
| UI.openModal( | |
| "Remove Assessment", | |
| "Remove this quiz from the class? (Student grades will remain intact).", | |
| () => { | |
| DB.load(); | |
| const cls = DB.data.classes.find(c => c.id === ClassCtrl.currentClassId); | |
| cls.assignments = cls.assignments.filter(id => id !== quizId); | |
| DB.save(); | |
| ClassCtrl.switchDetailTab('assignments'); | |
| UI.toast("Assessment removed from class.", "success"); | |
| } | |
| ); | |
| }, | |
| // --- STUDENT FACING LOGIC --- | |
| renderStudentClasses: () => { | |
| DB.load(); | |
| const list = document.getElementById('student-class-list'); | |
| list.innerHTML = ''; | |
| const myClasses = DB.data.classes.filter(c => c.students.includes(AppState.user.id)); | |
| if (myClasses.length === 0) { | |
| list.innerHTML = `<div class="card" style="text-align:center; grid-column: 1/-1;"><h3>No classes yet.</h3><p>Click "+ Join a Class" and enter your instructor's code.</p></div>`; | |
| return; | |
| } | |
| myClasses.forEach(cls => { | |
| const teacher = DB.data.users.find(u => u.id === cls.teacherId); | |
| const card = document.createElement('div'); | |
| card.className = 'card'; | |
| card.style.cursor = 'pointer'; | |
| card.onclick = () => ClassCtrl.openStudentClassDetail(cls.id); | |
| card.innerHTML = ` | |
| <div style="background: var(--primary); padding: 1.5rem; margin: -2.5rem -2.5rem 1.5rem -2.5rem; border-radius: var(--radius-lg) var(--radius-lg) 0 0;"> | |
| <h2 style="color: white; margin: 0;">${cls.name}</h2> | |
| </div> | |
| <p style="font-weight: 700; color: var(--text-main);">Instructor: ${teacher ? teacher.name : 'Unknown'}</p> | |
| <div class="flex-row" style="margin-top: 1.5rem;"> | |
| <span class="badge badge-primary">${cls.assignments.length} Assessments</span> | |
| </div> | |
| `; | |
| list.appendChild(card); | |
| }); | |
| }, | |
| openStudentClassDetail: (classId) => { | |
| ClassCtrl.currentClassId = classId; | |
| DB.load(); | |
| const cls = DB.data.classes.find(c => c.id === classId); | |
| const teacher = DB.data.users.find(u => u.id === cls.teacherId); | |
| const nEl = document.getElementById('sc-detail-name'); | |
| const tEl = document.getElementById('sc-detail-teacher'); | |
| if(nEl) nEl.innerText = cls.name; | |
| if(tEl) tEl.innerText = teacher ? teacher.name : 'Unknown'; | |
| Router.navigate('student-class-detail'); | |
| const list = document.getElementById('sc-assignments-list'); | |
| list.innerHTML = ''; | |
| if (cls.assignments.length === 0) { | |
| list.innerHTML = `<div class="card" style="text-align:center;">No active assessments assigned by instructor.</div>`; | |
| return; | |
| } | |
| cls.assignments.forEach(qId => { | |
| const qz = DB.data.quizzes.find(q => q.id === qId); | |
| if (!qz) return; | |
| const submission = DB.data.submissions.find(s => s.quizId === qId && s.studentId === AppState.user.id); | |
| const session = DB.data.sessions.find(s => s.quizId === qId && s.studentId === AppState.user.id); | |
| let statusHtml = `<button class="btn btn-primary" onclick="TestCtrl.initPreTest('${qId}', '${cls.name}')">Start Assessment</button>`; | |
| if (submission) { | |
| statusHtml = `<span class="badge badge-success">Completed</span>`; | |
| } else if (session) { | |
| statusHtml = `<button class="btn btn-warning" style="background:var(--warning); color:white; border:none;" onclick="TestCtrl.initPreTest('${qId}', '${cls.name}')">Resume Paused</button>`; | |
| } | |
| const el = document.createElement('div'); | |
| el.className = 'list-item'; | |
| el.innerHTML = ` | |
| <div> | |
| <h3 style="margin:0;">${qz.title}</h3> | |
| <p style="margin:0; font-size:0.9rem;" class="mt-2">${qz.questions.length} Qs | ${qz.timeLimit ? qz.timeLimit + ' Mins' : 'Unlimited Time'}</p> | |
| </div> | |
| <div class="flex-row">${statusHtml}</div> | |
| `; | |
| list.appendChild(el); | |
| }); | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * STUDENT CONTROLLER | |
| * ============================================================================ | |
| * Description: | |
| * Distinct functions mapped to specific student features, mainly class enrollment | |
| * and historical performance metrics. | |
| * ============================================================================ | |
| */ | |
| const StudentCtrl = { | |
| /** | |
| * Resolves class connection using global tracking code. | |
| */ | |
| showJoinModal: () => { | |
| UI.openModal( | |
| "Join a Class", | |
| "Enter the 6-character Class Code provided by your instructor.", | |
| (code) => { | |
| if (!code || code.trim() === '') { | |
| return UI.toast("You must provide a code to join.", "error"); | |
| } | |
| const cleanCode = code.trim().toUpperCase(); | |
| DB.load(); | |
| const targetClass = DB.data.classes.find(c => c.code === cleanCode); | |
| if (!targetClass) { | |
| return UI.toast("Invalid class code. Please check and try again.", "error"); | |
| } | |
| if (!targetClass.students) { | |
| targetClass.students = []; | |
| } | |
| if (targetClass.students.includes(AppState.user.id)) { | |
| return UI.toast("You are already enrolled in this class.", "error"); | |
| } | |
| targetClass.students.push(AppState.user.id); | |
| DB.save(); | |
| UI.toast(`Successfully enrolled in ${targetClass.name}!`, "success"); | |
| ClassCtrl.renderStudentClasses(); | |
| }, | |
| "Class Code (e.g. A1B2C3)" | |
| ); | |
| }, | |
| renderHistory: () => { | |
| DB.load(); | |
| const list = document.getElementById('student-history-list'); | |
| list.innerHTML = ''; | |
| const subs = DB.data.submissions.filter(s => s.studentId === AppState.user.id).sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp)); | |
| if (subs.length === 0) { | |
| return list.innerHTML = '<div class="card" style="text-align:center;">No completed assessments yet.</div>'; | |
| } | |
| subs.forEach(sub => { | |
| const qz = DB.data.quizzes.find(q => q.id === sub.quizId); | |
| if (!qz) return; | |
| const el = document.createElement('div'); | |
| el.className = 'list-item'; | |
| el.innerHTML = ` | |
| <div> | |
| <h3 style="margin:0; color:var(--primary);">${qz.title}</h3> | |
| <p style="margin:0; font-size:0.9rem;" class="mt-2">${new Date(sub.timestamp).toLocaleDateString()} at ${new Date(sub.timestamp).toLocaleTimeString()}</p> | |
| </div> | |
| <div class="flex-row"> | |
| ${sub.status === 'pending' ? `<span class="badge badge-warning">Pending Review</span>` : `<span class="badge ${Utils.getPctColor(sub.earnedPts, sub.totalPts)}">Score: ${sub.earnedPts}/${sub.totalPts}</span>`} | |
| <button class="btn btn-secondary" onclick="StudentCtrl.openReview('${sub.id}')">View Report</button> | |
| </div>`; | |
| list.appendChild(el); | |
| }); | |
| }, | |
| openReview: (subId) => { | |
| const sub = DB.data.submissions.find(s => s.id === subId); | |
| const qz = DB.data.quizzes.find(q => q.id === sub.quizId); | |
| document.querySelectorAll('.page').forEach(p => p.classList.add('hidden')); | |
| document.getElementById('page-review-test').classList.remove('hidden'); | |
| const hdr = document.getElementById('review-header-card'); | |
| if (sub.status === 'pending') { | |
| hdr.innerHTML = `<h2 style="color:var(--warning); font-size: 2.5rem;">Review Pending</h2><p style="font-size:1.2rem;">Waiting for manual grading by your instructor.</p>`; | |
| } else { | |
| hdr.innerHTML = `<h2 style="color:var(--primary); font-size:4rem; margin-bottom:0.5rem;">${Math.round((sub.earnedPts/sub.totalPts)*100)}%</h2><p style="font-size:1.2rem; font-weight:700;">${sub.earnedPts} of ${sub.totalPts} Points Earned</p>`; | |
| } | |
| const c = document.getElementById('review-questions'); | |
| c.innerHTML = ''; | |
| if (!sub.feedback) sub.feedback = {}; | |
| qz.questions.forEach((q, idx) => { | |
| const card = document.createElement('div'); | |
| card.className = 'question-card'; | |
| const sAns = sub.answers[q.id]; | |
| const pts = sub.scores[q.id]; | |
| const sFeed = sub.feedback[q.id]; | |
| let html = `<div class="flex-between" style="margin-bottom:1.5rem;"><h3 style="margin:0; font-size:1.2rem;">${idx+1}. ${q.text}</h3><span class="badge ${pts === q.pts ? 'badge-success' : 'badge-danger'}">${pts} / ${q.pts} Pts</span></div>`; | |
| if (q.type === 'mc') { | |
| q.options.forEach((opt, oIdx) => { | |
| let cls = 'mc-option'; | |
| if (opt.correct) cls += ' selected'; | |
| else if (sAns === oIdx && !opt.correct) cls += ' wrong'; | |
| html += `<div class="${cls}" style="cursor:default; pointer-events:none;"><input type="radio" disabled ${sAns===oIdx ? 'checked' : ''}><span>${opt.text}</span></div>`; | |
| }); | |
| } else { | |
| html += `<div style="background:var(--bg-offset); padding:1.5rem; border-radius:var(--radius-md); border:1px solid var(--border); font-size:1.1rem;"><strong>Your Response:</strong><p style="margin-top:0.5rem;">${sAns || '<em>None</em>'}</p></div>`; | |
| } | |
| if (sFeed) { | |
| html += `<div style="background-color: var(--warning-light); border-left: 6px solid var(--warning); padding: 1.5rem; margin-top: 1.5rem; border-radius: var(--radius-sm); font-size: 1.1rem;"><strong>Instructor Feedback:</strong><br><span style="color:#B45309; font-style:italic;">"${sFeed}"</span></div>`; | |
| } | |
| card.innerHTML = html; | |
| c.appendChild(card); | |
| }); | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * TEACHER QUIZ BUILDER & BANK | |
| * ============================================================================ | |
| * Description: | |
| * Manages Quiz creation, saving, editing, previewing, and bulk importing. | |
| * ============================================================================ | |
| */ | |
| const BuilderCtrl = { | |
| id: null, | |
| questions: [], | |
| existingIdx: -1, | |
| renderBank: () => { | |
| DB.load(); | |
| const list = document.getElementById('teacher-quiz-list'); | |
| list.innerHTML = ''; | |
| const qs = DB.data.quizzes.filter(q => q.author === AppState.user.id); | |
| if (qs.length === 0) { | |
| return list.innerHTML = `<div class="card" style="text-align:center; grid-column:1/-1;">No assessments in bank. Create one!</div>`; | |
| } | |
| qs.forEach(q => { | |
| const el = document.createElement('div'); | |
| el.className = 'card'; | |
| el.style.marginBottom = '0'; | |
| el.innerHTML = ` | |
| <h3 style="margin-bottom: 0.5rem; color: var(--primary);">${q.title}</h3> | |
| <p style="margin-bottom: 1.5rem;">${q.questions.length} Questions</p> | |
| <div class="flex-row"> | |
| <button class="btn btn-secondary btn-sm" onclick="BuilderCtrl.init('${q.id}')">Edit</button> | |
| <button class="btn btn-success btn-sm" onclick="TestCtrl.previewAssessment('${q.id}')">Preview</button> | |
| <button class="btn btn-danger btn-sm" onclick="BuilderCtrl.deleteQuiz('${q.id}')">Delete</button> | |
| </div> | |
| `; | |
| list.appendChild(el); | |
| }); | |
| }, | |
| deleteQuiz: (id) => { | |
| UI.openModal( | |
| "Delete Assessment", | |
| "Permanently delete from bank? (Will be removed from all assigned classes).", | |
| () => { | |
| DB.load(); | |
| DB.data.quizzes = DB.data.quizzes.filter(q => q.id !== id); | |
| DB.data.classes.forEach(c => { | |
| if(c.assignments) { | |
| c.assignments = c.assignments.filter(aid => aid !== id); | |
| } | |
| }); | |
| DB.save(); | |
| BuilderCtrl.renderBank(); | |
| UI.toast("Assessment deleted.", "success"); | |
| } | |
| ); | |
| }, | |
| init: (editId = null) => { | |
| DB.load(); | |
| BuilderCtrl.id = editId || Utils.id(); | |
| BuilderCtrl.questions = []; | |
| BuilderCtrl.existingIdx = DB.data.quizzes.findIndex(q => q.id === editId); | |
| document.getElementById('builder-header-title').innerText = editId ? "Edit Assessment" : "Create Assessment"; | |
| document.getElementById('build-title').value = ''; | |
| document.getElementById('build-timer').value = '0'; | |
| // Clear password field initially | |
| const pwdInput = document.getElementById('build-password'); | |
| if (pwdInput) pwdInput.value = ''; | |
| if (BuilderCtrl.existingIdx >= 0) { | |
| const qz = DB.data.quizzes[BuilderCtrl.existingIdx]; | |
| document.getElementById('build-title').value = qz.title; | |
| document.getElementById('build-timer').value = qz.timeLimit || 0; | |
| document.getElementById('build-rand-q').checked = qz.randQ; | |
| document.getElementById('build-rand-o').checked = qz.randO; | |
| // ADDED: Load existing password into settings | |
| if (pwdInput && qz.password) pwdInput.value = qz.password; | |
| BuilderCtrl.questions = Utils.deepClone(qz.questions); | |
| } else { | |
| BuilderCtrl.addQuestion(); | |
| } | |
| Router.navigate('teacher-builder', null); | |
| BuilderCtrl.switchTab('editor'); | |
| }, | |
| switchTab: (tab) => { | |
| ['editor', 'settings', 'bulk'].forEach(t => { | |
| const tEl = document.getElementById('tab-tb-' + t); | |
| const vEl = document.getElementById('build-view-' + t); | |
| if (tEl) tEl.classList.remove('active'); | |
| if (vEl) vEl.classList.add('hidden'); | |
| }); | |
| const aTab = document.getElementById('tab-tb-' + tab); | |
| const aView = document.getElementById('build-view-' + tab); | |
| if (aTab) aTab.classList.add('active'); | |
| if (aView) aView.classList.remove('hidden'); | |
| if (tab === 'editor') BuilderCtrl.renderQuestions(); | |
| }, | |
| addQuestion: () => { | |
| BuilderCtrl.questions.push({ | |
| id: Utils.id(), | |
| type: 'mc', | |
| text: '', | |
| pts: 1, | |
| options: [{text:'', correct:true}, {text:'', correct:false}] | |
| }); | |
| BuilderCtrl.renderQuestions(); | |
| }, | |
| removeQuestion: (idx) => { | |
| BuilderCtrl.questions.splice(idx, 1); | |
| BuilderCtrl.renderQuestions(); | |
| }, | |
| renderQuestions: () => { | |
| const c = document.getElementById('build-questions-list'); | |
| c.innerHTML = ''; | |
| BuilderCtrl.questions.forEach((q, idx) => { | |
| const card = document.createElement('div'); | |
| card.className = 'question-card'; | |
| let optsHtml = ''; | |
| if (q.type === 'mc') { | |
| optsHtml = `<div style="margin-top: 1rem;"><label style="font-weight:700; display:block; margin-bottom:0.5rem;">Options (Check correct)</label>`; | |
| q.options.forEach((opt, oIdx) => { | |
| optsHtml += ` | |
| <div class="flex-row" style="margin-top: 0.75rem;"> | |
| <input type="radio" name="corr_${q.id}" ${opt.correct ? 'checked' : ''} onchange="BuilderCtrl.setCorrect(${idx}, ${oIdx})" style="width:1.5rem; height:1.5rem; accent-color:var(--primary);"> | |
| <input type="text" class="form-control" value="${opt.text}" placeholder="Option text" oninput="BuilderCtrl.updOpt(${idx}, ${oIdx}, this.value)"> | |
| <button class="btn btn-danger btn-sm" onclick="BuilderCtrl.rmOpt(${idx}, ${oIdx})">Del</button> | |
| </div>`; | |
| }); | |
| optsHtml += `</div><button class="btn btn-secondary btn-sm" style="margin-top:1rem;" onclick="BuilderCtrl.addOpt(${idx})">+ Add Option</button>`; | |
| } else { | |
| optsHtml = `<div class="input-group" style="margin-top: 1.5rem; background: var(--warning-light); padding: 1rem; border-radius: var(--radius-sm);"><label style="color: #B45309;">Instructor Note</label><p style="margin:0; color: #B45309;">Short Answer questions require manual grading via the Grading Center.</p></div>`; | |
| } | |
| card.innerHTML = ` | |
| <div class="flex-between" style="margin-bottom: 1.5rem;"> | |
| <h3 style="margin:0; color: var(--primary);">Question ${idx+1}</h3> | |
| <div class="flex-row" style="gap: 0.5rem;"> | |
| <select class="form-control" style="width:auto; padding:0.5rem;" onchange="BuilderCtrl.changeType(${idx}, this.value)"> | |
| <option value="mc" ${q.type==='mc' ? 'selected' : ''}>Multiple Choice</option> | |
| <option value="short" ${q.type==='short' ? 'selected' : ''}>Short Answer</option> | |
| </select> | |
| <input type="number" class="form-control" value="${q.pts}" min="1" style="width:80px; padding:0.5rem;" oninput="BuilderCtrl.questions[${idx}].pts = parseInt(this.value)||1"> | |
| <button class="btn btn-danger btn-sm" onclick="BuilderCtrl.removeQuestion(${idx})">Remove</button> | |
| </div> | |
| </div> | |
| <textarea class="form-control" placeholder="Enter prompt..." oninput="BuilderCtrl.questions[${idx}].text = this.value" style="font-size: 1.1rem;">${q.text}</textarea> | |
| ${optsHtml} | |
| `; | |
| c.appendChild(card); | |
| }); | |
| }, | |
| changeType: (qIdx, type) => { | |
| BuilderCtrl.questions[qIdx].type = type; | |
| BuilderCtrl.renderQuestions(); | |
| }, | |
| updOpt: (qIdx, oIdx, val) => { | |
| BuilderCtrl.questions[qIdx].options[oIdx].text = val; | |
| }, | |
| setCorrect: (qIdx, oIdx) => { | |
| BuilderCtrl.questions[qIdx].options.forEach((o, i) => o.correct = (i === oIdx)); | |
| }, | |
| addOpt: (qIdx) => { | |
| BuilderCtrl.questions[qIdx].options.push({text:'', correct:false}); | |
| BuilderCtrl.renderQuestions(); | |
| }, | |
| rmOpt: (qIdx, oIdx) => { | |
| if (BuilderCtrl.questions[qIdx].options.length <= 2) { | |
| return UI.toast("A multiple choice question needs at least 2 options.", "error"); | |
| } | |
| BuilderCtrl.questions[qIdx].options.splice(oIdx, 1); | |
| if (!BuilderCtrl.questions[qIdx].options.some(o => o.correct)) { | |
| BuilderCtrl.questions[qIdx].options[0].correct = true; | |
| } | |
| BuilderCtrl.renderQuestions(); | |
| }, | |
| save: () => { | |
| const title = document.getElementById('build-title').value.trim(); | |
| if (!title) { | |
| return UI.toast("Assessment Title is required.", "error"); | |
| } | |
| if (BuilderCtrl.questions.length === 0) { | |
| return UI.toast("At least 1 question is required.", "error"); | |
| } | |
| // ADDED: Capture the optional password | |
| const pwdInput = document.getElementById('build-password'); | |
| const parsedPassword = pwdInput ? pwdInput.value.trim() : ''; | |
| const qz = { | |
| id: BuilderCtrl.id, | |
| author: AppState.user.id, | |
| title: title, | |
| timeLimit: parseInt(document.getElementById('build-timer').value) || 0, | |
| password: parsedPassword, // Store the password in DB | |
| randQ: document.getElementById('build-rand-q').checked, | |
| randO: document.getElementById('build-rand-o').checked, | |
| questions: BuilderCtrl.questions | |
| }; | |
| if (BuilderCtrl.existingIdx >= 0) { | |
| DB.data.quizzes[BuilderCtrl.existingIdx] = qz; | |
| } else { | |
| DB.data.quizzes.push(qz); | |
| } | |
| DB.save(); | |
| UI.toast(`Assessment securely saved to bank.`, "success"); | |
| Router.navigate('teacher-bank', document.querySelector('#nav-teacher .nav-item:nth-child(2)')); | |
| }, | |
| processBulk: () => { | |
| SystemLogger.info("Initiating Bulk Data Processing..."); | |
| const textArea = document.getElementById('bulk-import-text'); | |
| if (!textArea) return; | |
| const text = textArea.value.trim(); | |
| if (!text) { | |
| return typeof UI !== 'undefined' && UI.toast ? UI.toast("Please enter text to import.", "error") : alert("No text entered."); | |
| } | |
| let addedCount = 0; | |
| if (text.includes(':') && !text.includes('\n\n')) { | |
| SystemLogger.info("Detected COLON format parsing mode."); | |
| const lines = text.split('\n').map(l => l.trim()).filter(l => l); | |
| lines.forEach(line => { | |
| if (line.includes(':')) { | |
| const parts = line.split(':').map(p => p.trim()).filter(p => p); | |
| if (parts.length >= 2) { | |
| const questionText = parts[0]; | |
| const options = []; | |
| for (let i = 1; i < parts.length; i++) { | |
| options.push({ text: parts[i], correct: (i === 1) }); | |
| } | |
| if (!BuilderCtrl.questions) BuilderCtrl.questions = []; | |
| BuilderCtrl.questions.push({ | |
| id: Utils.id(), type: 'mc', pts: 1, text: questionText, options: options | |
| }); | |
| addedCount++; | |
| } | |
| } | |
| }); | |
| } else { | |
| SystemLogger.info("Detected PARAGRAPH format parsing mode."); | |
| const blocks = text.split(/\n\s*\n/); | |
| blocks.forEach(block => { | |
| const lines = block.split('\n').map(l => l.trim()).filter(l => l); | |
| if (lines.length < 2) return; | |
| const questionText = lines[0]; | |
| const options = []; | |
| for (let i = 1; i < lines.length; i++) { | |
| const isCorrect = lines[i].startsWith('*'); | |
| const optionText = isCorrect ? lines[i].substring(1).trim() : lines[i]; | |
| options.push({ text: optionText, correct: isCorrect }); | |
| } | |
| if (!options.some(o => o.correct) && options.length > 0) { | |
| options[0].correct = true; | |
| } | |
| if (!BuilderCtrl.questions) BuilderCtrl.questions = []; | |
| BuilderCtrl.questions.push({ | |
| id: Utils.id(), type: 'mc', pts: 1, text: questionText, options: options | |
| }); | |
| addedCount++; | |
| }); | |
| } | |
| if (addedCount > 0) { | |
| UI.toast(`Successfully processed and imported ${addedCount} questions!`, "success"); | |
| textArea.value = ''; | |
| BuilderCtrl.switchTab('editor'); | |
| } else { | |
| UI.toast("Could not detect any valid questions. Please verify your formatting.", "error"); | |
| } | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * TEACHER GRADING CONTROLLER | |
| * ============================================================================ | |
| * Description: | |
| * Exposes grading mechanisms and logic to teachers reviewing assessments. | |
| * ============================================================================ | |
| */ | |
| const TeacherCtrl = { | |
| renderGrading: () => { | |
| DB.load(); | |
| const list = document.getElementById('teacher-subs-list'); | |
| list.innerHTML = ''; | |
| const tQs = DB.data.quizzes.filter(q => q.author === AppState.user.id).map(q => q.id); | |
| const subs = DB.data.submissions.filter(s => tQs.includes(s.quizId)).sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp)); | |
| if (subs.length === 0) { | |
| return list.innerHTML = '<div class="card" style="text-align:center;">No student submissions available to review.</div>'; | |
| } | |
| subs.forEach(sub => { | |
| const qz = DB.data.quizzes.find(q => q.id === sub.quizId); | |
| if (!qz) return; | |
| const el = document.createElement('div'); | |
| el.className = 'list-item'; | |
| el.innerHTML = ` | |
| <div> | |
| <h3 style="margin:0; color:var(--primary);">${qz.title}</h3> | |
| <p style="margin:0; font-size:0.95rem; font-weight: 600;" class="mt-2">Student: ${sub.studentName}</p> | |
| <p style="margin:0; font-size:0.85rem; color:var(--text-muted);">${new Date(sub.timestamp).toLocaleString()}</p> | |
| </div> | |
| <div class="flex-row"> | |
| ${sub.status === 'pending' ? `<span class="badge badge-warning">Needs Review</span>` : `<span class="badge ${Utils.getPctColor(sub.earnedPts, sub.totalPts)}">${sub.earnedPts}/${sub.totalPts} Pts</span>`} | |
| <button class="btn btn-primary" onclick="TeacherCtrl.openGrader('${sub.id}')">Review Submission</button> | |
| </div>`; | |
| list.appendChild(el); | |
| }); | |
| }, | |
| openGrader: (subId) => { | |
| const sub = DB.data.submissions.find(s => s.id === subId); | |
| const qz = DB.data.quizzes.find(q => q.id === sub.quizId); | |
| document.getElementById('grade-modal-student').innerText = `${sub.studentName} — ${qz.title}`; | |
| const cont = document.getElementById('grade-questions-container'); | |
| cont.innerHTML = ''; | |
| if (!sub.feedback) sub.feedback = {}; | |
| qz.questions.forEach((q, idx) => { | |
| const sAns = sub.answers[q.id] || 'No Response'; | |
| const sPts = sub.scores[q.id] || 0; | |
| const sFeed = sub.feedback[q.id] || ''; | |
| let ansHtml = ''; | |
| if (q.type === 'mc') { | |
| const isCorr = (sAns === q.options.findIndex(o => o.correct)); | |
| ansHtml = ` | |
| <p style="font-size:1.1rem;"><strong>Selection:</strong> <span style="color:${isCorr ? 'var(--secondary)' : 'var(--danger)'}; font-weight:600;">${q.options[sAns] ? q.options[sAns].text : 'N/A'}</span></p> | |
| <p style="font-size:0.85rem; color:var(--text-muted);">Auto-graded: ${sPts}/${q.pts} Pts</p>`; | |
| } else { | |
| ansHtml = ` | |
| <div style="background:var(--bg-offset); padding:1rem; border-radius:var(--radius-md); border:1px solid var(--border); margin-bottom:1rem;"> | |
| <strong>Response:</strong><p style="margin-top:0.5rem; white-space:pre-wrap; font-size:1.1rem;">${sAns}</p> | |
| </div>`; | |
| } | |
| const card = document.createElement('div'); | |
| card.style.cssText = "margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px dashed var(--border);"; | |
| card.innerHTML = ` | |
| <h4 style="margin-bottom: 1rem; font-size:1.15rem;">Q${idx+1}: ${q.text} <span class="badge badge-primary" style="float:right;">Max: ${q.pts}</span></h4> | |
| ${ansHtml} | |
| <div class="flex-row" style="margin-top: 1rem; align-items:flex-start;"> | |
| <div class="input-group" style="flex: 1; margin:0;"> | |
| <label>Awarded Points</label> | |
| <input type="number" id="grade-pts-${q.id}" class="form-control" value="${sPts}" min="0" max="${q.pts}" ${q.type==='mc' ? 'disabled' : ''}> | |
| </div> | |
| <div class="input-group" style="flex: 3; margin:0;"> | |
| <label>Instructor Feedback (Optional)</label> | |
| <textarea id="grade-feed-${q.id}" class="form-control" style="min-height:60px;" placeholder="Great job! or Needs more detail...">${sFeed}</textarea> | |
| </div> | |
| </div> | |
| `; | |
| cont.appendChild(card); | |
| }); | |
| const saveBtn = document.getElementById('grade-save-btn'); | |
| const newSaveBtn = saveBtn.cloneNode(true); | |
| saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); | |
| newSaveBtn.onclick = () => TeacherCtrl.saveGrades(sub.id, qz); | |
| document.getElementById('grade-modal').classList.add('active'); | |
| }, | |
| saveGrades: (subId, qz) => { | |
| const sub = DB.data.submissions.find(s => s.id === subId); | |
| let newEarned = 0; | |
| qz.questions.forEach(q => { | |
| const ptInput = document.getElementById(`grade-pts-${q.id}`); | |
| const feedInput = document.getElementById(`grade-feed-${q.id}`); | |
| if (ptInput) { | |
| const pts = Math.min(Math.max(parseInt(ptInput.value) || 0, 0), q.pts); | |
| sub.scores[q.id] = pts; | |
| newEarned += pts; | |
| } | |
| if (feedInput) { | |
| sub.feedback[q.id] = feedInput.value.trim(); | |
| } | |
| }); | |
| sub.earnedPts = newEarned; | |
| sub.status = 'graded'; | |
| DB.save(); | |
| document.getElementById('grade-modal').classList.remove('active'); | |
| TeacherCtrl.renderGrading(); | |
| UI.toast("Grades and feedback permanently saved.", "success"); | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * LIVE ASSESSMENT ENGINE | |
| * ============================================================================ | |
| * Description: | |
| * Resolves taking, pausing, previewing, saving, and verifying constraints | |
| * (like passwords and time limits) for an active session. | |
| * ============================================================================ | |
| */ | |
| const TestCtrl = { | |
| liveSession: null, | |
| timerInterval: null, | |
| pendingQuizId: null, | |
| pendingClassName: null, | |
| /** | |
| * ADDED: Teacher Preview Assessment Logic | |
| * Bypasses DB writes and loads a mocked session directly into the viewer. | |
| */ | |
| previewAssessment: (quizId) => { | |
| SystemLogger.info(`Initiating Teacher Preview for Assessment: ${quizId}`); | |
| DB.load(); | |
| const quiz = DB.data.quizzes.find(q => q.id === quizId); | |
| if (!quiz) return UI.toast("Assessment not found in local database.", "error"); | |
| document.querySelectorAll('.page').forEach(p => p.classList.add('hidden')); | |
| document.getElementById('page-take-test').classList.remove('hidden'); | |
| document.getElementById('live-test-title').innerText = "PREVIEW: " + quiz.title; | |
| let renderQs = Utils.deepClone(quiz.questions); | |
| // Mirror actual randomized logic to ensure accuracy during preview | |
| if (quiz.randQ) renderQs = Utils.shuffle(renderQs); | |
| renderQs.forEach(q => { | |
| if (q.type === 'mc') { | |
| q.options.forEach((o, i) => o._oIdx = i); | |
| if (quiz.randO) q.options = Utils.shuffle(q.options); | |
| } | |
| }); | |
| TestCtrl.liveSession = { | |
| id: 'preview_' + Utils.id(), | |
| quizId: quiz.id, | |
| studentId: AppState.user.id, | |
| renderMap: renderQs, | |
| answers: {}, | |
| timeLimit: quiz.timeLimit, | |
| timeRemaining: quiz.timeLimit * 60, | |
| isPreview: true // Marks session as ghost | |
| }; | |
| TestCtrl.render(); | |
| const tBadge = document.getElementById('live-timer'); | |
| if (TestCtrl.liveSession.timeLimit > 0) { | |
| tBadge.classList.remove('hidden'); | |
| TestCtrl.updateTimerUI(); | |
| TestCtrl.timerInterval = setInterval(() => { | |
| TestCtrl.liveSession.timeRemaining--; | |
| TestCtrl.updateTimerUI(); | |
| if (TestCtrl.liveSession.timeRemaining <= 0) { | |
| clearInterval(TestCtrl.timerInterval); | |
| UI.toast("Preview Time Expired!", "warning"); | |
| TestCtrl.submit(true); | |
| } | |
| }, 1000); | |
| } else { | |
| tBadge.classList.add('hidden'); | |
| } | |
| }, | |
| /** | |
| * ADDED: Pre-test sequence now natively enforces Passwords. | |
| */ | |
| initPreTest: (quizId, className) => { | |
| TestCtrl.pendingQuizId = quizId; | |
| TestCtrl.pendingClassName = className; | |
| DB.load(); | |
| const qz = DB.data.quizzes.find(q => q.id === quizId); | |
| // Helper to initialize view | |
| const openPreTestView = () => { | |
| document.querySelectorAll('.page').forEach(p => p.classList.add('hidden')); | |
| document.getElementById('page-pre-test').classList.remove('hidden'); | |
| document.getElementById('pre-title').innerText = qz.title; | |
| document.getElementById('pre-class').innerText = className; | |
| document.getElementById('pre-time').innerText = qz.timeLimit > 0 ? `${qz.timeLimit} Minutes` : "Unlimited"; | |
| }; | |
| // Enforce Password check | |
| if (qz.password && qz.password.trim() !== "") { | |
| UI.openModal( | |
| "Assessment Password Required", | |
| "Your instructor has secured this assessment. Enter the password below:", | |
| (pwd) => { | |
| if (pwd !== qz.password) { | |
| return UI.toast("Incorrect password. Access denied.", "error"); | |
| } | |
| openPreTestView(); | |
| }, | |
| "Enter Password..." | |
| ); | |
| } else { | |
| openPreTestView(); | |
| } | |
| }, | |
| startSession: () => { | |
| DB.load(); | |
| const quiz = DB.data.quizzes.find(q => q.id === TestCtrl.pendingQuizId); | |
| const existing = DB.data.sessions.find(s => s.quizId === quiz.id && s.studentId === AppState.user.id); | |
| document.querySelectorAll('.page').forEach(p => p.classList.add('hidden')); | |
| document.getElementById('page-take-test').classList.remove('hidden'); | |
| document.getElementById('live-test-title').innerText = quiz.title; | |
| if (!existing) { | |
| let renderQs = Utils.deepClone(quiz.questions); | |
| if (quiz.randQ) { | |
| renderQs = Utils.shuffle(renderQs); | |
| } | |
| renderQs.forEach(q => { | |
| if (q.type === 'mc') { | |
| q.options.forEach((o, i) => o._oIdx = i); | |
| if (quiz.randO) q.options = Utils.shuffle(q.options); | |
| } | |
| }); | |
| TestCtrl.liveSession = { | |
| id: Utils.id(), | |
| quizId: quiz.id, | |
| studentId: AppState.user.id, | |
| renderMap: renderQs, | |
| answers: {}, | |
| timeLimit: quiz.timeLimit, | |
| timeRemaining: quiz.timeLimit * 60 | |
| }; | |
| } else { | |
| TestCtrl.liveSession = existing; | |
| } | |
| TestCtrl.render(); | |
| const tBadge = document.getElementById('live-timer'); | |
| if (TestCtrl.liveSession.timeLimit > 0) { | |
| tBadge.classList.remove('hidden'); | |
| TestCtrl.updateTimerUI(); | |
| TestCtrl.timerInterval = setInterval(() => { | |
| TestCtrl.liveSession.timeRemaining--; | |
| TestCtrl.updateTimerUI(); | |
| if (TestCtrl.liveSession.timeRemaining <= 0) { | |
| clearInterval(TestCtrl.timerInterval); | |
| UI.toast("Time expired! Submitting assessment.", "error"); | |
| TestCtrl.submit(true); | |
| } | |
| }, 1000); | |
| } else { | |
| tBadge.classList.add('hidden'); | |
| } | |
| }, | |
| updateTimerUI: () => { | |
| const tr = TestCtrl.liveSession.timeRemaining; | |
| const m = Math.floor(tr / 60).toString().padStart(2, '0'); | |
| const s = (tr % 60).toString().padStart(2, '0'); | |
| document.querySelector('#live-timer span').innerText = `⏳ ${m}:${s}`; | |
| }, | |
| render: () => { | |
| const c = document.getElementById('live-test-questions'); | |
| c.innerHTML = ''; | |
| const qs = TestCtrl.liveSession.renderMap; | |
| document.getElementById('live-test-progress').innerText = `${qs.length} Questions`; | |
| qs.forEach((q, idx) => { | |
| const card = document.createElement('div'); | |
| card.className = 'question-card'; | |
| let html = ` | |
| <div class="flex-between" style="margin-bottom:1.5rem;"> | |
| <h3 style="margin:0; font-size:1.2rem;">${idx+1}. ${q.text}</h3> | |
| <span class="badge badge-primary">${q.pts} Pts</span> | |
| </div> | |
| `; | |
| const savedAns = TestCtrl.liveSession.answers[q.id]; | |
| if (q.type === 'mc') { | |
| q.options.forEach((opt) => { | |
| const isCh = savedAns === opt._oIdx; | |
| html += ` | |
| <label class="mc-option ${isCh ? 'selected' : ''}"> | |
| <input type="radio" name="ans_${q.id}" value="${opt._oIdx}" ${isCh ? 'checked' : ''} onchange="TestCtrl.record('${q.id}', ${opt._oIdx}, this)"> | |
| <span style="font-weight:600; font-size:1.05rem;">${opt.text}</span> | |
| </label>`; | |
| }); | |
| } else { | |
| html += `<textarea class="form-control" style="font-size:1.1rem;" placeholder="Write your response here..." oninput="TestCtrl.record('${q.id}', this.value, null)">${savedAns || ''}</textarea>`; | |
| } | |
| card.innerHTML = html; | |
| c.appendChild(card); | |
| }); | |
| }, | |
| record: (qId, val, radioEl) => { | |
| TestCtrl.liveSession.answers[qId] = val; | |
| if (radioEl) { | |
| const p = radioEl.closest('.question-card'); | |
| p.querySelectorAll('.mc-option').forEach(el => el.classList.remove('selected')); | |
| radioEl.closest('.mc-option').classList.add('selected'); | |
| } | |
| // Do not persist previews to DB | |
| if (TestCtrl.liveSession.isPreview) return; | |
| const exist = DB.data.sessions.findIndex(s => s.id === TestCtrl.liveSession.id); | |
| if (exist >= 0) { | |
| DB.data.sessions[exist] = TestCtrl.liveSession; | |
| } else { | |
| DB.data.sessions.push(TestCtrl.liveSession); | |
| } | |
| DB.save(); | |
| }, | |
| pause: () => { | |
| clearInterval(TestCtrl.timerInterval); | |
| if (TestCtrl.liveSession.isPreview) { | |
| UI.toast("Preview closed.", "warning"); | |
| TestCtrl.liveSession = null; | |
| Router.navigate('teacher-bank', document.querySelector('#nav-teacher .nav-item:nth-child(2)')); | |
| return; | |
| } | |
| UI.toast("Progress securely saved.", "success"); | |
| TestCtrl.liveSession = null; | |
| Router.navigate('student-dash', document.querySelector('#nav-student .nav-item')); | |
| }, | |
| submit: (auto = false) => { | |
| const finalize = () => { | |
| clearInterval(TestCtrl.timerInterval); | |
| const qz = DB.data.quizzes.find(q => q.id === TestCtrl.liveSession.quizId); | |
| let totalPts = 0; | |
| let earnedPts = 0; | |
| let needsManual = false; | |
| const scores = {}; | |
| const feedback = {}; | |
| qz.questions.forEach(q => { | |
| totalPts += q.pts; | |
| const ans = TestCtrl.liveSession.answers[q.id]; | |
| let pts = 0; | |
| if (q.type === 'mc') { | |
| const corrOrigIdx = q.options.findIndex(o => o.correct); | |
| if (ans === corrOrigIdx) { | |
| pts = q.pts; | |
| } | |
| } else { | |
| needsManual = true; | |
| } | |
| scores[q.id] = pts; | |
| earnedPts += pts; | |
| }); | |
| // Intercept and branch logic if this was a teacher preview | |
| if (TestCtrl.liveSession.isPreview) { | |
| TestCtrl.liveSession = null; | |
| UI.toast(`Preview Complete! Theoretical Score: ${earnedPts}/${totalPts}`, "success"); | |
| Router.navigate('teacher-bank', document.querySelector('#nav-teacher .nav-item:nth-child(2)')); | |
| return; | |
| } | |
| // Standard Student Submission | |
| DB.data.submissions.push({ | |
| id: Utils.id(), | |
| quizId: qz.id, | |
| studentId: AppState.user.id, | |
| studentName: AppState.user.name, | |
| answers: TestCtrl.liveSession.answers, | |
| scores: scores, | |
| feedback: feedback, | |
| totalPts: totalPts, | |
| earnedPts: earnedPts, | |
| status: needsManual ? 'pending' : 'graded', | |
| timestamp: new Date().toISOString() | |
| }); | |
| DB.data.sessions = DB.data.sessions.filter(s => s.id !== TestCtrl.liveSession.id); | |
| DB.save(); | |
| TestCtrl.liveSession = null; | |
| ConfettiEngine.fire(); | |
| UI.toast(needsManual ? "Submitted! Waiting for instructor grading." : `Complete! Initial Score: ${earnedPts}/${totalPts}`, "success"); | |
| Router.navigate('student-history', document.querySelector('#nav-student .nav-item:nth-child(2)')); | |
| }; | |
| if (auto) { | |
| finalize(); | |
| } else { | |
| UI.openModal("Submit Assessment", "Are you sure you want to finalize your answers?", finalize); | |
| } | |
| } | |
| }; | |
| /** | |
| * ============================================================================ | |
| * APPLICATION BOOTSTRAP | |
| * ============================================================================ | |
| * Initiates the core routing protocol. | |
| * Wait until DOM is fully loaded before executing. | |
| * ============================================================================ | |
| */ | |
| window.onload = Router.init; | |
| </script> | |
| </body> | |
| </html> |