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