this repo has no description
at main 24 kB view raw
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 sidebar-right"> 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 section { 416 background: var(--bg-secondary); 417 padding: var(--space-6); 418 border-radius: var(--radius-xl); 419 margin-bottom: var(--space-6); 420 } 421 422 .side-column section { 423 margin-bottom: 0; 424 } 425 426 section h2 { 427 margin: 0 0 var(--space-2) 0; 428 font-size: var(--text-lg); 429 } 430 431 .section-description { 432 color: var(--text-secondary); 433 font-size: var(--text-sm); 434 margin: 0 0 var(--space-4) 0; 435 } 436 437 .channel-options { 438 display: flex; 439 flex-direction: column; 440 gap: var(--space-2); 441 } 442 443 .channel-option { 444 display: flex; 445 align-items: flex-start; 446 gap: var(--space-3); 447 padding: var(--space-3); 448 background: var(--bg-card); 449 border: 1px solid var(--border-color); 450 border-radius: var(--radius-md); 451 cursor: pointer; 452 transition: border-color var(--transition-fast); 453 } 454 455 .channel-option:hover:not(.disabled) { 456 border-color: var(--accent); 457 } 458 459 .channel-option.disabled { 460 opacity: 0.6; 461 cursor: not-allowed; 462 } 463 464 .channel-option.unavailable { 465 opacity: 0.5; 466 background: var(--bg-input-disabled); 467 } 468 469 .channel-option input[type="radio"] { 470 flex-shrink: 0; 471 width: 16px; 472 height: 16px; 473 margin-top: 2px; 474 } 475 476 .channel-info { 477 flex: 1; 478 min-width: 0; 479 display: flex; 480 flex-direction: column; 481 gap: 2px; 482 } 483 484 .channel-name { 485 font-weight: var(--font-medium); 486 } 487 488 .channel-description { 489 font-size: var(--text-sm); 490 color: var(--text-secondary); 491 } 492 493 .channel-hint { 494 font-size: var(--text-xs); 495 color: var(--text-muted); 496 font-style: italic; 497 } 498 499 .channel-hint.server-unavailable { 500 color: var(--warning-text); 501 } 502 503 .channel-config { 504 display: flex; 505 flex-direction: column; 506 gap: var(--space-5); 507 } 508 509 .config-item { 510 display: flex; 511 flex-direction: column; 512 gap: var(--space-1); 513 } 514 515 .config-item.unavailable { 516 opacity: 0.6; 517 } 518 519 .config-header { 520 display: flex; 521 align-items: center; 522 justify-content: space-between; 523 gap: var(--space-3); 524 margin-bottom: var(--space-1); 525 } 526 527 .config-item label { 528 font-size: var(--text-sm); 529 font-weight: var(--font-medium); 530 } 531 532 .config-input { 533 display: flex; 534 align-items: center; 535 gap: var(--space-2); 536 } 537 538 .config-input input { 539 flex: 1; 540 min-width: 0; 541 } 542 543 .config-item input.readonly { 544 background: var(--bg-input-disabled); 545 color: var(--text-secondary); 546 } 547 548 .status { 549 padding: var(--space-1) var(--space-2); 550 border-radius: var(--radius-md); 551 font-size: var(--text-xs); 552 white-space: nowrap; 553 } 554 555 .status.verified { 556 background: var(--success-bg); 557 color: var(--success-text); 558 } 559 560 .status.unverified { 561 background: var(--warning-bg); 562 color: var(--warning-text); 563 } 564 565 .status.unavailable { 566 background: var(--bg-input-disabled); 567 color: var(--text-muted); 568 } 569 570 .config-hint { 571 font-size: var(--text-xs); 572 color: var(--text-secondary); 573 margin: 0; 574 } 575 576 .actions { 577 display: flex; 578 justify-content: flex-end; 579 } 580 581 .verify-btn { 582 padding: var(--space-1) var(--space-2); 583 background: var(--accent); 584 color: var(--text-inverse); 585 border: none; 586 border-radius: var(--radius-md); 587 font-size: var(--text-xs); 588 cursor: pointer; 589 } 590 591 .verify-btn:hover { 592 background: var(--accent-hover); 593 } 594 595 .verify-form { 596 display: flex; 597 gap: var(--space-2); 598 margin-top: var(--space-2); 599 align-items: center; 600 } 601 602 .verify-form input { 603 padding: var(--space-2); 604 font-size: var(--text-sm); 605 width: 150px; 606 } 607 608 .verify-form button { 609 padding: var(--space-2) var(--space-3); 610 background: var(--accent); 611 color: var(--text-inverse); 612 border: none; 613 border-radius: var(--radius-md); 614 font-size: var(--text-sm); 615 cursor: pointer; 616 } 617 618 .verify-form button:hover { 619 background: var(--accent-hover); 620 } 621 622 .verify-form button.cancel { 623 background: transparent; 624 border: 1px solid var(--border-color); 625 color: var(--text-secondary); 626 } 627 628 .verify-form button.cancel:hover { 629 background: var(--bg-secondary); 630 } 631 632 .history-section h2 { 633 margin: 0 0 var(--space-2) 0; 634 font-size: var(--text-lg); 635 } 636 637 .skeleton-list { 638 display: flex; 639 flex-direction: column; 640 gap: var(--space-3); 641 } 642 643 .skeleton-item { 644 background: var(--bg-card); 645 border: 1px solid var(--border-color); 646 border-radius: var(--radius-md); 647 padding: var(--space-3); 648 } 649 650 .skeleton-header { 651 display: flex; 652 gap: var(--space-2); 653 margin-bottom: var(--space-2); 654 } 655 656 .skeleton-line { 657 height: 14px; 658 background: var(--bg-tertiary); 659 border-radius: var(--radius-sm); 660 animation: skeleton-pulse 1.5s ease-in-out infinite; 661 } 662 663 .skeleton-line.short { 664 width: 80px; 665 } 666 667 .skeleton-line.tiny { 668 width: 50px; 669 } 670 671 .skeleton-line.medium { 672 width: 60%; 673 } 674 675 .skeleton-line:not(.short):not(.tiny):not(.medium) { 676 width: 100%; 677 margin-bottom: var(--space-1); 678 } 679 680 @keyframes skeleton-pulse { 681 0%, 100% { opacity: 1; } 682 50% { opacity: 0.4; } 683 } 684 685 .no-messages { 686 color: var(--text-secondary); 687 font-style: italic; 688 margin-top: var(--space-4); 689 } 690 691 .message-list { 692 display: flex; 693 flex-direction: column; 694 gap: var(--space-3); 695 margin-top: var(--space-4); 696 } 697 698 .message-item { 699 background: var(--bg-card); 700 border: 1px solid var(--border-color); 701 border-radius: var(--radius-md); 702 padding: var(--space-3); 703 } 704 705 .message-header { 706 display: flex; 707 gap: var(--space-2); 708 margin-bottom: var(--space-2); 709 flex-wrap: wrap; 710 align-items: center; 711 } 712 713 .message-type { 714 font-weight: var(--font-medium); 715 font-size: var(--text-sm); 716 } 717 718 .message-channel { 719 font-size: var(--text-xs); 720 padding: var(--space-1) var(--space-2); 721 background: var(--bg-secondary); 722 border-radius: var(--radius-md); 723 color: var(--text-secondary); 724 } 725 726 .message-status { 727 font-size: var(--text-xs); 728 padding: var(--space-1) var(--space-2); 729 border-radius: var(--radius-md); 730 margin-left: auto; 731 } 732 733 .message-status.sent { 734 background: var(--success-bg); 735 color: var(--success-text); 736 } 737 738 .message-status.failed { 739 background: var(--error-bg); 740 color: var(--error-text); 741 } 742 743 .message-subject { 744 font-weight: var(--font-medium); 745 font-size: var(--text-sm); 746 margin-bottom: var(--space-1); 747 } 748 749 .message-body { 750 font-size: var(--text-sm); 751 color: var(--text-secondary); 752 white-space: pre-wrap; 753 word-break: break-word; 754 } 755 756 .message-date { 757 font-size: var(--text-xs); 758 color: var(--text-muted); 759 margin-top: var(--space-2); 760 } 761 762 .skeleton-sections { 763 display: flex; 764 flex-direction: column; 765 gap: var(--space-6); 766 } 767 768 .skeleton-section { 769 height: 180px; 770 background: var(--bg-secondary); 771 border-radius: var(--radius-xl); 772 animation: skeleton-pulse 1.5s ease-in-out infinite; 773 } 774 775 @keyframes skeleton-pulse { 776 0%, 100% { opacity: 1; } 777 50% { opacity: 0.5; } 778 } 779</style>