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