Upload 11 files
Browse files- static/css/style.css +277 -0
- static/icons/logo.svg +26 -0
- static/js/api.js +147 -0
- static/js/app.js +717 -0
- static/js/audio.js +218 -0
- static/js/auth.js +135 -0
- static/js/profiles.js +99 -0
- static/js/session.js +314 -0
- static/js/state.js +30 -0
- static/js/ui.js +242 -0
- templates/index.html +456 -0
static/css/style.css
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Public+Sans:ital,wght@0,100..900;1,100..900&display=swap');
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
/* Colors */
|
| 5 |
+
--color-paper: #F6F3EE;
|
| 6 |
+
--color-ink: #262521;
|
| 7 |
+
--color-moss: #4B6358;
|
| 8 |
+
--color-clay: #9C7A5B;
|
| 9 |
+
--color-signal: #B4483C;
|
| 10 |
+
|
| 11 |
+
/* Typography */
|
| 12 |
+
--font-display: 'Fraunces', serif;
|
| 13 |
+
--font-body: 'Public Sans', sans-serif;
|
| 14 |
+
--font-utility: 'IBM Plex Mono', monospace;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
body {
|
| 18 |
+
background-color: var(--color-paper);
|
| 19 |
+
color: var(--color-ink);
|
| 20 |
+
font-family: var(--font-body);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* Base resets to clear generic Tailwind */
|
| 24 |
+
h1,
|
| 25 |
+
h2,
|
| 26 |
+
h3,
|
| 27 |
+
h4,
|
| 28 |
+
h5,
|
| 29 |
+
h6 {
|
| 30 |
+
font-family: var(--font-display);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.font-display {
|
| 34 |
+
font-family: var(--font-display);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.font-utility {
|
| 38 |
+
font-family: var(--font-utility);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.font-body {
|
| 42 |
+
font-family: var(--font-body);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/* Remove previous animations and float effects */
|
| 46 |
+
.fade-in {
|
| 47 |
+
animation: fadeIn 0.15s ease-out;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
@keyframes fadeIn {
|
| 51 |
+
from {
|
| 52 |
+
opacity: 0;
|
| 53 |
+
transform: translateY(4px);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
to {
|
| 57 |
+
opacity: 1;
|
| 58 |
+
transform: translateY(0);
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
@media (prefers-reduced-motion: reduce) {
|
| 63 |
+
.fade-in {
|
| 64 |
+
animation: none;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.custom-scrollbar::-webkit-scrollbar {
|
| 69 |
+
width: 6px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.custom-scrollbar::-webkit-scrollbar-track {
|
| 73 |
+
background: var(--color-paper);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
| 77 |
+
background: var(--color-clay);
|
| 78 |
+
border-radius: 3px;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
| 82 |
+
background: var(--color-moss);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* Loading Screen */
|
| 86 |
+
#loadingScreen {
|
| 87 |
+
position: fixed;
|
| 88 |
+
top: 0;
|
| 89 |
+
left: 0;
|
| 90 |
+
width: 100%;
|
| 91 |
+
height: 100%;
|
| 92 |
+
background: var(--color-paper);
|
| 93 |
+
display: flex;
|
| 94 |
+
flex-direction: column;
|
| 95 |
+
align-items: center;
|
| 96 |
+
justify-content: center;
|
| 97 |
+
z-index: 9999;
|
| 98 |
+
transition: opacity 0.5s ease-out;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
#loadingScreen.hidden {
|
| 102 |
+
opacity: 0;
|
| 103 |
+
pointer-events: none;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.logo-text {
|
| 107 |
+
font-family: var(--font-display);
|
| 108 |
+
font-size: clamp(3rem, 10vw, 5rem);
|
| 109 |
+
font-weight: 400;
|
| 110 |
+
color: var(--color-ink);
|
| 111 |
+
margin-bottom: 1rem;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.loading-dots {
|
| 115 |
+
display: flex;
|
| 116 |
+
gap: 0.5rem;
|
| 117 |
+
margin-top: 1rem;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.loading-dots span {
|
| 121 |
+
width: 8px;
|
| 122 |
+
height: 8px;
|
| 123 |
+
border-radius: 50%;
|
| 124 |
+
background: var(--color-ink);
|
| 125 |
+
animation: breathe 2s infinite ease-in-out both;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
@keyframes breathe {
|
| 129 |
+
|
| 130 |
+
0%,
|
| 131 |
+
100% {
|
| 132 |
+
transform: scale(0.8);
|
| 133 |
+
opacity: 0.3;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
50% {
|
| 137 |
+
transform: scale(1.2);
|
| 138 |
+
opacity: 0.8;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/* Panels */
|
| 143 |
+
.panel {
|
| 144 |
+
background: var(--color-paper);
|
| 145 |
+
border: 1px solid var(--color-ink);
|
| 146 |
+
box-shadow: none;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/* Chat Input */
|
| 150 |
+
.chat-input-container {
|
| 151 |
+
display: flex;
|
| 152 |
+
gap: 0.5rem;
|
| 153 |
+
align-items: center;
|
| 154 |
+
background: transparent;
|
| 155 |
+
padding: 0.75rem;
|
| 156 |
+
border: 1px solid var(--color-ink);
|
| 157 |
+
border-radius: 0;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.chat-input {
|
| 161 |
+
flex: 1;
|
| 162 |
+
background: transparent;
|
| 163 |
+
border: none;
|
| 164 |
+
outline: none;
|
| 165 |
+
color: var(--color-ink);
|
| 166 |
+
font-family: var(--font-body);
|
| 167 |
+
font-size: 0.95rem;
|
| 168 |
+
padding: 0.5rem 1rem;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.chat-input::placeholder {
|
| 172 |
+
color: var(--color-clay);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* Buttons */
|
| 176 |
+
.btn-primary {
|
| 177 |
+
background: var(--color-moss);
|
| 178 |
+
color: var(--color-paper);
|
| 179 |
+
border: 1px solid var(--color-moss);
|
| 180 |
+
padding: 0.5rem 1rem;
|
| 181 |
+
font-family: var(--font-body);
|
| 182 |
+
cursor: pointer;
|
| 183 |
+
transition: background 0.2s ease, color 0.2s ease;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.btn-primary:hover {
|
| 187 |
+
background: transparent;
|
| 188 |
+
color: var(--color-moss);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.btn-primary:disabled {
|
| 192 |
+
opacity: 0.5;
|
| 193 |
+
cursor: not-allowed;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.btn-toggle {
|
| 197 |
+
background: transparent;
|
| 198 |
+
color: var(--color-ink);
|
| 199 |
+
border: none;
|
| 200 |
+
border-bottom: 2px solid transparent;
|
| 201 |
+
padding: 0.5rem;
|
| 202 |
+
font-family: var(--font-body);
|
| 203 |
+
cursor: pointer;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.btn-toggle.active {
|
| 207 |
+
border-bottom-color: var(--color-moss);
|
| 208 |
+
font-weight: 600;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* Chat Messages */
|
| 212 |
+
.message-user {
|
| 213 |
+
text-align: left;
|
| 214 |
+
font-family: var(--font-body);
|
| 215 |
+
color: var(--color-ink);
|
| 216 |
+
padding: 1rem 0;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.message-assistant {
|
| 220 |
+
text-align: left;
|
| 221 |
+
font-family: var(--font-body);
|
| 222 |
+
color: var(--color-ink);
|
| 223 |
+
padding: 1rem 0;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.assistant-label {
|
| 227 |
+
font-family: var(--font-utility);
|
| 228 |
+
color: var(--color-clay);
|
| 229 |
+
font-size: 0.8rem;
|
| 230 |
+
text-transform: uppercase;
|
| 231 |
+
letter-spacing: 0.05em;
|
| 232 |
+
display: block;
|
| 233 |
+
margin-bottom: 0.5rem;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.safety-banner {
|
| 237 |
+
background: var(--color-paper);
|
| 238 |
+
border: 1px solid var(--color-signal);
|
| 239 |
+
color: var(--color-ink);
|
| 240 |
+
padding: 1rem;
|
| 241 |
+
margin: 1rem 0;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.safety-banner-title {
|
| 245 |
+
color: var(--color-signal);
|
| 246 |
+
font-family: var(--font-display);
|
| 247 |
+
margin-bottom: 0.5rem;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.typing-indicator span {
|
| 251 |
+
display: inline-block;
|
| 252 |
+
width: 6px;
|
| 253 |
+
height: 6px;
|
| 254 |
+
border-radius: 50%;
|
| 255 |
+
background: var(--color-ink);
|
| 256 |
+
animation: breathe 1.4s infinite;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.typing-indicator span:nth-child(2) {
|
| 260 |
+
animation-delay: 0.2s;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.typing-indicator span:nth-child(3) {
|
| 264 |
+
animation-delay: 0.4s;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.dashboard-label {
|
| 268 |
+
font-family: var(--font-utility);
|
| 269 |
+
color: var(--color-clay);
|
| 270 |
+
font-size: 0.85rem;
|
| 271 |
+
text-transform: uppercase;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.dashboard-value {
|
| 275 |
+
font-family: var(--font-body);
|
| 276 |
+
color: var(--color-ink);
|
| 277 |
+
}
|
static/icons/logo.svg
ADDED
|
|
static/js/api.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const BACKEND_URL = "https://amogh1221-serinity.hf.space";
|
| 2 |
+
|
| 3 |
+
// Helper to get token
|
| 4 |
+
export function getToken() {
|
| 5 |
+
return localStorage.getItem('serinity_token');
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
// Global fetch wrapper for auth
|
| 9 |
+
export async function authFetch(url, options = {}) {
|
| 10 |
+
const token = getToken();
|
| 11 |
+
const headers = { ...options.headers };
|
| 12 |
+
|
| 13 |
+
if (token) {
|
| 14 |
+
headers['Authorization'] = `Bearer ${token}`;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Don't override FormData headers
|
| 18 |
+
if (!(options.body instanceof FormData) && !headers['Content-Type']) {
|
| 19 |
+
headers['Content-Type'] = 'application/json';
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const res = await fetch(url, { ...options, headers });
|
| 23 |
+
|
| 24 |
+
// Handle unauthorized globaly
|
| 25 |
+
if (res.status === 401) {
|
| 26 |
+
localStorage.removeItem('serinity_token');
|
| 27 |
+
window.dispatchEvent(new Event('unauthorized'));
|
| 28 |
+
throw new Error('Unauthorized');
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return res;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Fetches the list of patients associated with the logged-in user.
|
| 36 |
+
* @returns {Promise<Array>} List of patient objects.
|
| 37 |
+
*/
|
| 38 |
+
export async function fetchPatientsList() {
|
| 39 |
+
const res = await authFetch(`${BACKEND_URL}/patients`, { cache: "no-store" });
|
| 40 |
+
return res.json();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Loads the dashboard data for a specific patient.
|
| 45 |
+
* @param {string} patientId
|
| 46 |
+
* @returns {Promise<Object>} Dashboard overview data.
|
| 47 |
+
*/
|
| 48 |
+
export async function loadDashboard(patientId) {
|
| 49 |
+
const res = await authFetch(`${BACKEND_URL}/patients/${patientId}/dashboard`, { cache: "no-store" });
|
| 50 |
+
if (!res.ok) throw new Error("Dashboard load failed");
|
| 51 |
+
return res.json();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Resets a patient's historical profile and sessions.
|
| 56 |
+
* @param {string} patientId
|
| 57 |
+
*/
|
| 58 |
+
export async function resetPatientProfile(patientId) {
|
| 59 |
+
return authFetch(`${BACKEND_URL}/patients/${patientId}/reset`, { method: "POST" });
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Deletes a patient profile completely.
|
| 64 |
+
* @param {string} patientId
|
| 65 |
+
*/
|
| 66 |
+
export async function deletePatientProfile(patientId) {
|
| 67 |
+
return authFetch(`${BACKEND_URL}/patients/${patientId}`, { method: "DELETE" });
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Creates a new patient profile linked to the user.
|
| 72 |
+
* @param {Object} data
|
| 73 |
+
* @returns {Promise<Object>} The created patient data.
|
| 74 |
+
*/
|
| 75 |
+
export async function createPatientProfile(data) {
|
| 76 |
+
const res = await authFetch(`${BACKEND_URL}/patients/create`, {
|
| 77 |
+
method: "POST",
|
| 78 |
+
body: JSON.stringify(data)
|
| 79 |
+
});
|
| 80 |
+
return res.json();
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export async function pingHealth() {
|
| 84 |
+
// health doesn't need auth, but authFetch is safe to use
|
| 85 |
+
return fetch(`${BACKEND_URL}/health`);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export async function startSessionReq(patientId) {
|
| 89 |
+
const res = await authFetch(`${BACKEND_URL}/start`, {
|
| 90 |
+
method: "POST",
|
| 91 |
+
body: JSON.stringify({ patient_id: patientId })
|
| 92 |
+
});
|
| 93 |
+
return res.json();
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export async function sendChatText(sessionId, patientId, message, emotion) {
|
| 97 |
+
const res = await authFetch(`${BACKEND_URL}/chat_text`, {
|
| 98 |
+
method: "POST",
|
| 99 |
+
body: JSON.stringify({
|
| 100 |
+
message: message,
|
| 101 |
+
session_id: sessionId,
|
| 102 |
+
patient_id: patientId,
|
| 103 |
+
emotion: emotion
|
| 104 |
+
})
|
| 105 |
+
});
|
| 106 |
+
if (!res.ok) {
|
| 107 |
+
let errorDetail = `HTTP Error ${res.status}`;
|
| 108 |
+
try {
|
| 109 |
+
const data = await res.json();
|
| 110 |
+
errorDetail = data.detail || errorDetail;
|
| 111 |
+
} catch(e) {}
|
| 112 |
+
throw new Error(errorDetail);
|
| 113 |
+
}
|
| 114 |
+
return res.json();
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
export async function transcribeAudio(audioBlob) {
|
| 118 |
+
const formData = new FormData();
|
| 119 |
+
formData.append('audio', audioBlob, 'recording.webm');
|
| 120 |
+
|
| 121 |
+
// Custom headers because authFetch handles FormData correctly if Content-Type is omitted
|
| 122 |
+
const res = await authFetch(`${BACKEND_URL}/transcribe`, {
|
| 123 |
+
method: "POST",
|
| 124 |
+
body: formData
|
| 125 |
+
});
|
| 126 |
+
return res.json();
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
export async function endSessionReq(sessionId, patientId) {
|
| 130 |
+
return authFetch(`${BACKEND_URL}/end_session`, {
|
| 131 |
+
method: "POST",
|
| 132 |
+
body: JSON.stringify({
|
| 133 |
+
session_id: sessionId,
|
| 134 |
+
patient_id: patientId
|
| 135 |
+
})
|
| 136 |
+
});
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
export async function getActiveSession(patientId) {
|
| 140 |
+
const res = await authFetch(`${BACKEND_URL}/patients/${patientId}/active_session`, { cache: "no-store" });
|
| 141 |
+
return res.json();
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
export async function getSessionMessages(sessionId) {
|
| 145 |
+
const res = await authFetch(`${BACKEND_URL}/sessions/${sessionId}/messages`, { cache: "no-store" });
|
| 146 |
+
return res.json();
|
| 147 |
+
}
|
static/js/app.js
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { state } from './state.js';
|
| 2 |
+
import * as api from './api.js';
|
| 3 |
+
import * as ui from './ui.js';
|
| 4 |
+
import * as audio from './audio.js';
|
| 5 |
+
import * as profiles from './profiles.js';
|
| 6 |
+
import * as session from './session.js';
|
| 7 |
+
import * as auth from './auth.js';
|
| 8 |
+
|
| 9 |
+
// DOM Elements specific to top-level app logic
|
| 10 |
+
const startButton = document.getElementById("startButton");
|
| 11 |
+
const voiceModeBtn = document.getElementById("voiceModeBtn");
|
| 12 |
+
const textModeBtn = document.getElementById("textModeBtn");
|
| 13 |
+
const voiceInput = document.getElementById("voiceInput");
|
| 14 |
+
const textInput = document.getElementById("textInput");
|
| 15 |
+
const chatInput = document.getElementById("chatInput");
|
| 16 |
+
const sendBtn = document.getElementById("sendBtn");
|
| 17 |
+
const micBtn = document.getElementById("micBtn");
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Binds all global events to the DOM (button clicks, form submits, keypresses).
|
| 21 |
+
*/
|
| 22 |
+
function bindEvents() {
|
| 23 |
+
// Handle browser back/forward buttons
|
| 24 |
+
window.addEventListener("popstate", (event) => {
|
| 25 |
+
if (event.state && event.state.screen) {
|
| 26 |
+
ui.switchScreen(event.state.screen, false);
|
| 27 |
+
} else {
|
| 28 |
+
const path = window.location.pathname;
|
| 29 |
+
if (path === "/dashboard" || path === "/session") {
|
| 30 |
+
if (!state.patientId) {
|
| 31 |
+
profiles.fetchPatients();
|
| 32 |
+
} else {
|
| 33 |
+
ui.switchScreen(path === "/dashboard" ? "dashboardScreen" : "mainApp", false);
|
| 34 |
+
}
|
| 35 |
+
} else {
|
| 36 |
+
ui.switchScreen("loadingScreen", false);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
// Global Unauthorized Handler
|
| 42 |
+
window.addEventListener("unauthorized", () => {
|
| 43 |
+
ui.switchScreen("authScreen", false);
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
// --- Auth Events ---
|
| 47 |
+
const loginFormContainer = document.getElementById("loginFormContainer");
|
| 48 |
+
const signupFormContainer = document.getElementById("signupFormContainer");
|
| 49 |
+
const forgotPwdFormContainer = document.getElementById("forgotPwdFormContainer");
|
| 50 |
+
const otpStepContainer = document.getElementById("otpStepContainer");
|
| 51 |
+
const newPwdStepContainer = document.getElementById("newPwdStepContainer");
|
| 52 |
+
const signupOtpStepContainer = document.getElementById("signupOtpStepContainer");
|
| 53 |
+
|
| 54 |
+
// Track verified OTP state in memory (not persisted — refresh = back to login)
|
| 55 |
+
let _otpVerifiedEmail = null;
|
| 56 |
+
|
| 57 |
+
function showAuthPanel(panelId) {
|
| 58 |
+
// Only hide/show panels that exist
|
| 59 |
+
const panels = [loginFormContainer, signupFormContainer, forgotPwdFormContainer, otpStepContainer, newPwdStepContainer, signupOtpStepContainer];
|
| 60 |
+
panels.forEach(p => {
|
| 61 |
+
if(p) {
|
| 62 |
+
p.classList.add('hidden');
|
| 63 |
+
p.classList.remove('block');
|
| 64 |
+
}
|
| 65 |
+
});
|
| 66 |
+
const target = document.getElementById(panelId);
|
| 67 |
+
if (target) {
|
| 68 |
+
target.classList.remove('hidden');
|
| 69 |
+
target.classList.add('block');
|
| 70 |
+
} else {
|
| 71 |
+
console.error("Auth panel not found:", panelId);
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
document.getElementById("showSignupBtn")?.addEventListener("click", () => showAuthPanel("signupFormContainer"));
|
| 76 |
+
document.getElementById("showForgotPwdBtn")?.addEventListener("click", () => showAuthPanel("forgotPwdFormContainer"));
|
| 77 |
+
document.getElementById("backToLoginBtn1")?.addEventListener("click", () => showAuthPanel("loginFormContainer"));
|
| 78 |
+
document.getElementById("backToLoginBtn2")?.addEventListener("click", () => showAuthPanel("loginFormContainer"));
|
| 79 |
+
document.getElementById("backToForgotBtn")?.addEventListener("click", () => showAuthPanel("forgotPwdFormContainer"));
|
| 80 |
+
document.getElementById("backToSignupBtn")?.addEventListener("click", () => showAuthPanel("signupFormContainer"));
|
| 81 |
+
|
| 82 |
+
// Populate Nationality Dropdown
|
| 83 |
+
const countries = [
|
| 84 |
+
"India", "Afghanistan", "Albania", "Algeria", "Andorra", "Angola", "Antigua and Barbuda",
|
| 85 |
+
"Argentina", "Armenia", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh",
|
| 86 |
+
"Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bhutan", "Bolivia",
|
| 87 |
+
"Bosnia and Herzegovina", "Botswana", "Brazil", "Brunei", "Bulgaria", "Burkina Faso",
|
| 88 |
+
"Burundi", "Côte d'Ivoire", "Cabo Verde", "Cambodia", "Cameroon", "Canada", "Central African Republic",
|
| 89 |
+
"Chad", "Chile", "China", "Colombia", "Comoros", "Congo (Congo-Brazzaville)", "Costa Rica",
|
| 90 |
+
"Croatia", "Cuba", "Cyprus", "Czechia (Czech Republic)", "Democratic Republic of the Congo",
|
| 91 |
+
"Denmark", "Djibouti", "Dominica", "Dominican Republic", "Ecuador", "Egypt", "El Salvador",
|
| 92 |
+
"Equatorial Guinea", "Eritrea", "Estonia", "Eswatini", "Ethiopia", "Fiji", "Finland",
|
| 93 |
+
"France", "Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Greece", "Grenada",
|
| 94 |
+
"Guatemala", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Holy See", "Honduras",
|
| 95 |
+
"Hungary", "Iceland", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy", "Jamaica",
|
| 96 |
+
"Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Kuwait", "Kyrgyzstan", "Laos",
|
| 97 |
+
"Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg",
|
| 98 |
+
"Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Mauritania",
|
| 99 |
+
"Mauritius", "Mexico", "Micronesia", "Moldova", "Monaco", "Mongolia", "Montenegro", "Morocco",
|
| 100 |
+
"Mozambique", "Myanmar (formerly Burma)", "Namibia", "Nauru", "Nepal", "Netherlands", "New Zealand",
|
| 101 |
+
"Nicaragua", "Niger", "Nigeria", "North Korea", "North Macedonia", "Norway", "Oman", "Pakistan",
|
| 102 |
+
"Palau", "Palestine State", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Poland",
|
| 103 |
+
"Portugal", "Qatar", "Romania", "Russia", "Rwanda", "Saint Kitts and Nevis", "Saint Lucia",
|
| 104 |
+
"Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia",
|
| 105 |
+
"Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia",
|
| 106 |
+
"Solomon Islands", "Somalia", "South Africa", "South Korea", "South Sudan", "Spain", "Sri Lanka",
|
| 107 |
+
"Sudan", "Suriname", "Sweden", "Switzerland", "Syria", "Tajikistan", "Tanzania", "Thailand",
|
| 108 |
+
"Timor-Leste", "Togo", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan",
|
| 109 |
+
"Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "Uruguay",
|
| 110 |
+
"Uzbekistan", "Vanuatu", "Venezuela", "Vietnam", "Yemen", "Zambia", "Zimbabwe"
|
| 111 |
+
];
|
| 112 |
+
|
| 113 |
+
const signupNationality = document.getElementById("signupNationality");
|
| 114 |
+
const nationalityDropdown = document.getElementById("nationalityDropdown");
|
| 115 |
+
|
| 116 |
+
if (signupNationality && nationalityDropdown) {
|
| 117 |
+
function renderCountries(filterText = "") {
|
| 118 |
+
nationalityDropdown.innerHTML = "";
|
| 119 |
+
const filtered = countries.filter(c => c.toLowerCase().includes(filterText.toLowerCase()));
|
| 120 |
+
filtered.forEach(country => {
|
| 121 |
+
const div = document.createElement("div");
|
| 122 |
+
div.className = "p-2 hover:bg-moss/20 cursor-pointer text-sm text-ink";
|
| 123 |
+
div.textContent = country;
|
| 124 |
+
div.onmousedown = (e) => {
|
| 125 |
+
e.preventDefault(); // prevent blur
|
| 126 |
+
signupNationality.value = country;
|
| 127 |
+
nationalityDropdown.classList.add("hidden");
|
| 128 |
+
};
|
| 129 |
+
nationalityDropdown.appendChild(div);
|
| 130 |
+
});
|
| 131 |
+
if (filtered.length === 0) {
|
| 132 |
+
const div = document.createElement("div");
|
| 133 |
+
div.className = "p-2 text-sm text-clay italic";
|
| 134 |
+
div.textContent = "No matches";
|
| 135 |
+
nationalityDropdown.appendChild(div);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
signupNationality.addEventListener("focus", () => {
|
| 140 |
+
renderCountries(signupNationality.value);
|
| 141 |
+
nationalityDropdown.classList.remove("hidden");
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
signupNationality.addEventListener("input", (e) => {
|
| 145 |
+
renderCountries(e.target.value);
|
| 146 |
+
nationalityDropdown.classList.remove("hidden");
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
signupNationality.addEventListener("blur", () => {
|
| 150 |
+
nationalityDropdown.classList.add("hidden");
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Helper function to strip spaces from OTP input
|
| 155 |
+
function setupOtpInputStripper(fieldId) {
|
| 156 |
+
const field = document.getElementById(fieldId);
|
| 157 |
+
if (field) {
|
| 158 |
+
field.addEventListener('input', (e) => {
|
| 159 |
+
// Remove all spaces from the input value
|
| 160 |
+
e.target.value = e.target.value.replace(/\s/g, '');
|
| 161 |
+
});
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
// Setup OTP input strippers for all OTP fields (signup, forgot password, delete account)
|
| 166 |
+
setupOtpInputStripper("signupOtpCode");
|
| 167 |
+
setupOtpInputStripper("resetOtpCode");
|
| 168 |
+
setupOtpInputStripper("deleteAccountOtpCode");
|
| 169 |
+
|
| 170 |
+
document.getElementById("loginBtn")?.addEventListener("click", async () => {
|
| 171 |
+
const user = document.getElementById("loginUsername").value.trim();
|
| 172 |
+
const pass = document.getElementById("loginPassword").value;
|
| 173 |
+
if (!user || !pass) return ui.showAlert("Login Error", "Please enter both username/email and password");
|
| 174 |
+
|
| 175 |
+
try {
|
| 176 |
+
document.getElementById("loginBtn").textContent = "Logging In...";
|
| 177 |
+
await auth.login(user, pass);
|
| 178 |
+
document.getElementById("loginBtn").textContent = "Log In";
|
| 179 |
+
await profiles.fetchPatients();
|
| 180 |
+
} catch (e) {
|
| 181 |
+
ui.showAlert("Login Failed", e.message);
|
| 182 |
+
document.getElementById("loginBtn").textContent = "Log In";
|
| 183 |
+
}
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
let _signupPayload = null;
|
| 187 |
+
|
| 188 |
+
document.getElementById("signupGetOtpBtn")?.addEventListener("click", async () => {
|
| 189 |
+
const payload = {
|
| 190 |
+
name: document.getElementById("signupName").value.trim(),
|
| 191 |
+
username: document.getElementById("signupUsername").value.trim(),
|
| 192 |
+
email: document.getElementById("signupEmail").value.trim(),
|
| 193 |
+
password: document.getElementById("signupPassword").value,
|
| 194 |
+
gender: document.getElementById("signupGender").value,
|
| 195 |
+
age: parseInt(document.getElementById("signupAge").value) || null,
|
| 196 |
+
nationality: document.getElementById("signupNationality").value,
|
| 197 |
+
primary_concern: document.getElementById("signupConcern").value,
|
| 198 |
+
emergency_contact_name: document.getElementById("signupEmergencyName").value.trim() || null,
|
| 199 |
+
emergency_contact_phone: document.getElementById("signupEmergencyPhone").value.trim() || null
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
if (!payload.name || !payload.username || !payload.email || !payload.password || !payload.gender || !payload.nationality || !payload.primary_concern) {
|
| 203 |
+
return ui.showAlert("Signup Error", "Please fill out all required fields (*).");
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
if (payload.password.length < 8) {
|
| 207 |
+
return ui.showAlert("Signup Error", "Password must be at least 8 characters long.");
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
if (payload.age !== null && (payload.age < 5 || payload.age > 99)) {
|
| 211 |
+
return ui.showAlert("Signup Error", "Age must be between 5 and 99.");
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
try {
|
| 215 |
+
document.getElementById("signupGetOtpBtn").textContent = "Sending OTP...";
|
| 216 |
+
document.getElementById("signupGetOtpBtn").disabled = true;
|
| 217 |
+
|
| 218 |
+
await auth.requestSignupOtp(payload.email, payload.username);
|
| 219 |
+
|
| 220 |
+
_signupPayload = payload;
|
| 221 |
+
|
| 222 |
+
// Clear OTP code and switch to signup OTP panel
|
| 223 |
+
const signupOtpCodeField = document.getElementById("signupOtpCode");
|
| 224 |
+
if (signupOtpCodeField) {
|
| 225 |
+
signupOtpCodeField.value = "";
|
| 226 |
+
}
|
| 227 |
+
showAuthPanel("signupOtpStepContainer");
|
| 228 |
+
|
| 229 |
+
} catch (e) {
|
| 230 |
+
ui.showAlert("Signup Failed", e.message);
|
| 231 |
+
} finally {
|
| 232 |
+
document.getElementById("signupGetOtpBtn").textContent = "Get OTP";
|
| 233 |
+
document.getElementById("signupGetOtpBtn").disabled = false;
|
| 234 |
+
}
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
document.getElementById("signupCompleteBtn")?.addEventListener("click", async () => {
|
| 238 |
+
if (!_signupPayload) {
|
| 239 |
+
return ui.showAlert("Signup Error", "Missing signup details. Please refresh and try again.");
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const signupOtpCodeField = document.getElementById("signupOtpCode");
|
| 243 |
+
if (!signupOtpCodeField) {
|
| 244 |
+
return ui.showAlert("Signup Error", "OTP code field not found. Please refresh and try again.");
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
const otpCode = signupOtpCodeField.value.trim();
|
| 248 |
+
if (otpCode.length !== 6) {
|
| 249 |
+
return ui.showAlert("Signup Error", "Please enter the 6-digit OTP code.");
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
try {
|
| 253 |
+
const completeBtn = document.getElementById("signupCompleteBtn");
|
| 254 |
+
if (completeBtn) {
|
| 255 |
+
completeBtn.textContent = "Creating Account...";
|
| 256 |
+
completeBtn.disabled = true;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
const payload = {
|
| 260 |
+
..._signupPayload,
|
| 261 |
+
otp_code: otpCode
|
| 262 |
+
};
|
| 263 |
+
|
| 264 |
+
await auth.signup(payload);
|
| 265 |
+
|
| 266 |
+
_signupPayload = null;
|
| 267 |
+
if (signupOtpCodeField) {
|
| 268 |
+
signupOtpCodeField.value = "";
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// Account created successfully, go back to login
|
| 272 |
+
ui.showAlert("Success", "Account created successfully!");
|
| 273 |
+
showAuthPanel("loginFormContainer");
|
| 274 |
+
|
| 275 |
+
await profiles.fetchPatients();
|
| 276 |
+
} catch (e) {
|
| 277 |
+
ui.showAlert("Signup Failed", e.message);
|
| 278 |
+
} finally {
|
| 279 |
+
const completeBtn = document.getElementById("signupCompleteBtn");
|
| 280 |
+
if (completeBtn) {
|
| 281 |
+
completeBtn.textContent = "Complete Signup";
|
| 282 |
+
completeBtn.disabled = false;
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
document.getElementById("requestOtpBtn")?.addEventListener("click", async () => {
|
| 288 |
+
const email = document.getElementById("forgotPwdEmail").value.trim();
|
| 289 |
+
if (!email) return ui.showAlert("Reset Password", "Please enter your email.");
|
| 290 |
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
| 291 |
+
if (!emailRegex.test(email)) return ui.showAlert("Reset Password", "Please enter a valid email address.");
|
| 292 |
+
try {
|
| 293 |
+
document.getElementById("requestOtpBtn").textContent = "Sending...";
|
| 294 |
+
document.getElementById("requestOtpBtn").disabled = true;
|
| 295 |
+
await auth.forgotPassword(email);
|
| 296 |
+
document.getElementById("resetOtpCode").value = "";
|
| 297 |
+
showAuthPanel("otpStepContainer");
|
| 298 |
+
} catch(e) {
|
| 299 |
+
ui.showAlert("Reset Password", e.message);
|
| 300 |
+
} finally {
|
| 301 |
+
document.getElementById("requestOtpBtn").textContent = "Send Code";
|
| 302 |
+
document.getElementById("requestOtpBtn").disabled = false;
|
| 303 |
+
}
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
document.getElementById("verifyOtpBtn")?.addEventListener("click", async () => {
|
| 307 |
+
const email = document.getElementById("forgotPwdEmail").value.trim();
|
| 308 |
+
const otp = document.getElementById("resetOtpCode").value.trim();
|
| 309 |
+
if (otp.length !== 6) return ui.showAlert("Reset Password", "Please enter the 6-digit code.");
|
| 310 |
+
try {
|
| 311 |
+
document.getElementById("verifyOtpBtn").textContent = "Verifying...";
|
| 312 |
+
document.getElementById("verifyOtpBtn").disabled = true;
|
| 313 |
+
await auth.verifyOtp(email, otp);
|
| 314 |
+
_otpVerifiedEmail = email;
|
| 315 |
+
document.getElementById("resetNewPassword").value = "";
|
| 316 |
+
showAuthPanel("newPwdStepContainer");
|
| 317 |
+
} catch(e) {
|
| 318 |
+
ui.showAlert("Verification Failed", e.message);
|
| 319 |
+
} finally {
|
| 320 |
+
document.getElementById("verifyOtpBtn").textContent = "Verify Code";
|
| 321 |
+
document.getElementById("verifyOtpBtn").disabled = false;
|
| 322 |
+
}
|
| 323 |
+
});
|
| 324 |
+
|
| 325 |
+
document.getElementById("submitResetBtn")?.addEventListener("click", async () => {
|
| 326 |
+
// If _otpVerifiedEmail is null, this means a refresh happened — redirect to login
|
| 327 |
+
if (!_otpVerifiedEmail) {
|
| 328 |
+
showAuthPanel("loginFormContainer");
|
| 329 |
+
return;
|
| 330 |
+
}
|
| 331 |
+
const newPass = document.getElementById("resetNewPassword").value;
|
| 332 |
+
if (!newPass || newPass.length < 8) return ui.showAlert("Reset Password", "New password must be at least 8 characters long.");
|
| 333 |
+
try {
|
| 334 |
+
document.getElementById("submitResetBtn").textContent = "Updating...";
|
| 335 |
+
document.getElementById("submitResetBtn").disabled = true;
|
| 336 |
+
const otp = document.getElementById("resetOtpCode").value.trim();
|
| 337 |
+
await auth.resetPassword(_otpVerifiedEmail, otp, newPass);
|
| 338 |
+
_otpVerifiedEmail = null;
|
| 339 |
+
ui.showAlert("Success", "Password reset successfully! Please log in.");
|
| 340 |
+
showAuthPanel("loginFormContainer");
|
| 341 |
+
} catch (e) {
|
| 342 |
+
ui.showAlert("Reset Failed", e.message);
|
| 343 |
+
} finally {
|
| 344 |
+
document.getElementById("submitResetBtn").textContent = "Update Password";
|
| 345 |
+
document.getElementById("submitResetBtn").disabled = false;
|
| 346 |
+
}
|
| 347 |
+
});
|
| 348 |
+
|
| 349 |
+
// --- Profiles & Dashboard Events ---
|
| 350 |
+
|
| 351 |
+
const patientDropdown = document.getElementById("patientDropdown");
|
| 352 |
+
if (patientDropdown) patientDropdown.onchange = profiles.toggleSelectButton;
|
| 353 |
+
|
| 354 |
+
const dashboardLogoutBtn = document.getElementById("dashboardLogoutBtn");
|
| 355 |
+
if (dashboardLogoutBtn) {
|
| 356 |
+
dashboardLogoutBtn.onclick = () => {
|
| 357 |
+
auth.logout();
|
| 358 |
+
ui.switchScreen("authScreen");
|
| 359 |
+
};
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
const dashboardResetBtn = document.getElementById("dashboardResetBtn");
|
| 363 |
+
if (dashboardResetBtn) {
|
| 364 |
+
dashboardResetBtn.onclick = () => {
|
| 365 |
+
ui.showConfirm(
|
| 366 |
+
"Reset Profile?",
|
| 367 |
+
"Are you sure you want to reset this profile? All sessions, messages, and analysis will be permanently deleted. The patient record will remain.",
|
| 368 |
+
async () => {
|
| 369 |
+
try {
|
| 370 |
+
await api.resetPatientProfile(state.patientId);
|
| 371 |
+
} catch (e) {
|
| 372 |
+
console.error("Error resetting profile:", e);
|
| 373 |
+
}
|
| 374 |
+
document.getElementById("dashboardSessionsList").innerHTML = `<p class="text-clay text-sm italic">No previous sessions found.</p>`;
|
| 375 |
+
document.getElementById("dashboardDomains").innerHTML = `
|
| 376 |
+
<h3 class="font-utility uppercase text-clay text-sm border-b border-ink/30 pb-2">Clinical Profile</h3>
|
| 377 |
+
<p class="text-clay text-sm italic mt-4">Profile has been reset.</p>
|
| 378 |
+
`;
|
| 379 |
+
}
|
| 380 |
+
);
|
| 381 |
+
};
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
const dashboardDeleteBtn = document.getElementById("dashboardDeleteBtn");
|
| 385 |
+
if (dashboardDeleteBtn) {
|
| 386 |
+
dashboardDeleteBtn.onclick = () => {
|
| 387 |
+
document.getElementById("deleteAccountConfirmModal").style.display = "flex";
|
| 388 |
+
};
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
const deleteAccountCancelBtn = document.getElementById("deleteAccountCancelBtn");
|
| 392 |
+
if (deleteAccountCancelBtn) {
|
| 393 |
+
deleteAccountCancelBtn.onclick = () => {
|
| 394 |
+
document.getElementById("deleteAccountConfirmModal").style.display = "none";
|
| 395 |
+
};
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
const deleteAccountSendOtpBtn = document.getElementById("deleteAccountSendOtpBtn");
|
| 399 |
+
if (deleteAccountSendOtpBtn) {
|
| 400 |
+
deleteAccountSendOtpBtn.onclick = async () => {
|
| 401 |
+
try {
|
| 402 |
+
deleteAccountSendOtpBtn.textContent = "Sending OTP...";
|
| 403 |
+
deleteAccountSendOtpBtn.disabled = true;
|
| 404 |
+
|
| 405 |
+
await auth.requestDeleteAccountOtp();
|
| 406 |
+
|
| 407 |
+
// Close confirmation modal and show OTP modal
|
| 408 |
+
document.getElementById("deleteAccountConfirmModal").style.display = "none";
|
| 409 |
+
document.getElementById("deleteAccountOtpCode").value = "";
|
| 410 |
+
document.getElementById("deleteAccountOtpModal").style.display = "flex";
|
| 411 |
+
|
| 412 |
+
} catch (e) {
|
| 413 |
+
console.error("Error requesting OTP:", e);
|
| 414 |
+
ui.showAlert("Error", e.message);
|
| 415 |
+
} finally {
|
| 416 |
+
deleteAccountSendOtpBtn.textContent = "SEND OTP";
|
| 417 |
+
deleteAccountSendOtpBtn.disabled = false;
|
| 418 |
+
}
|
| 419 |
+
};
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
const closeDeleteOtpModalBtn = document.getElementById("closeDeleteOtpModalBtn");
|
| 423 |
+
if (closeDeleteOtpModalBtn) {
|
| 424 |
+
closeDeleteOtpModalBtn.onclick = () => {
|
| 425 |
+
document.getElementById("deleteAccountOtpModal").style.display = "none";
|
| 426 |
+
};
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
const submitDeleteOtpBtn = document.getElementById("submitDeleteOtpBtn");
|
| 430 |
+
if (submitDeleteOtpBtn) {
|
| 431 |
+
submitDeleteOtpBtn.onclick = async () => {
|
| 432 |
+
const code = document.getElementById("deleteAccountOtpCode").value.trim();
|
| 433 |
+
if (code.length !== 6) return ui.showAlert("Error", "Please enter a valid 6-digit code.");
|
| 434 |
+
try {
|
| 435 |
+
submitDeleteOtpBtn.textContent = "Verifying...";
|
| 436 |
+
submitDeleteOtpBtn.disabled = true;
|
| 437 |
+
|
| 438 |
+
await auth.verifyDeleteAccount(code);
|
| 439 |
+
|
| 440 |
+
document.getElementById("deleteAccountOtpModal").style.display = "none";
|
| 441 |
+
auth.logout();
|
| 442 |
+
ui.switchScreen("authScreen");
|
| 443 |
+
|
| 444 |
+
} catch (e) {
|
| 445 |
+
ui.showAlert("Error", e.message);
|
| 446 |
+
} finally {
|
| 447 |
+
submitDeleteOtpBtn.textContent = "Verify & Delete";
|
| 448 |
+
submitDeleteOtpBtn.disabled = false;
|
| 449 |
+
}
|
| 450 |
+
};
|
| 451 |
+
}
|
| 452 |
+
const dashboardStartBtn = document.getElementById("dashboardStartBtn");
|
| 453 |
+
if (dashboardStartBtn) {
|
| 454 |
+
dashboardStartBtn.onclick = async () => {
|
| 455 |
+
ui.switchScreen("loadingScreen", false);
|
| 456 |
+
startButton.textContent = "Checking Session...";
|
| 457 |
+
startButton.disabled = true;
|
| 458 |
+
|
| 459 |
+
try {
|
| 460 |
+
const active = await api.getActiveSession(state.patientId);
|
| 461 |
+
if (active && active.session_id) {
|
| 462 |
+
ui.switchScreen("dashboardScreen");
|
| 463 |
+
const modal = document.getElementById("continueSessionModal");
|
| 464 |
+
modal.style.display = "flex";
|
| 465 |
+
|
| 466 |
+
document.getElementById("continueSessionBtn").onclick = async () => {
|
| 467 |
+
modal.style.display = "none";
|
| 468 |
+
ui.switchScreen("loadingScreen", false);
|
| 469 |
+
startButton.textContent = "Resuming Session...";
|
| 470 |
+
await session.continueActualSession(active.session_id);
|
| 471 |
+
};
|
| 472 |
+
|
| 473 |
+
document.getElementById("endAndStartNewBtn").onclick = async () => {
|
| 474 |
+
modal.style.display = "none";
|
| 475 |
+
ui.switchScreen("loadingScreen", false);
|
| 476 |
+
startButton.textContent = "Loading Session...";
|
| 477 |
+
state.setSessionId(null);
|
| 478 |
+
await api.endSessionReq(active.session_id, state.patientId);
|
| 479 |
+
await session.startActualSession(state.patientId);
|
| 480 |
+
};
|
| 481 |
+
} else {
|
| 482 |
+
startButton.textContent = "Loading Session...";
|
| 483 |
+
await session.startActualSession(state.patientId);
|
| 484 |
+
}
|
| 485 |
+
} catch (e) {
|
| 486 |
+
console.error(e);
|
| 487 |
+
startButton.textContent = "Loading Session...";
|
| 488 |
+
await session.startActualSession(state.patientId);
|
| 489 |
+
}
|
| 490 |
+
};
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
const selectProfileBtn = document.getElementById("selectProfileBtn");
|
| 494 |
+
if (selectProfileBtn) {
|
| 495 |
+
selectProfileBtn.onclick = async () => {
|
| 496 |
+
state.setPatientId(patientDropdown.value);
|
| 497 |
+
await profiles.showDashboard(state.patientId);
|
| 498 |
+
};
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
const createProfileBtn = document.getElementById("createProfileBtn");
|
| 502 |
+
if (createProfileBtn) {
|
| 503 |
+
createProfileBtn.onclick = async () => {
|
| 504 |
+
const nameInput = document.getElementById("newPatientName");
|
| 505 |
+
const ageInput = document.getElementById("newPatientAge");
|
| 506 |
+
const genderInput = document.getElementById("newPatientGender");
|
| 507 |
+
const occupationInput = document.getElementById("newPatientOccupation");
|
| 508 |
+
const concernInput = document.getElementById("newPatientConcern");
|
| 509 |
+
|
| 510 |
+
const name = nameInput.value.trim();
|
| 511 |
+
const age = ageInput.value;
|
| 512 |
+
const gender = genderInput ? genderInput.value : null;
|
| 513 |
+
const occupation = occupationInput ? occupationInput.value.trim() : null;
|
| 514 |
+
|
| 515 |
+
if (!name || !age || !gender || !occupation) {
|
| 516 |
+
ui.showAlert("Missing Information", "Please fill out all 4 required fields (Name, Age, Gender, and Occupation) to create a new profile.");
|
| 517 |
+
return;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
const parsedAge = parseInt(age);
|
| 521 |
+
if (isNaN(parsedAge) || parsedAge < 5 || parsedAge > 99) {
|
| 522 |
+
ui.showAlert("Invalid Age", "Age must be between 5 and 99.");
|
| 523 |
+
return;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
try {
|
| 527 |
+
const data = {
|
| 528 |
+
name: name,
|
| 529 |
+
age: parseInt(age),
|
| 530 |
+
gender: gender,
|
| 531 |
+
occupation: occupation,
|
| 532 |
+
primary_concern: concernInput ? concernInput.value.trim() : null
|
| 533 |
+
};
|
| 534 |
+
const pData = await api.createPatientProfile(data);
|
| 535 |
+
state.setPatientId(pData.patient_id);
|
| 536 |
+
await profiles.showDashboard(state.patientId);
|
| 537 |
+
} catch (e) {
|
| 538 |
+
console.error("Failed to create profile:", e);
|
| 539 |
+
ui.showAlert("Error", "Failed to create patient profile. Please try again.");
|
| 540 |
+
}
|
| 541 |
+
};
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
if (startButton) {
|
| 545 |
+
startButton.onclick = async () => {
|
| 546 |
+
const text = startButton.textContent.trim();
|
| 547 |
+
if (text.includes("Start Consultation")) {
|
| 548 |
+
if (!auth.isAuthenticated()) {
|
| 549 |
+
ui.switchScreen("authScreen", false);
|
| 550 |
+
return;
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
startButton.textContent = "Loading...";
|
| 554 |
+
startButton.disabled = true;
|
| 555 |
+
try {
|
| 556 |
+
await profiles.fetchPatients();
|
| 557 |
+
} catch(e) {
|
| 558 |
+
console.error(e);
|
| 559 |
+
ui.showAlert("Error", "Failed to load dashboard. Check console.");
|
| 560 |
+
} finally {
|
| 561 |
+
startButton.textContent = "Start Consultation";
|
| 562 |
+
startButton.disabled = false;
|
| 563 |
+
}
|
| 564 |
+
} else if (text.includes("Retry")) {
|
| 565 |
+
startButton.textContent = "Initializing...";
|
| 566 |
+
startButton.disabled = true;
|
| 567 |
+
session.initializeSession();
|
| 568 |
+
}
|
| 569 |
+
};
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
// --- Session UI Events (Toggle Text/Voice, etc) ---
|
| 573 |
+
|
| 574 |
+
if (voiceModeBtn) {
|
| 575 |
+
voiceModeBtn.onclick = (e) => {
|
| 576 |
+
state.isVoiceMode = true;
|
| 577 |
+
voiceModeBtn.classList.add("active");
|
| 578 |
+
textModeBtn.classList.remove("active");
|
| 579 |
+
voiceInput.style.display = "flex";
|
| 580 |
+
textInput.style.display = "none";
|
| 581 |
+
|
| 582 |
+
// Auto-enable audio output when switching to voice mode
|
| 583 |
+
if (!state.isAudioOutputEnabled) {
|
| 584 |
+
session.setAudioOutput(true);
|
| 585 |
+
}
|
| 586 |
+
e.currentTarget.blur();
|
| 587 |
+
};
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
if (textModeBtn) {
|
| 591 |
+
textModeBtn.onclick = (e) => {
|
| 592 |
+
state.isVoiceMode = false;
|
| 593 |
+
textModeBtn.classList.add("active");
|
| 594 |
+
voiceModeBtn.classList.remove("active");
|
| 595 |
+
voiceInput.style.display = "none";
|
| 596 |
+
textInput.style.display = "block";
|
| 597 |
+
chatInput.focus();
|
| 598 |
+
};
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
const voiceSelect = document.getElementById("voiceSelect");
|
| 602 |
+
if (voiceSelect) {
|
| 603 |
+
voiceSelect.addEventListener('change', (e) => e.target.blur());
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
const audioOutputToggleMobile = document.getElementById("audioOutputToggleMobile");
|
| 607 |
+
if (audioOutputToggleMobile) {
|
| 608 |
+
audioOutputToggleMobile.addEventListener('change', (e) => {
|
| 609 |
+
session.setAudioOutput(e.target.checked);
|
| 610 |
+
e.target.blur();
|
| 611 |
+
});
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
const audioOutputToggleDesktop = document.getElementById("audioOutputToggleDesktop");
|
| 615 |
+
if (audioOutputToggleDesktop) {
|
| 616 |
+
audioOutputToggleDesktop.addEventListener('click', (e) => {
|
| 617 |
+
session.setAudioOutput(!state.isAudioOutputEnabled);
|
| 618 |
+
e.currentTarget.blur();
|
| 619 |
+
});
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
if (sendBtn) {
|
| 623 |
+
sendBtn.onclick = () => {
|
| 624 |
+
const message = chatInput.value.trim();
|
| 625 |
+
if (message) {
|
| 626 |
+
chatInput.value = "";
|
| 627 |
+
session.primeTTS(); // Renew activation synchronously
|
| 628 |
+
session.sendTextMessage(message);
|
| 629 |
+
}
|
| 630 |
+
};
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
if (chatInput) {
|
| 634 |
+
chatInput.onkeypress = (e) => {
|
| 635 |
+
if (e.key === 'Enter') sendBtn.onclick();
|
| 636 |
+
};
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
// --- Audio Recording Events ---
|
| 640 |
+
|
| 641 |
+
if (micBtn) {
|
| 642 |
+
micBtn.addEventListener('pointerdown', (e) => {
|
| 643 |
+
e.preventDefault();
|
| 644 |
+
if (!state.isVoiceMode || state.isAISpeaking) return;
|
| 645 |
+
if (!state.isListening && !state.isRecordingPending) {
|
| 646 |
+
micBtn.style.transform = 'scale(0.95)';
|
| 647 |
+
audio.startRecording(session.sendTextMessage);
|
| 648 |
+
}
|
| 649 |
+
});
|
| 650 |
+
|
| 651 |
+
const stopMicAction = (e) => {
|
| 652 |
+
e.preventDefault();
|
| 653 |
+
if (state.isVoiceMode && (state.isListening || state.isRecordingPending)) {
|
| 654 |
+
micBtn.style.transform = 'none';
|
| 655 |
+
session.primeTTS(); // Renew activation synchronously
|
| 656 |
+
audio.stopRecording();
|
| 657 |
+
}
|
| 658 |
+
};
|
| 659 |
+
|
| 660 |
+
micBtn.addEventListener('pointerup', stopMicAction);
|
| 661 |
+
micBtn.addEventListener('pointercancel', stopMicAction);
|
| 662 |
+
micBtn.addEventListener('pointerleave', stopMicAction);
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
// Spacebar hold-to-record (capture phase to intercept before browser synthetic clicks)
|
| 666 |
+
window.addEventListener('keydown', (e) => {
|
| 667 |
+
if (e.code !== 'Space' || e.repeat) return;
|
| 668 |
+
if (!state.isVoiceMode) return;
|
| 669 |
+
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
|
| 670 |
+
|
| 671 |
+
e.preventDefault();
|
| 672 |
+
e.stopPropagation();
|
| 673 |
+
|
| 674 |
+
if (state.isAISpeaking) return;
|
| 675 |
+
if (!state.isListening && !state.isRecordingPending) {
|
| 676 |
+
audio.startRecording(session.sendTextMessage);
|
| 677 |
+
}
|
| 678 |
+
}, { capture: true });
|
| 679 |
+
|
| 680 |
+
window.addEventListener('keyup', (e) => {
|
| 681 |
+
if (e.code !== 'Space') return;
|
| 682 |
+
if (!state.isVoiceMode) return;
|
| 683 |
+
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
|
| 684 |
+
|
| 685 |
+
e.preventDefault();
|
| 686 |
+
e.stopPropagation();
|
| 687 |
+
|
| 688 |
+
session.primeTTS(); // Renew activation synchronously
|
| 689 |
+
audio.stopRecording();
|
| 690 |
+
}, { capture: true });
|
| 691 |
+
|
| 692 |
+
const endSessionBtn = document.getElementById("endSessionBtn");
|
| 693 |
+
if (endSessionBtn) {
|
| 694 |
+
endSessionBtn.onclick = async () => {
|
| 695 |
+
if (!state.sessionId) return;
|
| 696 |
+
|
| 697 |
+
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
|
| 698 |
+
ui.setStatusText("Ending session...");
|
| 699 |
+
|
| 700 |
+
try {
|
| 701 |
+
await api.endSessionReq(state.sessionId, state.patientId);
|
| 702 |
+
state.setSessionId(null);
|
| 703 |
+
window.location.href = "/";
|
| 704 |
+
} catch (err) {
|
| 705 |
+
console.error("Failed to end session:", err);
|
| 706 |
+
state.setSessionId(null);
|
| 707 |
+
window.location.href = "/";
|
| 708 |
+
}
|
| 709 |
+
};
|
| 710 |
+
}
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
// Bootstrap application
|
| 714 |
+
api.pingHealth().catch(() => console.log("Wakeup ping sent."));
|
| 715 |
+
audio.loadVoices();
|
| 716 |
+
bindEvents();
|
| 717 |
+
window.onload = () => session.initializeSession();
|
static/js/audio.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { state } from './state.js';
|
| 2 |
+
import { setStatusText, toggleMicButtonVisuals, addTyping, removeTyping } from './ui.js';
|
| 3 |
+
import { transcribeAudio } from './api.js';
|
| 4 |
+
|
| 5 |
+
let systemVoices = [];
|
| 6 |
+
let mediaRecorder = null;
|
| 7 |
+
let audioChunks = [];
|
| 8 |
+
|
| 9 |
+
// Keep a global reference so Chrome doesn't garbage-collect the utterance mid-speech
|
| 10 |
+
let currentUtterance = null;
|
| 11 |
+
|
| 12 |
+
export function loadVoices() {
|
| 13 |
+
const voiceSelect = document.getElementById("voiceSelect");
|
| 14 |
+
if (!voiceSelect) return;
|
| 15 |
+
|
| 16 |
+
systemVoices = window.speechSynthesis.getVoices();
|
| 17 |
+
voiceSelect.innerHTML = "";
|
| 18 |
+
|
| 19 |
+
// Add a reliable default option that doesn't force a specific voice object
|
| 20 |
+
const defaultOption = document.createElement("option");
|
| 21 |
+
defaultOption.value = "";
|
| 22 |
+
defaultOption.textContent = "Default OS Voice (Reliable)";
|
| 23 |
+
defaultOption.selected = true;
|
| 24 |
+
voiceSelect.appendChild(defaultOption);
|
| 25 |
+
|
| 26 |
+
const englishVoices = systemVoices.filter(v => v.lang.includes('en'));
|
| 27 |
+
|
| 28 |
+
englishVoices.forEach(voice => {
|
| 29 |
+
const option = document.createElement("option");
|
| 30 |
+
option.value = voice.name;
|
| 31 |
+
option.textContent = `${voice.name} (${voice.localService ? 'Local' : 'Network'})`;
|
| 32 |
+
voiceSelect.appendChild(option);
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
const googleUSEnglish = englishVoices.find(v => v.name === "Google US English");
|
| 36 |
+
if (googleUSEnglish) {
|
| 37 |
+
voiceSelect.value = googleUSEnglish.name;
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
| 42 |
+
window.speechSynthesis.onvoiceschanged = loadVoices;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Speak text using the browser's SpeechSynthesis API.
|
| 47 |
+
* Only speaks if audio output is enabled (state.isAudioOutputEnabled).
|
| 48 |
+
* This is independent of input mode (text vs voice).
|
| 49 |
+
*/
|
| 50 |
+
export function speak(text) {
|
| 51 |
+
return new Promise((resolve) => {
|
| 52 |
+
if (!state.isAudioOutputEnabled) {
|
| 53 |
+
resolve();
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Ensure voices are loaded
|
| 58 |
+
if (systemVoices.length === 0) {
|
| 59 |
+
systemVoices = window.speechSynthesis.getVoices();
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Strip markdown symbols before speaking
|
| 63 |
+
const cleanText = text.replace(/[*_~`#]/g, '').replace(/\[.*?\]/g, '').replace(/\(.*?\)/g, '').trim();
|
| 64 |
+
if (!cleanText) {
|
| 65 |
+
console.warn("TTS Skipped: Text was empty after stripping markdown");
|
| 66 |
+
resolve();
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
setStatusText("Assistant is responding...");
|
| 71 |
+
|
| 72 |
+
// No cancel(), no setTimeout. Just create and speak.
|
| 73 |
+
currentUtterance = new SpeechSynthesisUtterance(cleanText);
|
| 74 |
+
|
| 75 |
+
// Select voice
|
| 76 |
+
const voiceSelect = document.getElementById("voiceSelect");
|
| 77 |
+
const selectedVoiceName = voiceSelect ? voiceSelect.value : "";
|
| 78 |
+
|
| 79 |
+
// Only set the voice if the user explicitly selected one of the specific named voices.
|
| 80 |
+
// Otherwise, leave it undefined so the browser uses its ultra-reliable native default.
|
| 81 |
+
if (selectedVoiceName !== "") {
|
| 82 |
+
const preferredVoice = systemVoices.find(v => v.name === selectedVoiceName);
|
| 83 |
+
if (preferredVoice) {
|
| 84 |
+
currentUtterance.voice = preferredVoice;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
currentUtterance.rate = 1.0;
|
| 88 |
+
currentUtterance.pitch = 1.0;
|
| 89 |
+
currentUtterance.volume = 1.0;
|
| 90 |
+
|
| 91 |
+
currentUtterance.onstart = () => {
|
| 92 |
+
state.isAISpeaking = true;
|
| 93 |
+
setStatusText("Assistant is speaking... (Audio Started)");
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
currentUtterance.onend = () => {
|
| 97 |
+
state.isAISpeaking = false;
|
| 98 |
+
setStatusText(state.isVoiceMode ? "Hold Space to speak" : "Type your message");
|
| 99 |
+
resolve();
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
currentUtterance.onerror = (e) => {
|
| 103 |
+
console.error("TTS error:", e.error);
|
| 104 |
+
state.isAISpeaking = false;
|
| 105 |
+
setStatusText(`TTS Error: ${e.error}`);
|
| 106 |
+
setTimeout(() => {
|
| 107 |
+
setStatusText(state.isVoiceMode ? "Hold Space to speak" : "Type your message");
|
| 108 |
+
}, 3000);
|
| 109 |
+
resolve();
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
setStatusText("Attempting to speak...");
|
| 113 |
+
|
| 114 |
+
// Fallback timeout in case onstart/onend never fire
|
| 115 |
+
setTimeout(() => {
|
| 116 |
+
if (state.isAISpeaking) {
|
| 117 |
+
// It started, but maybe stuck?
|
| 118 |
+
} else {
|
| 119 |
+
setStatusText("Audio failed to start (Timeout)");
|
| 120 |
+
setTimeout(() => {
|
| 121 |
+
setStatusText(state.isVoiceMode ? "Hold Space to speak" : "Type your message");
|
| 122 |
+
}, 3000);
|
| 123 |
+
resolve();
|
| 124 |
+
}
|
| 125 |
+
}, 4000);
|
| 126 |
+
|
| 127 |
+
window.speechSynthesis.speak(currentUtterance);
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
/**
|
| 132 |
+
* Starts recording the user's voice via MediaRecorder.
|
| 133 |
+
* Called only via spacebar hold.
|
| 134 |
+
*/
|
| 135 |
+
export async function startRecording(onTranscriptionDone) {
|
| 136 |
+
if (state.isRecordingPending || state.isListening) return;
|
| 137 |
+
state.isRecordingPending = true;
|
| 138 |
+
|
| 139 |
+
try {
|
| 140 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 141 |
+
|
| 142 |
+
// If user released space before mic permission was granted
|
| 143 |
+
if (!state.isRecordingPending) {
|
| 144 |
+
stream.getTracks().forEach(track => track.stop());
|
| 145 |
+
return;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
mediaRecorder = new MediaRecorder(stream);
|
| 149 |
+
audioChunks = [];
|
| 150 |
+
|
| 151 |
+
mediaRecorder.ondataavailable = (event) => {
|
| 152 |
+
if (event.data.size > 0) {
|
| 153 |
+
audioChunks.push(event.data);
|
| 154 |
+
}
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
mediaRecorder.onstop = async () => {
|
| 158 |
+
// Stop all mic tracks immediately
|
| 159 |
+
stream.getTracks().forEach(track => track.stop());
|
| 160 |
+
|
| 161 |
+
const audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
| 162 |
+
|
| 163 |
+
if (audioBlob.size < 100) {
|
| 164 |
+
setStatusText("Recording was too short. Hold Space longer.");
|
| 165 |
+
return;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
setStatusText("Transcribing your voice...");
|
| 169 |
+
addTyping(true, "Transcribing...");
|
| 170 |
+
|
| 171 |
+
try {
|
| 172 |
+
const data = await transcribeAudio(audioBlob);
|
| 173 |
+
removeTyping();
|
| 174 |
+
|
| 175 |
+
if (data.text && data.text.trim() !== "") {
|
| 176 |
+
onTranscriptionDone(data.text, data.emotion);
|
| 177 |
+
} else {
|
| 178 |
+
setStatusText("I didn't catch that. Try again?");
|
| 179 |
+
}
|
| 180 |
+
} catch (e) {
|
| 181 |
+
removeTyping();
|
| 182 |
+
console.error("Transcription failed", e);
|
| 183 |
+
setStatusText("Hold Space to try again");
|
| 184 |
+
}
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
mediaRecorder.start();
|
| 188 |
+
state.isListening = true;
|
| 189 |
+
state.isRecordingPending = false;
|
| 190 |
+
toggleMicButtonVisuals(true);
|
| 191 |
+
setStatusText("Listening... (release Space to stop)");
|
| 192 |
+
} catch (err) {
|
| 193 |
+
console.error("Microphone access denied", err);
|
| 194 |
+
setStatusText("Microphone access denied");
|
| 195 |
+
state.isRecordingPending = false;
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
export function stopRecording() {
|
| 200 |
+
state.isRecordingPending = false;
|
| 201 |
+
|
| 202 |
+
if (mediaRecorder && state.isListening) {
|
| 203 |
+
try {
|
| 204 |
+
if (mediaRecorder.state === 'recording') {
|
| 205 |
+
mediaRecorder.stop();
|
| 206 |
+
}
|
| 207 |
+
} catch(e) {
|
| 208 |
+
console.error("Error stopping media recorder", e);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
state.isListening = false;
|
| 212 |
+
toggleMicButtonVisuals(false);
|
| 213 |
+
setStatusText("Processing...");
|
| 214 |
+
} else {
|
| 215 |
+
state.isListening = false;
|
| 216 |
+
toggleMicButtonVisuals(false);
|
| 217 |
+
}
|
| 218 |
+
}
|
static/js/auth.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BACKEND_URL } from './api.js';
|
| 2 |
+
|
| 3 |
+
export async function login(username, password) {
|
| 4 |
+
const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
|
| 5 |
+
method: "POST",
|
| 6 |
+
headers: { "Content-Type": "application/json" },
|
| 7 |
+
body: JSON.stringify({ username, password })
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const data = await res.json();
|
| 11 |
+
if (!res.ok) {
|
| 12 |
+
throw new Error(data.detail || "Login failed");
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
localStorage.setItem('serinity_token', data.access_token);
|
| 16 |
+
return data;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export async function requestSignupOtp(email, username) {
|
| 20 |
+
const res = await fetch(`${BACKEND_URL}/api/auth/signup/request-otp`, {
|
| 21 |
+
method: "POST",
|
| 22 |
+
headers: { "Content-Type": "application/json" },
|
| 23 |
+
body: JSON.stringify({ email, username })
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
const data = await res.json();
|
| 27 |
+
if (!res.ok) {
|
| 28 |
+
if (Array.isArray(data.detail)) {
|
| 29 |
+
throw new Error(data.detail[0].msg || "Validation error");
|
| 30 |
+
}
|
| 31 |
+
throw new Error(data.detail || "Failed to request OTP");
|
| 32 |
+
}
|
| 33 |
+
return data;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export async function signup(payload) {
|
| 37 |
+
const res = await fetch(`${BACKEND_URL}/api/auth/signup`, {
|
| 38 |
+
method: "POST",
|
| 39 |
+
headers: { "Content-Type": "application/json" },
|
| 40 |
+
body: JSON.stringify(payload)
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
const data = await res.json();
|
| 44 |
+
if (!res.ok) {
|
| 45 |
+
if (Array.isArray(data.detail)) {
|
| 46 |
+
throw new Error(data.detail[0].msg || "Validation error");
|
| 47 |
+
}
|
| 48 |
+
throw new Error(data.detail || "Signup failed");
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
localStorage.setItem('serinity_token', data.access_token);
|
| 52 |
+
return data;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export async function forgotPassword(email) {
|
| 56 |
+
const res = await fetch(`${BACKEND_URL}/api/auth/forgot-password`, {
|
| 57 |
+
method: "POST",
|
| 58 |
+
headers: { "Content-Type": "application/json" },
|
| 59 |
+
body: JSON.stringify({ email })
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
const data = await res.json();
|
| 63 |
+
if (!res.ok) {
|
| 64 |
+
// Pydantic validation errors return detail as an array
|
| 65 |
+
if (res.status === 422 || Array.isArray(data.detail)) {
|
| 66 |
+
throw new Error("Enter a valid email address.");
|
| 67 |
+
}
|
| 68 |
+
throw new Error(data.detail || "Failed to request OTP");
|
| 69 |
+
}
|
| 70 |
+
return data;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export async function verifyOtp(email, otp_code) {
|
| 74 |
+
const res = await fetch(`${BACKEND_URL}/api/auth/verify-otp`, {
|
| 75 |
+
method: "POST",
|
| 76 |
+
headers: { "Content-Type": "application/json" },
|
| 77 |
+
body: JSON.stringify({ email, otp_code })
|
| 78 |
+
});
|
| 79 |
+
const data = await res.json();
|
| 80 |
+
if (!res.ok) throw new Error(data.detail || "Invalid OTP");
|
| 81 |
+
return data;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export async function resetPassword(email, otp_code, new_password) {
|
| 85 |
+
const res = await fetch(`${BACKEND_URL}/api/auth/reset-password`, {
|
| 86 |
+
method: "POST",
|
| 87 |
+
headers: { "Content-Type": "application/json" },
|
| 88 |
+
body: JSON.stringify({ email, otp_code, new_password })
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
const data = await res.json();
|
| 92 |
+
if (!res.ok) {
|
| 93 |
+
if (Array.isArray(data.detail)) {
|
| 94 |
+
throw new Error(data.detail[0].msg || "Validation error");
|
| 95 |
+
}
|
| 96 |
+
throw new Error(data.detail || "Failed to reset password");
|
| 97 |
+
}
|
| 98 |
+
return data;
|
| 99 |
+
}
|
| 100 |
+
export async function requestDeleteAccountOtp() {
|
| 101 |
+
const token = localStorage.getItem('serinity_token');
|
| 102 |
+
const res = await fetch(`${BACKEND_URL}/api/auth/delete-account/request-otp`, {
|
| 103 |
+
method: "POST",
|
| 104 |
+
headers: {
|
| 105 |
+
"Content-Type": "application/json",
|
| 106 |
+
"Authorization": `Bearer ${token}`
|
| 107 |
+
}
|
| 108 |
+
});
|
| 109 |
+
const data = await res.json();
|
| 110 |
+
if (!res.ok) throw new Error(data.detail || "Failed to request OTP");
|
| 111 |
+
return data;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
export async function verifyDeleteAccount(otp_code) {
|
| 115 |
+
const token = localStorage.getItem('serinity_token');
|
| 116 |
+
const res = await fetch(`${BACKEND_URL}/api/auth/delete-account/verify`, {
|
| 117 |
+
method: "POST",
|
| 118 |
+
headers: {
|
| 119 |
+
"Content-Type": "application/json",
|
| 120 |
+
"Authorization": `Bearer ${token}`
|
| 121 |
+
},
|
| 122 |
+
body: JSON.stringify({ otp_code })
|
| 123 |
+
});
|
| 124 |
+
const data = await res.json();
|
| 125 |
+
if (!res.ok) throw new Error(data.detail || "Failed to delete account");
|
| 126 |
+
return data;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
export function logout() {
|
| 130 |
+
localStorage.removeItem('serinity_token');
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export function isAuthenticated() {
|
| 134 |
+
return !!localStorage.getItem('serinity_token');
|
| 135 |
+
}
|
static/js/profiles.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { state } from './state.js';
|
| 2 |
+
import * as api from './api.js';
|
| 3 |
+
import * as ui from './ui.js';
|
| 4 |
+
|
| 5 |
+
export async function fetchPatients() {
|
| 6 |
+
try {
|
| 7 |
+
const patients = await api.fetchPatientsList();
|
| 8 |
+
if (patients.length > 0) {
|
| 9 |
+
state.patientId = patients[0].patient_id;
|
| 10 |
+
await showDashboard(state.patientId);
|
| 11 |
+
} else {
|
| 12 |
+
// This should never happen now since the backend auto-creates a profile, but just in case:
|
| 13 |
+
ui.showAlert("Profile Error", "No patient profile found. Please contact support.");
|
| 14 |
+
}
|
| 15 |
+
} catch (err) {
|
| 16 |
+
console.error("Failed to load patient profile:", err);
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Fetches patient dashboard data (past sessions, domains, etc.) and renders the dashboard screen.
|
| 22 |
+
*
|
| 23 |
+
* @param {string} pId - The patient ID to load dashboard for.
|
| 24 |
+
*/
|
| 25 |
+
export async function showDashboard(pId) {
|
| 26 |
+
try {
|
| 27 |
+
const data = await api.loadDashboard(pId);
|
| 28 |
+
|
| 29 |
+
document.getElementById("dashboardPatientName").textContent = data.patient.name;
|
| 30 |
+
document.getElementById("dashboardPatientAge").textContent = data.patient.age ? `Age: ${data.patient.age}` : "Age: --";
|
| 31 |
+
|
| 32 |
+
const list = document.getElementById("dashboardSessionsList");
|
| 33 |
+
list.innerHTML = "";
|
| 34 |
+
|
| 35 |
+
const dashStartBtn = document.getElementById("dashboardStartBtn");
|
| 36 |
+
dashStartBtn.textContent = "NEW SESSION";
|
| 37 |
+
|
| 38 |
+
if (data.sessions && data.sessions.length > 0) {
|
| 39 |
+
data.sessions.forEach(sess => {
|
| 40 |
+
const d = new Date(sess.created_at).toLocaleString();
|
| 41 |
+
const summary = sess.rolling_summary || "No summary available.";
|
| 42 |
+
list.innerHTML += `
|
| 43 |
+
<details class="group p-4 border border-ink bg-paper shadow-sm mb-3">
|
| 44 |
+
<summary class="text-xs text-moss font-utility tracking-widest uppercase cursor-pointer select-none group-open:mb-2 flex justify-between">
|
| 45 |
+
${d}
|
| 46 |
+
<span class="text-ink transition-transform group-open:rotate-180">▼</span>
|
| 47 |
+
</summary>
|
| 48 |
+
<div class="text-sm text-ink whitespace-pre-wrap mt-2 pl-2 border-l-2 border-clay">${summary}</div>
|
| 49 |
+
</details>
|
| 50 |
+
`;
|
| 51 |
+
});
|
| 52 |
+
} else {
|
| 53 |
+
list.innerHTML = `<p class="text-clay text-sm italic">No previous sessions found.</p>`;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const domainsDiv = document.getElementById("dashboardDomains");
|
| 57 |
+
if (domainsDiv && data.profile) {
|
| 58 |
+
domainsDiv.innerHTML = `<h3 class="font-utility uppercase text-clay text-sm border-b border-ink/30 pb-2">Clinical Profile</h3>`;
|
| 59 |
+
const renderDomain = (title, items) => {
|
| 60 |
+
if (!items || items.length === 0) return;
|
| 61 |
+
const html = `
|
| 62 |
+
<details class="group mt-4 border-b border-clay/30 pb-2 cursor-pointer">
|
| 63 |
+
<summary class="dashboard-label mb-1 select-none flex justify-between">
|
| 64 |
+
${title}
|
| 65 |
+
<span class="text-ink text-xs transition-transform group-open:rotate-180">▼</span>
|
| 66 |
+
</summary>
|
| 67 |
+
<ul class="list-disc pl-5 text-sm dashboard-value space-y-1 mt-2 text-ink">
|
| 68 |
+
${items.map(i => `<li>${i}</li>`).join('')}
|
| 69 |
+
</ul>
|
| 70 |
+
</details>
|
| 71 |
+
`;
|
| 72 |
+
domainsDiv.innerHTML += html;
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
renderDomain("Emotional Themes", data.profile.emotional_themes);
|
| 76 |
+
renderDomain("Thinking Patterns", data.profile.thinking_patterns);
|
| 77 |
+
renderDomain("Stressors", data.profile.stressors);
|
| 78 |
+
renderDomain("Protective Factors", data.profile.protective_factors);
|
| 79 |
+
|
| 80 |
+
if (data.profile.risk_assessment) {
|
| 81 |
+
domainsDiv.innerHTML += `
|
| 82 |
+
<details class="group mt-4 border-b border-clay/30 pb-2 cursor-pointer">
|
| 83 |
+
<summary class="dashboard-label mb-1 select-none flex justify-between">
|
| 84 |
+
Risk Assessment
|
| 85 |
+
<span class="text-ink text-xs transition-transform group-open:rotate-180">▼</span>
|
| 86 |
+
</summary>
|
| 87 |
+
<p class="text-sm dashboard-value mt-2 text-ink">${data.profile.risk_assessment}</p>
|
| 88 |
+
</details>
|
| 89 |
+
`;
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
ui.switchScreen("dashboardScreen");
|
| 94 |
+
|
| 95 |
+
} catch (err) {
|
| 96 |
+
console.error("Dashboard error", err);
|
| 97 |
+
alert("Could not load patient dashboard.");
|
| 98 |
+
}
|
| 99 |
+
}
|
static/js/session.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { state } from './state.js';
|
| 2 |
+
import * as api from './api.js';
|
| 3 |
+
import * as ui from './ui.js';
|
| 4 |
+
import * as audio from './audio.js';
|
| 5 |
+
import * as profiles from './profiles.js';
|
| 6 |
+
import * as auth from './auth.js';
|
| 7 |
+
|
| 8 |
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Processes the assistant's split message response.
|
| 12 |
+
* Displays and speaks the empathetic acknowledgment first,
|
| 13 |
+
* then pauses before delivering the follow-up clinical question.
|
| 14 |
+
*
|
| 15 |
+
* @param {string} message - The raw message containing the && separator.
|
| 16 |
+
*/
|
| 17 |
+
export async function handleAssistantResponses(message) {
|
| 18 |
+
if (!message) return;
|
| 19 |
+
|
| 20 |
+
const parts = message.split("&&").map(p => p.trim()).filter(Boolean);
|
| 21 |
+
|
| 22 |
+
ui.addMessage(parts[0], "assistant");
|
| 23 |
+
await audio.speak(parts[0]);
|
| 24 |
+
|
| 25 |
+
if (parts[1]) {
|
| 26 |
+
ui.setStatusText("Assistant is thinking...");
|
| 27 |
+
await sleep(500);
|
| 28 |
+
|
| 29 |
+
ui.addTyping(false, "Thinking...");
|
| 30 |
+
await sleep(500);
|
| 31 |
+
ui.removeTyping();
|
| 32 |
+
|
| 33 |
+
ui.addMessage(parts[1], "assistant");
|
| 34 |
+
await audio.speak(parts[1]);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Ends the current active session, clears intervals, and returns the user to the dashboard.
|
| 40 |
+
*/
|
| 41 |
+
export async function endSession() {
|
| 42 |
+
if (state.sessionInterval) {
|
| 43 |
+
clearInterval(state.sessionInterval);
|
| 44 |
+
state.sessionInterval = null;
|
| 45 |
+
}
|
| 46 |
+
state.setSessionId(null);
|
| 47 |
+
state.setPatientId(null);
|
| 48 |
+
ui.switchScreen("dashboard");
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Sends a text message to the backend and handles the response.
|
| 53 |
+
*
|
| 54 |
+
* @param {string} message - The text message to send.
|
| 55 |
+
* @param {string|null} emotion - Optional emotion tag.
|
| 56 |
+
*/
|
| 57 |
+
/**
|
| 58 |
+
* Submits a new text message to the active session and waits for the AI response.
|
| 59 |
+
* @param {string} text - The user's input message.
|
| 60 |
+
* @returns {Promise<boolean>} True if successful, false otherwise.
|
| 61 |
+
*/
|
| 62 |
+
export async function recordMessage(text) {
|
| 63 |
+
return await sendTextMessage(text);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export async function sendTextMessage(message, emotion = null) {
|
| 67 |
+
ui.addMessage(message, "user");
|
| 68 |
+
ui.addTyping(false, "Analyzing...");
|
| 69 |
+
|
| 70 |
+
try {
|
| 71 |
+
const data = await api.sendChatText(state.sessionId, state.patientId, message, emotion);
|
| 72 |
+
ui.removeTyping();
|
| 73 |
+
|
| 74 |
+
if (data.session_id && data.session_id !== state.sessionId) {
|
| 75 |
+
state.setSessionId(data.session_id);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
await handleAssistantResponses(data.assistant_message);
|
| 79 |
+
|
| 80 |
+
// Only reset status text if audio is NOT enabled,
|
| 81 |
+
// otherwise audio.js will handle resetting it when speech finishes.
|
| 82 |
+
if (!state.isAudioOutputEnabled) {
|
| 83 |
+
ui.setStatusText(state.isVoiceMode ? "Hold Space to speak" : "Type your message");
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (data.risk_flagged) {
|
| 87 |
+
ui.showSafetyBanner();
|
| 88 |
+
} else {
|
| 89 |
+
ui.hideSafetyBanner();
|
| 90 |
+
}
|
| 91 |
+
} catch (err) {
|
| 92 |
+
ui.removeTyping();
|
| 93 |
+
if (err.message && (err.message.includes("Tokens Exhausted") || err.message.includes("429"))) {
|
| 94 |
+
ui.showAlert("Tokens Exhausted", "Your LLM provider tokens have been exhausted. Please wait or upgrade your plan.");
|
| 95 |
+
} else {
|
| 96 |
+
ui.addMessage("I apologize, but I'm having trouble connecting right now.", "assistant");
|
| 97 |
+
}
|
| 98 |
+
console.error("Error:", err);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Starts a new session for the given patient ID.
|
| 104 |
+
* Transitions UI to main app and starts session timers.
|
| 105 |
+
*
|
| 106 |
+
* @param {string} pId - The patient ID to start the session for.
|
| 107 |
+
*/
|
| 108 |
+
/**
|
| 109 |
+
* Starts a new chat session with the AI for the currently selected patient.
|
| 110 |
+
* Emits an initial context message to the backend.
|
| 111 |
+
*/
|
| 112 |
+
export async function startActualSession(pId) {
|
| 113 |
+
try {
|
| 114 |
+
const data = await api.startSessionReq(pId);
|
| 115 |
+
state.setSessionId(data.session_id);
|
| 116 |
+
state.setPatientId(data.patient_id);
|
| 117 |
+
|
| 118 |
+
ui.switchScreen("mainApp");
|
| 119 |
+
document.getElementById("chat").innerHTML = "";
|
| 120 |
+
|
| 121 |
+
state.sessionStartTime = Date.now();
|
| 122 |
+
state.exchangeCount = 0;
|
| 123 |
+
ui.updateSessionStats();
|
| 124 |
+
if (state.sessionInterval) clearInterval(state.sessionInterval);
|
| 125 |
+
state.sessionInterval = setInterval(ui.updateSessionStats, 60000);
|
| 126 |
+
|
| 127 |
+
handleAssistantResponses(data.assistant_message);
|
| 128 |
+
} catch (e) {
|
| 129 |
+
console.error("Failed to start session:", e);
|
| 130 |
+
alert("Failed to start session. Please try again.");
|
| 131 |
+
location.reload();
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* Resumes an existing active session.
|
| 137 |
+
*
|
| 138 |
+
* @param {string} sessionId - The ID of the session to resume.
|
| 139 |
+
*/
|
| 140 |
+
export async function continueActualSession(sessionId) {
|
| 141 |
+
try {
|
| 142 |
+
state.setSessionId(sessionId);
|
| 143 |
+
ui.switchScreen("mainApp");
|
| 144 |
+
|
| 145 |
+
document.getElementById("chat").innerHTML = "";
|
| 146 |
+
|
| 147 |
+
const data = await api.getSessionMessages(sessionId);
|
| 148 |
+
if (data.messages) {
|
| 149 |
+
data.messages.forEach(msg => {
|
| 150 |
+
if (msg.role !== "system") {
|
| 151 |
+
if (msg.role === 'assistant') {
|
| 152 |
+
const parts = msg.content.split("&&").map(p => p.trim()).filter(Boolean);
|
| 153 |
+
parts.forEach(part => ui.addMessage(part, 'assistant'));
|
| 154 |
+
} else {
|
| 155 |
+
ui.addMessage(msg.content, msg.role);
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
});
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
state.sessionStartTime = Date.now();
|
| 162 |
+
state.exchangeCount = Math.floor((data.messages || []).length / 2);
|
| 163 |
+
ui.updateSessionStats();
|
| 164 |
+
if (state.sessionInterval) clearInterval(state.sessionInterval);
|
| 165 |
+
state.sessionInterval = setInterval(ui.updateSessionStats, 60000);
|
| 166 |
+
} catch (e) {
|
| 167 |
+
console.error("Failed to continue session:", e);
|
| 168 |
+
alert("Failed to continue session. Please try again.");
|
| 169 |
+
location.reload();
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* Pings the backend to check health and initializes routing based on URL path.
|
| 175 |
+
* Retries with exponential backoff if backend is not ready.
|
| 176 |
+
*
|
| 177 |
+
* @param {number} retryCount - Number of initialization retries so far.
|
| 178 |
+
*/
|
| 179 |
+
export async function initializeSession(retryCount = 0) {
|
| 180 |
+
const startButton = document.getElementById("startButton");
|
| 181 |
+
const wakeupText = document.getElementById("wakeupText");
|
| 182 |
+
|
| 183 |
+
if (retryCount === 0) {
|
| 184 |
+
if (wakeupText) wakeupText.textContent = "Waking up backend (this may take up to a minute)...";
|
| 185 |
+
if (startButton) {
|
| 186 |
+
startButton.disabled = false;
|
| 187 |
+
startButton.textContent = "Start Consultation";
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
try {
|
| 191 |
+
await api.pingHealth();
|
| 192 |
+
document.getElementById("loadingDots").style.display = "none";
|
| 193 |
+
if (wakeupText) wakeupText.style.display = "none";
|
| 194 |
+
|
| 195 |
+
if (startButton) {
|
| 196 |
+
startButton.disabled = false;
|
| 197 |
+
startButton.textContent = "Start Consultation";
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// Handle hash routing from OAuth or direct links
|
| 201 |
+
if (window.location.hash) {
|
| 202 |
+
const hash = window.location.hash.substring(1);
|
| 203 |
+
let newPath = "/";
|
| 204 |
+
if (hash === "dashboardScreen") newPath = "/dashboard";
|
| 205 |
+
else if (hash === "mainApp") newPath = "/session";
|
| 206 |
+
|
| 207 |
+
if (newPath !== "/") {
|
| 208 |
+
window.history.replaceState(null, "", newPath);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Handle deep linking / routing on load
|
| 213 |
+
const path = window.location.pathname;
|
| 214 |
+
if (path === "/dashboard") {
|
| 215 |
+
// Always re-fetch on refresh — state.patientId is in-memory and lost on reload
|
| 216 |
+
await profiles.fetchPatients();
|
| 217 |
+
} else if (path === "/session") {
|
| 218 |
+
if (!state.sessionId) {
|
| 219 |
+
window.location.href = "/";
|
| 220 |
+
} else {
|
| 221 |
+
ui.switchScreen("mainApp", false);
|
| 222 |
+
}
|
| 223 |
+
} else {
|
| 224 |
+
// Allow user to click the start button
|
| 225 |
+
}
|
| 226 |
+
} catch (error) {
|
| 227 |
+
console.error(`Backend not ready (attempt ${retryCount + 1}):`, error);
|
| 228 |
+
if (retryCount < 15) {
|
| 229 |
+
const delay = Math.min(2000 * Math.pow(1.3, retryCount), 30000);
|
| 230 |
+
setTimeout(() => initializeSession(retryCount + 1), delay);
|
| 231 |
+
} else {
|
| 232 |
+
document.getElementById("loadingDots").style.display = "none";
|
| 233 |
+
if (startButton) {
|
| 234 |
+
startButton.disabled = false;
|
| 235 |
+
startButton.textContent = "Retry Connection";
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/**
|
| 242 |
+
* Renews the browser's TTS user-activation token synchronously.
|
| 243 |
+
* Must be called directly inside an onclick or keydown/keyup handler.
|
| 244 |
+
*/
|
| 245 |
+
export function primeTTS() {
|
| 246 |
+
if (state.isAudioOutputEnabled && window.speechSynthesis) {
|
| 247 |
+
const primer = new SpeechSynthesisUtterance(' ');
|
| 248 |
+
primer.volume = 0;
|
| 249 |
+
primer.rate = 10;
|
| 250 |
+
window.speechSynthesis.speak(primer);
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
/**
|
| 255 |
+
* Toggle audio output on/off and update the button visuals.
|
| 256 |
+
*
|
| 257 |
+
* @param {boolean} enabled - Whether audio should be enabled.
|
| 258 |
+
*/
|
| 259 |
+
export function setAudioOutput(enabled) {
|
| 260 |
+
state.isAudioOutputEnabled = enabled;
|
| 261 |
+
|
| 262 |
+
const mobileToggle = document.getElementById("audioOutputToggleMobile");
|
| 263 |
+
const track = document.getElementById("audioToggleTrack");
|
| 264 |
+
const dot = document.getElementById("audioToggleDot");
|
| 265 |
+
const iconMobile = document.getElementById("audioToggleIconMobile");
|
| 266 |
+
|
| 267 |
+
const desktopToggle = document.getElementById("audioOutputToggleDesktop");
|
| 268 |
+
const labelDesktop = document.getElementById("audioToggleLabelDesktop");
|
| 269 |
+
|
| 270 |
+
if (mobileToggle && mobileToggle.checked !== enabled) {
|
| 271 |
+
mobileToggle.checked = enabled;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
if (enabled) {
|
| 275 |
+
// Prime the TTS engine with a silent utterance linked to this click gesture.
|
| 276 |
+
if (window.speechSynthesis) {
|
| 277 |
+
window.speechSynthesis.getVoices();
|
| 278 |
+
const primer = new SpeechSynthesisUtterance(' ');
|
| 279 |
+
primer.volume = 0;
|
| 280 |
+
primer.rate = 10;
|
| 281 |
+
window.speechSynthesis.speak(primer);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
// Update mobile UI
|
| 285 |
+
if (track) { track.classList.remove("bg-ink/30"); track.classList.add("bg-moss"); }
|
| 286 |
+
if (dot) { dot.classList.add("translate-x-4"); }
|
| 287 |
+
if (iconMobile) { iconMobile.classList.remove("text-ink/50"); iconMobile.classList.add("text-moss"); }
|
| 288 |
+
|
| 289 |
+
// Update desktop UI
|
| 290 |
+
if (desktopToggle) {
|
| 291 |
+
desktopToggle.classList.add("bg-moss", "border-moss", "text-paper");
|
| 292 |
+
desktopToggle.classList.remove("text-ink", "border-ink");
|
| 293 |
+
}
|
| 294 |
+
if (labelDesktop) labelDesktop.textContent = "Audio On";
|
| 295 |
+
|
| 296 |
+
} else {
|
| 297 |
+
if (window.speechSynthesis && window.speechSynthesis.speaking) {
|
| 298 |
+
window.speechSynthesis.cancel();
|
| 299 |
+
}
|
| 300 |
+
state.isAISpeaking = false;
|
| 301 |
+
|
| 302 |
+
// Update mobile UI
|
| 303 |
+
if (track) { track.classList.remove("bg-moss"); track.classList.add("bg-ink/30"); }
|
| 304 |
+
if (dot) { dot.classList.remove("translate-x-4"); }
|
| 305 |
+
if (iconMobile) { iconMobile.classList.remove("text-moss"); iconMobile.classList.add("text-ink/50"); }
|
| 306 |
+
|
| 307 |
+
// Update desktop UI
|
| 308 |
+
if (desktopToggle) {
|
| 309 |
+
desktopToggle.classList.remove("bg-moss", "border-moss", "text-paper");
|
| 310 |
+
desktopToggle.classList.add("text-ink", "border-ink");
|
| 311 |
+
}
|
| 312 |
+
if (labelDesktop) labelDesktop.textContent = "Audio Off";
|
| 313 |
+
}
|
| 314 |
+
}
|
static/js/state.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const state = {
|
| 2 |
+
sessionId: sessionStorage.getItem("serinity_session_id") || null,
|
| 3 |
+
patientId: localStorage.getItem("serinity_patient_id") || null,
|
| 4 |
+
isListening: false,
|
| 5 |
+
isRecordingPending: false,
|
| 6 |
+
isVoiceMode: false,
|
| 7 |
+
isAudioOutputEnabled: false,
|
| 8 |
+
isAISpeaking: false,
|
| 9 |
+
exchangeCount: 0,
|
| 10 |
+
sessionStartTime: null,
|
| 11 |
+
sessionInterval: null,
|
| 12 |
+
|
| 13 |
+
setPatientId(id) {
|
| 14 |
+
this.patientId = id;
|
| 15 |
+
if (id) {
|
| 16 |
+
localStorage.setItem("serinity_patient_id", id);
|
| 17 |
+
} else {
|
| 18 |
+
localStorage.removeItem("serinity_patient_id");
|
| 19 |
+
}
|
| 20 |
+
},
|
| 21 |
+
|
| 22 |
+
setSessionId(id) {
|
| 23 |
+
this.sessionId = id;
|
| 24 |
+
if (id) {
|
| 25 |
+
sessionStorage.setItem("serinity_session_id", id);
|
| 26 |
+
} else {
|
| 27 |
+
sessionStorage.removeItem("serinity_session_id");
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
};
|
static/js/ui.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { state } from './state.js';
|
| 2 |
+
|
| 3 |
+
// Cache DOM elements that exist on load
|
| 4 |
+
const uiElements = {
|
| 5 |
+
chat: document.getElementById("chat"),
|
| 6 |
+
chatWrapper: document.getElementById("chatWrapper"),
|
| 7 |
+
statusText: document.getElementById("status"),
|
| 8 |
+
micBtn: document.getElementById("micBtn"),
|
| 9 |
+
sessionStats: document.getElementById("sessionStats"),
|
| 10 |
+
voiceModeBtn: document.getElementById("voiceModeBtn"),
|
| 11 |
+
textModeBtn: document.getElementById("textModeBtn"),
|
| 12 |
+
voiceInput: document.getElementById("voiceInput"),
|
| 13 |
+
textInput: document.getElementById("textInput"),
|
| 14 |
+
chatInput: document.getElementById("chatInput")
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
let typingNode = null;
|
| 18 |
+
|
| 19 |
+
const screens = ["loadingScreen", "authScreen", "dashboardScreen", "mainApp"];
|
| 20 |
+
|
| 21 |
+
export function getPathForScreen(screen) {
|
| 22 |
+
const map = {
|
| 23 |
+
"loadingScreen": "/",
|
| 24 |
+
"authScreen": "/login",
|
| 25 |
+
"dashboardScreen": "/dashboard",
|
| 26 |
+
"mainApp": "/session"
|
| 27 |
+
};
|
| 28 |
+
return map[screen] || "/";
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function switchScreen(targetScreen, pushToHistory = true) {
|
| 32 |
+
window.currentScreen = targetScreen;
|
| 33 |
+
screens.forEach(s => {
|
| 34 |
+
const el = document.getElementById(s);
|
| 35 |
+
if (el) {
|
| 36 |
+
if (s === targetScreen) {
|
| 37 |
+
if (s === 'mainApp' || s === 'dashboardScreen') el.classList.remove("hidden");
|
| 38 |
+
el.style.display = "flex";
|
| 39 |
+
} else {
|
| 40 |
+
el.style.display = "none";
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
if (pushToHistory) {
|
| 46 |
+
history.pushState({ screen: targetScreen }, "", getPathForScreen(targetScreen));
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export function scrollToBottom() {
|
| 51 |
+
setTimeout(() => {
|
| 52 |
+
if (uiElements.chatWrapper) {
|
| 53 |
+
uiElements.chatWrapper.scrollTop = uiElements.chatWrapper.scrollHeight;
|
| 54 |
+
}
|
| 55 |
+
}, 100);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function updateSessionStats() {
|
| 59 |
+
if (!state.sessionStartTime) return;
|
| 60 |
+
if (!uiElements.sessionStats) return;
|
| 61 |
+
const elapsed = Math.floor((Date.now() - state.sessionStartTime) / 60000);
|
| 62 |
+
uiElements.sessionStats.textContent = `Session · ${elapsed} min · ${state.exchangeCount} exchanges`;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function addMessage(text, role) {
|
| 66 |
+
const messageDiv = document.createElement("div");
|
| 67 |
+
messageDiv.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'} fade-in w-full mb-6`;
|
| 68 |
+
|
| 69 |
+
const contentDiv = document.createElement("div");
|
| 70 |
+
|
| 71 |
+
if (role === 'assistant') {
|
| 72 |
+
const label = document.createElement("span");
|
| 73 |
+
label.className = "assistant-label";
|
| 74 |
+
label.textContent = "ASSISTANT ·";
|
| 75 |
+
contentDiv.appendChild(label);
|
| 76 |
+
contentDiv.className = "pl-4 border-l-2 border-moss max-w-[85%] sm:max-w-[75%]";
|
| 77 |
+
} else {
|
| 78 |
+
const label = document.createElement("span");
|
| 79 |
+
label.className = "assistant-label text-right block";
|
| 80 |
+
const dashName = document.getElementById("dashboardPatientName");
|
| 81 |
+
const userName = dashName ? dashName.innerText : "USER";
|
| 82 |
+
label.textContent = "· " + userName.toUpperCase();
|
| 83 |
+
contentDiv.appendChild(label);
|
| 84 |
+
contentDiv.className = "pr-4 border-r-2 border-clay text-right max-w-[85%] sm:max-w-[75%]";
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const textNode = document.createElement("p");
|
| 88 |
+
textNode.className = "text-ink whitespace-pre-wrap";
|
| 89 |
+
textNode.textContent = text;
|
| 90 |
+
contentDiv.appendChild(textNode);
|
| 91 |
+
|
| 92 |
+
messageDiv.appendChild(contentDiv);
|
| 93 |
+
if (uiElements.chat) {
|
| 94 |
+
uiElements.chat.appendChild(messageDiv);
|
| 95 |
+
scrollToBottom();
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
if (role === 'user') {
|
| 99 |
+
state.exchangeCount++;
|
| 100 |
+
updateSessionStats();
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
export function addTyping(isUser = false, customText = null) {
|
| 105 |
+
if (typingNode) return;
|
| 106 |
+
|
| 107 |
+
const messageDiv = document.createElement("div");
|
| 108 |
+
messageDiv.className = `flex ${isUser ? 'justify-end' : 'justify-start'} fade-in w-full mb-6`;
|
| 109 |
+
|
| 110 |
+
const contentDiv = document.createElement("div");
|
| 111 |
+
|
| 112 |
+
if (!isUser) {
|
| 113 |
+
const label = document.createElement("span");
|
| 114 |
+
label.className = "assistant-label";
|
| 115 |
+
label.textContent = "ASSISTANT ·";
|
| 116 |
+
contentDiv.appendChild(label);
|
| 117 |
+
contentDiv.className = "pl-4 border-l-2 border-moss max-w-[85%] sm:max-w-[75%]";
|
| 118 |
+
} else {
|
| 119 |
+
const label = document.createElement("span");
|
| 120 |
+
label.className = "assistant-label text-right block";
|
| 121 |
+
const dashName = document.getElementById("dashboardPatientName");
|
| 122 |
+
const userName = dashName ? dashName.innerText : "USER";
|
| 123 |
+
label.textContent = "· " + userName.toUpperCase();
|
| 124 |
+
contentDiv.appendChild(label);
|
| 125 |
+
contentDiv.className = "pr-4 border-r-2 border-clay text-right max-w-[85%] sm:max-w-[75%]";
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
if (customText) {
|
| 129 |
+
const textNode = document.createElement("p");
|
| 130 |
+
textNode.className = "text-moss text-sm italic font-utility animate-pulse";
|
| 131 |
+
textNode.textContent = customText;
|
| 132 |
+
contentDiv.appendChild(textNode);
|
| 133 |
+
} else {
|
| 134 |
+
const indicator = document.createElement("div");
|
| 135 |
+
indicator.className = "typing-indicator flex gap-1 mt-2" + (isUser ? " justify-end" : "");
|
| 136 |
+
indicator.innerHTML = `<span></span><span></span><span></span>`;
|
| 137 |
+
contentDiv.appendChild(indicator);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
messageDiv.appendChild(contentDiv);
|
| 141 |
+
|
| 142 |
+
typingNode = messageDiv;
|
| 143 |
+
if (uiElements.chat) {
|
| 144 |
+
uiElements.chat.appendChild(messageDiv);
|
| 145 |
+
scrollToBottom();
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
export function removeTyping() {
|
| 150 |
+
if (!typingNode) return;
|
| 151 |
+
typingNode.remove();
|
| 152 |
+
typingNode = null;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
export function setStatusText(text) {
|
| 156 |
+
if (uiElements.statusText) {
|
| 157 |
+
uiElements.statusText.textContent = text;
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
export function toggleMicButtonVisuals(isRecording) {
|
| 162 |
+
if (!uiElements.micBtn) return;
|
| 163 |
+
if (isRecording) {
|
| 164 |
+
uiElements.micBtn.classList.add("bg-moss", "border-moss", "text-paper");
|
| 165 |
+
} else {
|
| 166 |
+
uiElements.micBtn.classList.remove("bg-moss", "border-moss", "text-paper");
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
export function showSafetyBanner() {
|
| 171 |
+
if (!document.getElementById("safetyBanner")) {
|
| 172 |
+
const banner = document.createElement("div");
|
| 173 |
+
banner.id = "safetyBanner";
|
| 174 |
+
banner.className = "bg-signal text-paper p-4 shadow-md z-50 fixed top-0 left-0 right-0 fade-in";
|
| 175 |
+
banner.innerHTML = `
|
| 176 |
+
<div class="max-w-5xl mx-auto flex w-full justify-between items-start">
|
| 177 |
+
<div>
|
| 178 |
+
<h3 class="font-utility uppercase tracking-widest text-sm mb-1 font-bold">Emergency Support</h3>
|
| 179 |
+
<p class="text-sm">You are not alone. Free, confidential support is available right now.</p>
|
| 180 |
+
<ul class="list-none text-sm font-utility space-y-1 mt-2">
|
| 181 |
+
<li>iCall (TISS): 9152987821</li>
|
| 182 |
+
<li>Kiran: 1800-599-0019</li>
|
| 183 |
+
<li>Vandrevala: 1860-2662-345</li>
|
| 184 |
+
</ul>
|
| 185 |
+
</div>
|
| 186 |
+
<button onclick="document.getElementById('safetyBanner').remove()" class="text-paper hover:opacity-70 text-2xl font-bold p-2 cursor-pointer leading-none">×</button>
|
| 187 |
+
</div>
|
| 188 |
+
`;
|
| 189 |
+
document.body.appendChild(banner);
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
export function hideSafetyBanner() {
|
| 194 |
+
const banner = document.getElementById("safetyBanner");
|
| 195 |
+
if (banner) {
|
| 196 |
+
banner.remove();
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
export function showConfirm(title, text, callback) {
|
| 201 |
+
const confirmationModal = document.getElementById("confirmationModal");
|
| 202 |
+
const confirmModalTitle = document.getElementById("confirmModalTitle");
|
| 203 |
+
const confirmModalText = document.getElementById("confirmModalText");
|
| 204 |
+
const confirmModalCancelBtn = document.getElementById("confirmModalCancelBtn");
|
| 205 |
+
const confirmModalConfirmBtn = document.getElementById("confirmModalConfirmBtn");
|
| 206 |
+
|
| 207 |
+
confirmModalTitle.textContent = title;
|
| 208 |
+
confirmModalText.textContent = text;
|
| 209 |
+
|
| 210 |
+
confirmModalCancelBtn.onclick = () => {
|
| 211 |
+
confirmationModal.style.display = "none";
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
confirmModalConfirmBtn.onclick = () => {
|
| 215 |
+
confirmationModal.style.display = "none";
|
| 216 |
+
if (callback) callback();
|
| 217 |
+
};
|
| 218 |
+
|
| 219 |
+
confirmationModal.style.display = "flex";
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
export function showAlert(title, text) {
|
| 223 |
+
const alertModal = document.getElementById("alertModal");
|
| 224 |
+
if (!alertModal) return alert(text); // Fallback
|
| 225 |
+
|
| 226 |
+
document.getElementById("alertModalTitle").textContent = title;
|
| 227 |
+
document.getElementById("alertModalText").textContent = text;
|
| 228 |
+
|
| 229 |
+
const closeModal = () => {
|
| 230 |
+
alertModal.style.display = "none";
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
document.getElementById("alertModalOkBtn").onclick = closeModal;
|
| 234 |
+
|
| 235 |
+
alertModal.onclick = (e) => {
|
| 236 |
+
if (e.target === alertModal) {
|
| 237 |
+
closeModal();
|
| 238 |
+
}
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
alertModal.style.display = "flex";
|
| 242 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Serinity - Mental Healthcare</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg">
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
<link rel="stylesheet" href="/static/css/style.css">
|
| 10 |
+
<script>
|
| 11 |
+
tailwind.config = {
|
| 12 |
+
theme: {
|
| 13 |
+
extend: {
|
| 14 |
+
colors: {
|
| 15 |
+
paper: '#F6F3EE',
|
| 16 |
+
ink: '#262521',
|
| 17 |
+
moss: '#4B6358',
|
| 18 |
+
clay: '#9C7A5B',
|
| 19 |
+
signal: '#B4483C'
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
</script>
|
| 25 |
+
</head>
|
| 26 |
+
<body class="bg-paper text-ink min-h-screen flex flex-col font-body">
|
| 27 |
+
|
| 28 |
+
<!-- Loading Screen -->
|
| 29 |
+
<div id="loadingScreen" class="fixed inset-0 bg-paper z-50 flex flex-col items-center justify-center transition-opacity duration-500">
|
| 30 |
+
<h1 class="logo-text">Serinity</h1>
|
| 31 |
+
<div class="loading-dots" id="loadingDots">
|
| 32 |
+
<span></span>
|
| 33 |
+
<span></span>
|
| 34 |
+
<span></span>
|
| 35 |
+
</div>
|
| 36 |
+
<button class="btn-primary mt-8" id="startButton" disabled>
|
| 37 |
+
Initializing...
|
| 38 |
+
</button>
|
| 39 |
+
<p id="wakeupText" class="text-sm text-clay mt-4 text-center max-w-xs mx-auto font-utility">
|
| 40 |
+
Validating credentials...
|
| 41 |
+
</p>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<!-- Auth Screen -->
|
| 45 |
+
<div id="authScreen" style="display: none;" class="fixed inset-0 bg-paper z-40 flex flex-col items-center justify-center p-4 overflow-y-auto custom-scrollbar">
|
| 46 |
+
<div class="panel p-8 max-w-md w-full my-auto shadow-sm">
|
| 47 |
+
|
| 48 |
+
<!-- Logo/Branding -->
|
| 49 |
+
<div class="flex items-center gap-3 mb-8 justify-center">
|
| 50 |
+
<img src="/static/icons/logo.svg" alt="Serinity Logo" class="w-10 h-10">
|
| 51 |
+
<h2 class="text-3xl font-display text-ink">Serinity</h2>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<!-- Login Form -->
|
| 55 |
+
<div id="loginFormContainer" class="block space-y-5">
|
| 56 |
+
<div>
|
| 57 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Username / Email *</label>
|
| 58 |
+
<input type="text" id="loginUsername" placeholder="Username or Email" class="w-full bg-paper border border-ink p-3 text-ink focus:outline-none focus:border-moss transition">
|
| 59 |
+
</div>
|
| 60 |
+
<div>
|
| 61 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Password</label>
|
| 62 |
+
<input type="password" id="loginPassword" class="w-full bg-paper border border-ink p-3 text-ink focus:outline-none focus:border-moss transition" placeholder="Enter password">
|
| 63 |
+
</div>
|
| 64 |
+
<div class="flex justify-between items-center text-xs font-utility uppercase tracking-widest mt-2">
|
| 65 |
+
<button id="showForgotPwdBtn" class="text-clay hover:text-ink transition">Forgot Password?</button>
|
| 66 |
+
</div>
|
| 67 |
+
<button id="loginBtn" class="w-full btn-primary py-3 font-utility uppercase tracking-widest text-sm mt-4">
|
| 68 |
+
Log In
|
| 69 |
+
</button>
|
| 70 |
+
<div class="text-center mt-6 border-t border-ink/20 pt-6">
|
| 71 |
+
<p class="text-xs text-clay font-utility uppercase tracking-widest mb-3">New Patient?</p>
|
| 72 |
+
<button id="showSignupBtn" class="w-full bg-transparent border border-ink text-ink hover:bg-ink hover:text-paper transition py-3 font-utility uppercase tracking-widest text-sm">
|
| 73 |
+
Create Account
|
| 74 |
+
</button>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<!-- Signup Form -->
|
| 79 |
+
<div id="signupFormContainer" class="hidden space-y-4">
|
| 80 |
+
<div>
|
| 81 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Full Name *</label>
|
| 82 |
+
<input type="text" id="signupName" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss" placeholder="Jane Doe">
|
| 83 |
+
</div>
|
| 84 |
+
<div class="grid grid-cols-3 gap-4">
|
| 85 |
+
<div>
|
| 86 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Username *</label>
|
| 87 |
+
<input type="text" id="signupUsername" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss">
|
| 88 |
+
</div>
|
| 89 |
+
<div>
|
| 90 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Gender *</label>
|
| 91 |
+
<select id="signupGender" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss">
|
| 92 |
+
<option value="">Select</option>
|
| 93 |
+
<option value="Male">Male</option>
|
| 94 |
+
<option value="Female">Female</option>
|
| 95 |
+
<option value="Other">Other</option>
|
| 96 |
+
</select>
|
| 97 |
+
</div>
|
| 98 |
+
<div>
|
| 99 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Age</label>
|
| 100 |
+
<input type="number" id="signupAge" min="5" max="99" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss">
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
<div>
|
| 104 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Email *</label>
|
| 105 |
+
<input type="email" id="signupEmail" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss" placeholder="jane@example.com">
|
| 106 |
+
</div>
|
| 107 |
+
<div>
|
| 108 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Password *</label>
|
| 109 |
+
<input type="password" id="signupPassword" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss">
|
| 110 |
+
</div>
|
| 111 |
+
<div class="grid grid-cols-2 gap-4">
|
| 112 |
+
<div class="relative" id="nationalityWrapper">
|
| 113 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1" title="For cultural context">Nationality *</label>
|
| 114 |
+
<input type="text" id="signupNationality" autocomplete="off" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss" placeholder="e.g. India">
|
| 115 |
+
<div id="nationalityDropdown" class="hidden absolute left-0 right-0 top-full mt-1 bg-paper border border-ink z-50 max-h-48 overflow-y-auto shadow-lg">
|
| 116 |
+
<!-- Populated by JS -->
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
<div>
|
| 120 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Primary Concern *</label>
|
| 121 |
+
<select id="signupConcern" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss">
|
| 122 |
+
<option value="">Select...</option>
|
| 123 |
+
<option value="Anxiety">Anxiety</option>
|
| 124 |
+
<option value="Depression">Depression</option>
|
| 125 |
+
<option value="Stress">Stress</option>
|
| 126 |
+
<option value="Sleep">Sleep Issues</option>
|
| 127 |
+
<option value="General">General Wellness</option>
|
| 128 |
+
<option value="Other">Other</option>
|
| 129 |
+
</select>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
<div class="grid grid-cols-2 gap-4">
|
| 133 |
+
<div>
|
| 134 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Emergency Contact Name</label>
|
| 135 |
+
<input type="text" id="signupEmergencyName" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss" placeholder="John Doe">
|
| 136 |
+
</div>
|
| 137 |
+
<div>
|
| 138 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Emergency Contact Phone</label>
|
| 139 |
+
<input type="tel" id="signupEmergencyPhone" class="w-full bg-paper border border-ink p-2 text-ink focus:outline-none focus:border-moss" placeholder="+1 234 567 8900">
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<button id="signupGetOtpBtn" class="w-full btn-primary py-3 font-utility uppercase tracking-widest text-sm mt-4">
|
| 144 |
+
Get OTP
|
| 145 |
+
</button>
|
| 146 |
+
|
| 147 |
+
<button id="backToLoginBtn1" class="w-full bg-transparent text-clay hover:text-ink transition py-3 font-utility uppercase tracking-widest text-xs mt-2">
|
| 148 |
+
← Back to Login
|
| 149 |
+
</button>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<!-- Forgot Password Form -->
|
| 153 |
+
<div id="forgotPwdFormContainer" class="hidden space-y-4">
|
| 154 |
+
<p class="text-sm text-clay font-body mb-4">Enter your email address and we will send you a 6-digit verification code to reset your password.</p>
|
| 155 |
+
<div>
|
| 156 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">Email Address</label>
|
| 157 |
+
<input type="email" id="forgotPwdEmail" class="w-full bg-paper border border-ink p-3 text-ink focus:outline-none focus:border-moss transition" placeholder="jane@example.com">
|
| 158 |
+
</div>
|
| 159 |
+
<button id="requestOtpBtn" class="w-full btn-primary py-3 font-utility uppercase tracking-widest text-sm mt-2">
|
| 160 |
+
Send Code
|
| 161 |
+
</button>
|
| 162 |
+
<button id="backToLoginBtn2" class="w-full bg-transparent text-clay hover:text-ink transition py-3 font-utility uppercase tracking-widest text-xs mt-2">
|
| 163 |
+
← Back to Login
|
| 164 |
+
</button>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<!-- Reset Password Step 1: OTP Verification -->
|
| 168 |
+
<div id="otpStepContainer" class="hidden space-y-4">
|
| 169 |
+
<p class="text-sm text-clay font-body mb-4 text-center">We've sent a 6-digit code to your email.</p>
|
| 170 |
+
<div>
|
| 171 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">6-Digit Code</label>
|
| 172 |
+
<input type="text" id="resetOtpCode" class="w-full bg-paper border border-ink p-3 text-ink focus:outline-none focus:border-moss text-center tracking-widest font-mono text-lg" placeholder="000000" maxlength="6" inputmode="numeric">
|
| 173 |
+
</div>
|
| 174 |
+
<button id="verifyOtpBtn" class="w-full btn-primary py-3 font-utility uppercase tracking-widest text-sm mt-2">
|
| 175 |
+
Verify Code
|
| 176 |
+
</button>
|
| 177 |
+
<button id="backToForgotBtn" class="w-full bg-transparent text-clay hover:text-ink transition py-3 font-utility uppercase tracking-widest text-xs mt-2">
|
| 178 |
+
← Back
|
| 179 |
+
</button>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<!-- Reset Password Step 2: New Password (no back button) -->
|
| 183 |
+
<div id="newPwdStepContainer" class="hidden space-y-4">
|
| 184 |
+
<p class="text-sm text-clay font-body mb-4 text-center">OTP verified. Enter your new password below.</p>
|
| 185 |
+
<div>
|
| 186 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">New Password</label>
|
| 187 |
+
<input type="password" id="resetNewPassword" class="w-full bg-paper border border-ink p-3 text-ink focus:outline-none focus:border-moss" placeholder="Min. 8 characters">
|
| 188 |
+
</div>
|
| 189 |
+
<button id="submitResetBtn" class="w-full btn-primary py-3 font-utility uppercase tracking-widest text-sm mt-2">
|
| 190 |
+
Update Password
|
| 191 |
+
</button>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<!-- Signup Step 2: OTP Verification -->
|
| 195 |
+
<div id="signupOtpStepContainer" class="hidden space-y-4">
|
| 196 |
+
<p class="text-sm text-clay font-body mb-4 text-center">We've sent a 6-digit code to your email.</p>
|
| 197 |
+
<div>
|
| 198 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">6-Digit Code *</label>
|
| 199 |
+
<input type="text" id="signupOtpCode" class="w-full bg-paper border border-ink p-3 text-ink focus:outline-none focus:border-moss text-center tracking-widest font-mono text-lg" placeholder="000000" maxlength="6" inputmode="numeric">
|
| 200 |
+
</div>
|
| 201 |
+
<button id="signupCompleteBtn" class="w-full btn-primary py-3 font-utility uppercase tracking-widest text-sm mt-2">
|
| 202 |
+
Complete Signup
|
| 203 |
+
</button>
|
| 204 |
+
<button id="backToSignupBtn" class="w-full bg-transparent text-clay hover:text-ink transition py-3 font-utility uppercase tracking-widest text-xs mt-2">
|
| 205 |
+
← Back to Signup
|
| 206 |
+
</button>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<!-- Profile Selection Screen Removed -->
|
| 213 |
+
|
| 214 |
+
<!-- Patient Dashboard -->
|
| 215 |
+
<div class="hidden max-w-5xl mx-auto w-full h-screen p-4 flex-col fade-in overflow-hidden" id="dashboardScreen">
|
| 216 |
+
<div class="bg-paper border border-ink p-6 lg:p-10 flex-1 flex flex-col min-h-0 h-full">
|
| 217 |
+
|
| 218 |
+
<!-- Header -->
|
| 219 |
+
<div class="flex justify-between items-start border-b border-ink/30 pb-6 mb-6 shrink-0">
|
| 220 |
+
<div>
|
| 221 |
+
<h2 class="text-3xl font-display text-ink" id="dashboardPatientName">Patient Name</h2>
|
| 222 |
+
<p class="text-xs font-utility tracking-widest uppercase text-clay mt-2" id="dashboardPatientAge">Age: --</p>
|
| 223 |
+
</div>
|
| 224 |
+
<button class="btn-primary" id="dashboardStartBtn">NEW SESSION</button>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<!-- Content -->
|
| 228 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 flex-1 min-h-0 overflow-hidden" id="dashboardContent">
|
| 229 |
+
<!-- Profile left -->
|
| 230 |
+
<div class="space-y-6 overflow-y-auto pr-4 custom-scrollbar" id="dashboardDomains">
|
| 231 |
+
<h3 class="font-utility uppercase text-clay text-sm border-b border-ink/30 pb-2">Clinical Profile</h3>
|
| 232 |
+
<p class="text-clay text-sm italic">Loading profile data...</p>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
<!-- Column 2: Session History -->
|
| 236 |
+
<div class="flex flex-col min-h-0 h-full">
|
| 237 |
+
<h3 class="font-utility uppercase text-clay text-sm border-b border-ink/30 pb-2 mb-4 shrink-0">Session History</h3>
|
| 238 |
+
<div id="dashboardSessionsList" class="flex-1 overflow-y-auto pr-4 space-y-4 custom-scrollbar">
|
| 239 |
+
<p class="text-clay text-sm italic">Loading previous sessions...</p>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
<div class="mt-8 pt-6 border-t border-ink/30 shrink-0 flex flex-wrap justify-between items-center gap-4">
|
| 245 |
+
<button class="text-xs font-utility tracking-widest text-clay hover:text-ink uppercase transition flex items-center gap-2" id="dashboardLogoutBtn">
|
| 246 |
+
<span>←</span> LOGOUT
|
| 247 |
+
</button>
|
| 248 |
+
<div class="flex gap-4">
|
| 249 |
+
<button class="text-xs font-utility tracking-widest text-signal border border-signal hover:bg-signal hover:text-paper uppercase transition px-4 py-2 flex items-center gap-2" id="dashboardResetBtn">
|
| 250 |
+
RESET DATA
|
| 251 |
+
</button>
|
| 252 |
+
<button class="text-xs font-utility tracking-widest text-paper bg-signal hover:bg-signal/90 uppercase transition px-4 py-2 flex items-center gap-2" id="dashboardDeleteBtn">
|
| 253 |
+
DELETE ACCOUNT
|
| 254 |
+
</button>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
|
| 260 |
+
<!-- Main App (Hidden Initially) -->
|
| 261 |
+
<div id="mainApp" style="display: none;" class="flex flex-col h-screen">
|
| 262 |
+
<!-- Header -->
|
| 263 |
+
<header class="bg-paper border-b border-ink sticky top-0 z-50 py-2 md:py-3 shadow-sm">
|
| 264 |
+
<div class="w-full px-3 md:px-12 mx-auto flex items-center justify-between gap-1 md:gap-2">
|
| 265 |
+
|
| 266 |
+
<!-- Left: Logo & Title -->
|
| 267 |
+
<div class="flex items-center gap-2 md:gap-4 shrink-0 min-w-0">
|
| 268 |
+
<img src="/static/icons/logo.svg" alt="Serinity Logo" class="w-8 h-8 md:w-10 md:h-10 opacity-90 shrink-0">
|
| 269 |
+
<div class="flex flex-col justify-center min-w-0">
|
| 270 |
+
<h1 class="text-xl md:text-3xl font-display text-ink leading-none truncate" id="headerName">Serinity</h1>
|
| 271 |
+
<p class="text-[10px] md:text-xs font-utility text-clay tracking-widest uppercase mt-0.5 truncate hidden sm:block">Clinical Session</p>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
<!-- Right: Controls -->
|
| 276 |
+
<div class="flex items-center gap-2 shrink-0">
|
| 277 |
+
<span id="sessionStats" class="text-xs font-utility text-moss uppercase hidden lg:block"></span>
|
| 278 |
+
|
| 279 |
+
<select
|
| 280 |
+
id="voiceSelect"
|
| 281 |
+
class="bg-paper border border-ink text-ink text-xs md:text-sm font-utility px-1 py-1.5 outline-none focus:border-moss w-[70px] sm:w-[140px] md:w-[200px] cursor-pointer hover:bg-ink/5 truncate shrink-0"
|
| 282 |
+
>
|
| 283 |
+
<option value="">Voice: Off</option>
|
| 284 |
+
</select>
|
| 285 |
+
|
| 286 |
+
<!-- Mobile Slider Toggle -->
|
| 287 |
+
<label class="flex sm:hidden items-center cursor-pointer gap-1.5 shrink-0" title="Toggle Audio Response">
|
| 288 |
+
<div class="relative flex items-center">
|
| 289 |
+
<input type="checkbox" id="audioOutputToggleMobile" class="sr-only">
|
| 290 |
+
<div id="audioToggleTrack" class="block bg-ink/30 w-10 h-6 rounded-full transition-colors duration-200"></div>
|
| 291 |
+
<div id="audioToggleDot" class="dot absolute left-1 top-1 bg-paper w-4 h-4 rounded-full transition-transform duration-200"></div>
|
| 292 |
+
</div>
|
| 293 |
+
<svg id="audioToggleIconMobile" class="w-4 h-4 text-ink/50 transition-colors duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 294 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 8.5H4a1 1 0 00-1 1v5a1 1 0 001 1h2.5l4 4V4.5l-4 4z"></path>
|
| 295 |
+
</svg>
|
| 296 |
+
</label>
|
| 297 |
+
|
| 298 |
+
<!-- Desktop Button Toggle -->
|
| 299 |
+
<button
|
| 300 |
+
id="audioOutputToggleDesktop"
|
| 301 |
+
class="hidden sm:flex border border-ink text-ink hover:bg-moss hover:border-moss hover:text-paper transition-all items-center justify-center px-2 py-1.5 md:px-3 shrink-0 gap-1"
|
| 302 |
+
title="Toggle Audio Response"
|
| 303 |
+
>
|
| 304 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 305 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 8.5H4a1 1 0 00-1 1v5a1 1 0 001 1h2.5l4 4V4.5l-4 4z"></path>
|
| 306 |
+
</svg>
|
| 307 |
+
<span class="text-xs font-utility uppercase tracking-wide" id="audioToggleLabelDesktop">Audio Off</span>
|
| 308 |
+
</button>
|
| 309 |
+
|
| 310 |
+
<button
|
| 311 |
+
id="endSessionBtn"
|
| 312 |
+
class="text-signal hover:text-signal/70 transition-colors border border-signal flex items-center justify-center px-2 py-1.5 md:px-3 shrink-0"
|
| 313 |
+
title="End Session"
|
| 314 |
+
>
|
| 315 |
+
<span class="text-xs md:text-sm font-utility uppercase tracking-wide md:hidden">End</span>
|
| 316 |
+
<span class="hidden md:inline text-sm font-utility uppercase tracking-wide">End Session</span>
|
| 317 |
+
</button>
|
| 318 |
+
</div>
|
| 319 |
+
|
| 320 |
+
</div>
|
| 321 |
+
</header>
|
| 322 |
+
|
| 323 |
+
<!-- Chat Container -->
|
| 324 |
+
<div class="flex-1 overflow-hidden flex flex-col w-full">
|
| 325 |
+
<div id="chatWrapper" class="flex-1 overflow-y-auto px-6 md:px-12 py-8 custom-scrollbar">
|
| 326 |
+
<div id="chat" class="flex flex-col gap-6 max-w-3xl mx-auto pb-8"></div>
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
<!-- Fixed Bottom Controls -->
|
| 331 |
+
<div class="bg-paper border-t border-ink sticky bottom-0">
|
| 332 |
+
<div class="w-full px-6 md:px-12 mx-auto py-4">
|
| 333 |
+
<!-- Input Mode Toggle -->
|
| 334 |
+
<div class="flex justify-center gap-4 mb-4">
|
| 335 |
+
<button id="textModeBtn" class="btn-toggle active uppercase tracking-wider text-xs">Text</button>
|
| 336 |
+
<button id="voiceModeBtn" class="btn-toggle uppercase tracking-wider text-xs">Audio</button>
|
| 337 |
+
</div>
|
| 338 |
+
|
| 339 |
+
<!-- Voice Input -->
|
| 340 |
+
<div id="voiceInput" class="hidden flex-col items-center gap-3">
|
| 341 |
+
<button
|
| 342 |
+
id="micBtn"
|
| 343 |
+
class="w-14 h-14 border border-ink rounded-full flex items-center justify-center transition-all duration-200 hover:bg-moss hover:border-moss hover:text-paper group text-ink"
|
| 344 |
+
style="touch-action: none;"
|
| 345 |
+
>
|
| 346 |
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 347 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
|
| 348 |
+
</svg>
|
| 349 |
+
</button>
|
| 350 |
+
<p id="status" class="text-xs font-utility text-clay uppercase tracking-widest">Hold Space to speak</p>
|
| 351 |
+
</div>
|
| 352 |
+
|
| 353 |
+
<!-- Text Input -->
|
| 354 |
+
<div id="textInput" class="block">
|
| 355 |
+
<div class="chat-input-container max-w-3xl mx-auto">
|
| 356 |
+
<input
|
| 357 |
+
type="text"
|
| 358 |
+
id="chatInput"
|
| 359 |
+
class="chat-input"
|
| 360 |
+
placeholder="Enter message..."
|
| 361 |
+
autocomplete="off"
|
| 362 |
+
/>
|
| 363 |
+
<button id="sendBtn" class="px-4 py-2 bg-ink text-paper text-sm font-utility uppercase transition-colors hover:bg-moss">
|
| 364 |
+
Submit
|
| 365 |
+
</button>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
<!-- Confirmation Modal -->
|
| 373 |
+
<div id="confirmationModal" style="display: none;" class="fixed inset-0 bg-ink/50 backdrop-blur-sm z-[60] flex flex-col items-center justify-center p-4 fade-in">
|
| 374 |
+
<div class="bg-paper border border-signal p-8 max-w-sm w-full shadow-lg">
|
| 375 |
+
<h2 class="text-xl font-display text-signal mb-2" id="confirmModalTitle">Are you sure?</h2>
|
| 376 |
+
<p class="text-clay text-sm mb-6" id="confirmModalText">This action cannot be undone.</p>
|
| 377 |
+
|
| 378 |
+
<div class="flex gap-4">
|
| 379 |
+
<button id="confirmModalCancelBtn" class="flex-1 bg-transparent border border-ink text-ink hover:bg-ink hover:text-paper transition py-2 font-utility uppercase tracking-widest text-sm">
|
| 380 |
+
CANCEL
|
| 381 |
+
</button>
|
| 382 |
+
<button id="confirmModalConfirmBtn" class="flex-1 bg-signal text-paper hover:bg-signal/80 transition py-2 font-utility uppercase tracking-widest text-sm">
|
| 383 |
+
CONFIRM
|
| 384 |
+
</button>
|
| 385 |
+
</div>
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
<!-- Continue Session Modal -->
|
| 390 |
+
<div id="continueSessionModal" style="display: none;" class="fixed inset-0 bg-ink/50 backdrop-blur-sm z-[60] flex flex-col items-center justify-center p-4 fade-in">
|
| 391 |
+
<div class="bg-paper border border-signal p-8 max-w-sm w-full shadow-lg">
|
| 392 |
+
<h2 class="text-xl font-display text-signal mb-2">Active Session Found</h2>
|
| 393 |
+
<p class="text-clay text-sm mb-6">You have a previous session that is still open. Would you like to pick up where you left off, or end it and start a new one?</p>
|
| 394 |
+
|
| 395 |
+
<div class="flex flex-col gap-3">
|
| 396 |
+
<button id="continueSessionBtn" class="w-full bg-signal text-paper hover:bg-signal/80 transition py-2 font-utility uppercase tracking-widest text-sm">
|
| 397 |
+
Continue Session
|
| 398 |
+
</button>
|
| 399 |
+
<button id="endAndStartNewBtn" class="w-full bg-transparent border border-ink text-ink hover:bg-ink hover:text-paper transition py-2 font-utility uppercase tracking-widest text-sm">
|
| 400 |
+
End & Start New
|
| 401 |
+
</button>
|
| 402 |
+
</div>
|
| 403 |
+
</div>
|
| 404 |
+
</div>
|
| 405 |
+
|
| 406 |
+
<!-- Alert Modal -->
|
| 407 |
+
<div id="alertModal" style="display: none;" class="fixed inset-0 bg-ink/50 backdrop-blur-sm z-[70] flex flex-col items-center justify-center p-4 fade-in">
|
| 408 |
+
<div class="bg-paper border border-ink p-8 max-w-sm w-full shadow-lg">
|
| 409 |
+
<h2 class="text-xl font-display text-ink mb-2" id="alertModalTitle">Notice</h2>
|
| 410 |
+
<p class="text-clay text-sm mb-6" id="alertModalText"></p>
|
| 411 |
+
<button id="alertModalOkBtn" class="w-full bg-ink text-paper hover:bg-moss transition py-2 font-utility uppercase tracking-widest text-sm">
|
| 412 |
+
OK
|
| 413 |
+
</button>
|
| 414 |
+
</div>
|
| 415 |
+
</div>
|
| 416 |
+
|
| 417 |
+
<!-- Delete Account Confirmation Modal -->
|
| 418 |
+
<div id="deleteAccountConfirmModal" style="display: none;" class="fixed inset-0 bg-ink/50 backdrop-blur-sm z-50 flex flex-col items-center justify-center p-4">
|
| 419 |
+
<div class="panel p-8 max-w-sm w-full shadow-lg">
|
| 420 |
+
<h2 class="text-xl font-display text-signal mb-2">Delete Account?</h2>
|
| 421 |
+
<p class="text-clay text-sm mb-6">Are you sure you want to completely delete your account? This action is irreversible and all data will be erased. An OTP will be sent to your email to verify.</p>
|
| 422 |
+
|
| 423 |
+
<div class="flex gap-4">
|
| 424 |
+
<button id="deleteAccountCancelBtn" class="flex-1 bg-transparent border border-ink text-ink hover:bg-ink hover:text-paper transition py-2 font-utility uppercase tracking-widest text-sm">
|
| 425 |
+
CANCEL
|
| 426 |
+
</button>
|
| 427 |
+
<button id="deleteAccountSendOtpBtn" class="flex-1 bg-signal text-paper hover:bg-signal/80 transition py-2 font-utility uppercase tracking-widest text-sm">
|
| 428 |
+
SEND OTP
|
| 429 |
+
</button>
|
| 430 |
+
</div>
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
|
| 434 |
+
<!-- Delete Account OTP Modal -->
|
| 435 |
+
<div id="deleteAccountOtpModal" style="display: none;" class="fixed inset-0 bg-ink/50 backdrop-blur-sm z-50 flex flex-col items-center justify-center p-4">
|
| 436 |
+
<div class="panel p-8 max-w-sm w-full shadow-lg">
|
| 437 |
+
<div class="flex justify-between items-center mb-6">
|
| 438 |
+
<h2 class="text-xl font-display text-signal">Delete Account</h2>
|
| 439 |
+
<button id="closeDeleteOtpModalBtn" class="text-clay hover:text-ink transition">×</button>
|
| 440 |
+
</div>
|
| 441 |
+
<p class="text-clay text-sm mb-6">An OTP has been sent to your email. Enter it below to permanently delete your account and all data.</p>
|
| 442 |
+
<div class="space-y-4">
|
| 443 |
+
<div>
|
| 444 |
+
<label class="block text-xs font-utility text-clay uppercase mb-1">6-Digit Code</label>
|
| 445 |
+
<input type="text" id="deleteAccountOtpCode" class="w-full bg-paper border border-ink p-3 text-ink focus:outline-none focus:border-moss text-center tracking-widest font-mono text-lg" placeholder="000000" maxlength="6">
|
| 446 |
+
</div>
|
| 447 |
+
<button id="submitDeleteOtpBtn" class="w-full bg-signal text-paper hover:bg-signal/90 transition py-3 font-utility uppercase tracking-widest text-sm mt-2">
|
| 448 |
+
Verify & Delete
|
| 449 |
+
</button>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
|
| 454 |
+
<script type="module" src="/static/js/app.js"></script>
|
| 455 |
+
</body>
|
| 456 |
+
</html>
|