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>