Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

tranquil-pds / frontend / src / routes / Comms.svelte
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>