this repo has no description
1<script lang="ts"> 2 import { getAuthState, refreshSession } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 import { _ } from '../lib/i18n' 6 import { formatDateTime } from '../lib/date' 7 const auth = getAuthState() 8 let loading = $state(true) 9 let saving = $state(false) 10 let error = $state<string | null>(null) 11 let success = $state<string | null>(null) 12 let preferredChannel = $state('email') 13 let email = $state('') 14 let discordId = $state('') 15 let discordVerified = $state(false) 16 let telegramUsername = $state('') 17 let telegramVerified = $state(false) 18 let signalNumber = $state('') 19 let signalVerified = $state(false) 20 let verifyingChannel = $state<string | null>(null) 21 let verificationCode = $state('') 22 let verificationError = $state<string | null>(null) 23 let verificationSuccess = $state<string | null>(null) 24 let historyLoading = $state(false) 25 let historyError = $state<string | null>(null) 26 let messages = $state<Array<{ 27 createdAt: string 28 channel: string 29 notificationType: string 30 status: string 31 subject: string | null 32 body: string 33 }>>([]) 34 let showHistory = $state(false) 35 $effect(() => { 36 if (!auth.loading && !auth.session) { 37 navigate('/login') 38 } 39 }) 40 $effect(() => { 41 if (auth.session) { 42 loadPrefs() 43 } 44 }) 45 async function loadPrefs() { 46 if (!auth.session) return 47 loading = true 48 error = null 49 try { 50 const prefs = await api.getNotificationPrefs(auth.session.accessJwt) 51 preferredChannel = prefs.preferredChannel 52 email = prefs.email 53 discordId = prefs.discordId ?? '' 54 discordVerified = prefs.discordVerified 55 telegramUsername = prefs.telegramUsername ?? '' 56 telegramVerified = prefs.telegramVerified 57 signalNumber = prefs.signalNumber ?? '' 58 signalVerified = prefs.signalVerified 59 } catch (e) { 60 error = e instanceof ApiError ? e.message : 'Failed to load notification preferences' 61 } finally { 62 loading = false 63 } 64 } 65 async function handleSave(e: Event) { 66 e.preventDefault() 67 if (!auth.session) return 68 saving = true 69 error = null 70 success = null 71 try { 72 await api.updateNotificationPrefs(auth.session.accessJwt, { 73 preferredChannel, 74 discordId: discordId || undefined, 75 telegramUsername: telegramUsername || undefined, 76 signalNumber: signalNumber || undefined, 77 }) 78 await refreshSession() 79 success = $_('comms.preferencesSaved') 80 await loadPrefs() 81 } catch (e) { 82 error = e instanceof ApiError ? e.message : 'Failed to save preferences' 83 } finally { 84 saving = false 85 } 86 } 87 async function handleVerify(channel: string) { 88 if (!auth.session || !verificationCode) return 89 verificationError = null 90 verificationSuccess = null 91 try { 92 await api.confirmChannelVerification(auth.session.accessJwt, channel, verificationCode) 93 await refreshSession() 94 verificationSuccess = $_('comms.verifiedSuccess', { values: { channel } }) 95 verificationCode = '' 96 verifyingChannel = null 97 await loadPrefs() 98 } catch (e) { 99 verificationError = e instanceof ApiError ? e.message : 'Failed to verify channel' 100 } 101 } 102 async function loadHistory() { 103 if (!auth.session) return 104 historyLoading = true 105 historyError = null 106 try { 107 const result = await api.getNotificationHistory(auth.session.accessJwt) 108 messages = result.notifications 109 showHistory = true 110 } catch (e) { 111 historyError = e instanceof ApiError ? e.message : 'Failed to load notification history' 112 } finally { 113 historyLoading = false 114 } 115 } 116 function formatDate(dateStr: string): string { 117 return formatDateTime(dateStr) 118 } 119 const channels = ['email', 'discord', 'telegram', 'signal'] 120 function getChannelName(id: string): string { 121 switch (id) { 122 case 'email': return $_('register.email') 123 case 'discord': return $_('register.discord') 124 case 'telegram': return $_('register.telegram') 125 case 'signal': return $_('register.signal') 126 default: return id 127 } 128 } 129 function getChannelDescription(id: string): string { 130 switch (id) { 131 case 'email': return $_('comms.emailVia') 132 case 'discord': return $_('comms.discordVia') 133 case 'telegram': return $_('comms.telegramVia') 134 case 'signal': return $_('comms.signalVia') 135 default: return '' 136 } 137 } 138 function canSelectChannel(channelId: string): boolean { 139 if (channelId === 'email') return true 140 if (channelId === 'discord') return !!discordId 141 if (channelId === 'telegram') return !!telegramUsername 142 if (channelId === 'signal') return !!signalNumber 143 return false 144 } 145 function needsVerification(channelId: string): boolean { 146 if (channelId === 'discord') return !!discordId && !discordVerified 147 if (channelId === 'telegram') return !!telegramUsername && !telegramVerified 148 if (channelId === 'signal') return !!signalNumber && !signalVerified 149 return false 150 } 151</script> 152<div class="page"> 153 <header> 154 <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 155 <h1>{$_('comms.title')}</h1> 156 </header> 157 <p class="description"> 158 {$_('comms.description')} 159 </p> 160 {#if loading} 161 <p class="loading">{$_('common.loading')}</p> 162 {:else} 163 {#if error} 164 <div class="message error">{error}</div> 165 {/if} 166 {#if success} 167 <div class="message success">{success}</div> 168 {/if} 169 <form onsubmit={handleSave}> 170 <section> 171 <h2>{$_('comms.preferredChannel')}</h2> 172 <p class="section-description"> 173 {$_('comms.preferredChannelDescription')} 174 </p> 175 <div class="channel-options"> 176 {#each channels as channelId} 177 <label class="channel-option" class:disabled={!canSelectChannel(channelId)}> 178 <input 179 type="radio" 180 name="preferredChannel" 181 value={channelId} 182 bind:group={preferredChannel} 183 disabled={!canSelectChannel(channelId) || saving} 184 /> 185 <div class="channel-info"> 186 <span class="channel-name">{getChannelName(channelId)}</span> 187 <span class="channel-description">{getChannelDescription(channelId)}</span> 188 {#if channelId !== 'email' && !canSelectChannel(channelId)} 189 <span class="channel-hint">{$_('comms.configureToEnable')}</span> 190 {/if} 191 </div> 192 </label> 193 {/each} 194 </div> 195 </section> 196 <section> 197 <h2>{$_('comms.channelConfiguration')}</h2> 198 <div class="channel-config"> 199 <div class="config-item"> 200 <label for="email">{$_('register.email')}</label> 201 <div class="config-input"> 202 <input 203 id="email" 204 type="email" 205 value={email} 206 disabled 207 class="readonly" 208 /> 209 <span class="status verified">{$_('comms.primary')}</span> 210 </div> 211 <p class="config-hint">{$_('comms.emailManagedInSettings')}</p> 212 </div> 213 <div class="config-item"> 214 <label for="discord">{$_('register.discordId')}</label> 215 <div class="config-input"> 216 <input 217 id="discord" 218 type="text" 219 bind:value={discordId} 220 placeholder={$_('register.discordIdPlaceholder')} 221 disabled={saving} 222 /> 223 {#if discordId} 224 {#if discordVerified} 225 <span class="status verified">{$_('comms.verified')}</span> 226 {:else} 227 <span class="status unverified">{$_('comms.notVerified')}</span> 228 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button> 229 {/if} 230 {/if} 231 </div> 232 <p class="config-hint">{$_('comms.discordIdHint')}</p> 233 {#if verifyingChannel === 'discord'} 234 <div class="verify-form"> 235 <input 236 type="text" 237 bind:value={verificationCode} 238 placeholder={$_('comms.verifyCodePlaceholder')} 239 maxlength="6" 240 /> 241 <button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button> 242 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 243 </div> 244 {/if} 245 </div> 246 <div class="config-item"> 247 <label for="telegram">{$_('register.telegramUsername')}</label> 248 <div class="config-input"> 249 <input 250 id="telegram" 251 type="text" 252 bind:value={telegramUsername} 253 placeholder={$_('register.telegramUsernamePlaceholder')} 254 disabled={saving} 255 /> 256 {#if telegramUsername} 257 {#if telegramVerified} 258 <span class="status verified">{$_('comms.verified')}</span> 259 {:else} 260 <span class="status unverified">{$_('comms.notVerified')}</span> 261 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button> 262 {/if} 263 {/if} 264 </div> 265 <p class="config-hint">{$_('comms.telegramHint')}</p> 266 {#if verifyingChannel === 'telegram'} 267 <div class="verify-form"> 268 <input 269 type="text" 270 bind:value={verificationCode} 271 placeholder={$_('comms.verifyCodePlaceholder')} 272 maxlength="6" 273 /> 274 <button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button> 275 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 276 </div> 277 {/if} 278 </div> 279 <div class="config-item"> 280 <label for="signal">{$_('register.signalNumber')}</label> 281 <div class="config-input"> 282 <input 283 id="signal" 284 type="tel" 285 bind:value={signalNumber} 286 placeholder={$_('register.signalNumberPlaceholder')} 287 disabled={saving} 288 /> 289 {#if signalNumber} 290 {#if signalVerified} 291 <span class="status verified">{$_('comms.verified')}</span> 292 {:else} 293 <span class="status unverified">{$_('comms.notVerified')}</span> 294 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button> 295 {/if} 296 {/if} 297 </div> 298 <p class="config-hint">{$_('comms.signalHint')}</p> 299 {#if verifyingChannel === 'signal'} 300 <div class="verify-form"> 301 <input 302 type="text" 303 bind:value={verificationCode} 304 placeholder={$_('comms.verifyCodePlaceholder')} 305 maxlength="6" 306 /> 307 <button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button> 308 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 309 </div> 310 {/if} 311 </div> 312 </div> 313 {#if verificationError} 314 <div class="message error" style="margin-top: 1rem">{verificationError}</div> 315 {/if} 316 {#if verificationSuccess} 317 <div class="message success" style="margin-top: 1rem">{verificationSuccess}</div> 318 {/if} 319 </section> 320 <div class="actions"> 321 <button type="submit" disabled={saving}> 322 {saving ? $_('comms.saving') : $_('comms.savePreferences')} 323 </button> 324 </div> 325 </form> 326 <section class="history-section"> 327 <h2>{$_('comms.messageHistory')}</h2> 328 <p class="section-description">{$_('comms.historyDescription')}</p> 329 {#if !showHistory} 330 <button class="load-history" onclick={loadHistory} disabled={historyLoading}> 331 {historyLoading ? $_('common.loading') : $_('comms.loadHistory')} 332 </button> 333 {:else} 334 <button class="load-history" onclick={() => showHistory = false}>{$_('comms.hideHistory')}</button> 335 {#if historyError} 336 <div class="message error">{historyError}</div> 337 {:else if messages.length === 0} 338 <p class="no-messages">{$_('comms.noMessages')}</p> 339 {:else} 340 <div class="message-list"> 341 {#each messages as msg} 342 <div class="message-item"> 343 <div class="message-header"> 344 <span class="message-type">{msg.notificationType}</span> 345 <span class="message-channel">{msg.channel}</span> 346 <span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span> 347 </div> 348 {#if msg.subject} 349 <div class="message-subject">{msg.subject}</div> 350 {/if} 351 <div class="message-body">{msg.body}</div> 352 <div class="message-date">{formatDate(msg.createdAt)}</div> 353 </div> 354 {/each} 355 </div> 356 {/if} 357 {/if} 358 </section> 359 {/if} 360</div> 361<style> 362 .page { 363 max-width: var(--width-md); 364 margin: 0 auto; 365 padding: var(--space-7); 366 } 367 368 header { 369 margin-bottom: var(--space-4); 370 } 371 372 .back { 373 color: var(--text-secondary); 374 text-decoration: none; 375 font-size: var(--text-sm); 376 } 377 378 .back:hover { 379 color: var(--accent); 380 } 381 382 h1 { 383 margin: var(--space-2) 0 0 0; 384 } 385 386 .description { 387 color: var(--text-secondary); 388 margin-bottom: var(--space-7); 389 } 390 391 .loading { 392 text-align: center; 393 color: var(--text-secondary); 394 padding: var(--space-7); 395 } 396 397 section { 398 background: var(--bg-secondary); 399 padding: var(--space-6); 400 border-radius: var(--radius-xl); 401 margin-bottom: var(--space-6); 402 } 403 404 section h2 { 405 margin: 0 0 var(--space-2) 0; 406 font-size: var(--text-lg); 407 } 408 409 .section-description { 410 color: var(--text-secondary); 411 font-size: var(--text-sm); 412 margin: 0 0 var(--space-4) 0; 413 } 414 415 .channel-options { 416 display: flex; 417 flex-direction: column; 418 gap: var(--space-2); 419 } 420 421 .channel-option { 422 display: flex; 423 align-items: flex-start; 424 gap: var(--space-3); 425 padding: var(--space-3); 426 background: var(--bg-card); 427 border: 1px solid var(--border-color); 428 border-radius: var(--radius-md); 429 cursor: pointer; 430 transition: border-color var(--transition-fast); 431 } 432 433 .channel-option:hover:not(.disabled) { 434 border-color: var(--accent); 435 } 436 437 .channel-option.disabled { 438 opacity: 0.6; 439 cursor: not-allowed; 440 } 441 442 .channel-option input[type="radio"] { 443 flex-shrink: 0; 444 width: 16px; 445 height: 16px; 446 margin-top: 2px; 447 } 448 449 .channel-info { 450 flex: 1; 451 min-width: 0; 452 display: flex; 453 flex-direction: column; 454 gap: 2px; 455 } 456 457 .channel-name { 458 font-weight: var(--font-medium); 459 } 460 461 .channel-description { 462 font-size: var(--text-sm); 463 color: var(--text-secondary); 464 } 465 466 .channel-hint { 467 font-size: var(--text-xs); 468 color: var(--text-muted); 469 font-style: italic; 470 } 471 472 .channel-config { 473 display: flex; 474 flex-direction: column; 475 gap: var(--space-5); 476 } 477 478 .config-item { 479 display: flex; 480 flex-direction: column; 481 gap: var(--space-1); 482 } 483 484 .config-item label { 485 font-size: var(--text-sm); 486 font-weight: var(--font-medium); 487 } 488 489 .config-input { 490 display: flex; 491 align-items: center; 492 gap: var(--space-2); 493 } 494 495 .config-input input { 496 flex: 1; 497 } 498 499 .config-input input.readonly { 500 background: var(--bg-input-disabled); 501 color: var(--text-secondary); 502 } 503 504 .status { 505 padding: var(--space-1) var(--space-2); 506 border-radius: var(--radius-md); 507 font-size: var(--text-xs); 508 white-space: nowrap; 509 } 510 511 .status.verified { 512 background: var(--success-bg); 513 color: var(--success-text); 514 } 515 516 .status.unverified { 517 background: var(--warning-bg); 518 color: var(--warning-text); 519 } 520 521 .config-hint { 522 font-size: var(--text-xs); 523 color: var(--text-secondary); 524 margin: 0; 525 } 526 527 .actions { 528 display: flex; 529 justify-content: flex-end; 530 } 531 532 .verify-btn { 533 padding: var(--space-1) var(--space-2); 534 background: var(--accent); 535 color: var(--text-inverse); 536 border: none; 537 border-radius: var(--radius-md); 538 font-size: var(--text-xs); 539 cursor: pointer; 540 } 541 542 .verify-btn:hover { 543 background: var(--accent-hover); 544 } 545 546 .verify-form { 547 display: flex; 548 gap: var(--space-2); 549 margin-top: var(--space-2); 550 align-items: center; 551 } 552 553 .verify-form input { 554 padding: var(--space-2); 555 font-size: var(--text-sm); 556 width: 150px; 557 } 558 559 .verify-form button { 560 padding: var(--space-2) var(--space-3); 561 background: var(--accent); 562 color: var(--text-inverse); 563 border: none; 564 border-radius: var(--radius-md); 565 font-size: var(--text-sm); 566 cursor: pointer; 567 } 568 569 .verify-form button:hover { 570 background: var(--accent-hover); 571 } 572 573 .verify-form button.cancel { 574 background: transparent; 575 border: 1px solid var(--border-color); 576 color: var(--text-secondary); 577 } 578 579 .verify-form button.cancel:hover { 580 background: var(--bg-secondary); 581 } 582 583 .history-section { 584 background: var(--bg-secondary); 585 padding: var(--space-6); 586 border-radius: var(--radius-xl); 587 margin-top: var(--space-6); 588 } 589 590 .history-section h2 { 591 margin: 0 0 var(--space-2) 0; 592 font-size: var(--text-lg); 593 } 594 595 .load-history { 596 padding: var(--space-2) var(--space-4); 597 background: transparent; 598 border: 1px solid var(--border-color); 599 border-radius: var(--radius-md); 600 cursor: pointer; 601 color: var(--text-primary); 602 margin-top: var(--space-2); 603 } 604 605 .load-history:hover:not(:disabled) { 606 background: var(--bg-card); 607 border-color: var(--accent); 608 } 609 610 .load-history:disabled { 611 opacity: 0.6; 612 cursor: not-allowed; 613 } 614 615 .no-messages { 616 color: var(--text-secondary); 617 font-style: italic; 618 margin-top: var(--space-4); 619 } 620 621 .message-list { 622 display: flex; 623 flex-direction: column; 624 gap: var(--space-3); 625 margin-top: var(--space-4); 626 } 627 628 .message-item { 629 background: var(--bg-card); 630 border: 1px solid var(--border-color); 631 border-radius: var(--radius-md); 632 padding: var(--space-3); 633 } 634 635 .message-header { 636 display: flex; 637 gap: var(--space-2); 638 margin-bottom: var(--space-2); 639 flex-wrap: wrap; 640 align-items: center; 641 } 642 643 .message-type { 644 font-weight: var(--font-medium); 645 font-size: var(--text-sm); 646 } 647 648 .message-channel { 649 font-size: var(--text-xs); 650 padding: var(--space-1) var(--space-2); 651 background: var(--bg-secondary); 652 border-radius: var(--radius-md); 653 color: var(--text-secondary); 654 } 655 656 .message-status { 657 font-size: var(--text-xs); 658 padding: var(--space-1) var(--space-2); 659 border-radius: var(--radius-md); 660 margin-left: auto; 661 } 662 663 .message-status.sent { 664 background: var(--success-bg); 665 color: var(--success-text); 666 } 667 668 .message-status.failed { 669 background: var(--error-bg); 670 color: var(--error-text); 671 } 672 673 .message-subject { 674 font-weight: var(--font-medium); 675 font-size: var(--text-sm); 676 margin-bottom: var(--space-1); 677 } 678 679 .message-body { 680 font-size: var(--text-sm); 681 color: var(--text-secondary); 682 white-space: pre-wrap; 683 word-break: break-word; 684 } 685 686 .message-date { 687 font-size: var(--text-xs); 688 color: var(--text-muted); 689 margin-top: var(--space-2); 690 } 691</style>