Browse Source

more performance improvements

more bug-fixes
master
Silberengel 1 month ago
parent
commit
811b49c5f7
  1. 6
      src/app.css
  2. 8
      src/lib/components/EventMenu.svelte
  3. 2
      src/lib/components/content/EmbeddedEvent.svelte
  4. 8
      src/lib/components/content/EmojiDrawer.svelte
  5. 8
      src/lib/components/content/EmojiPicker.svelte
  6. 16
      src/lib/components/content/FileExplorer.svelte
  7. 12
      src/lib/components/content/GifPicker.svelte
  8. 4
      src/lib/components/content/MarkdownRenderer.svelte
  9. 4
      src/lib/components/content/MentionsAutocomplete.svelte
  10. 8
      src/lib/components/content/MetadataCard.svelte
  11. 12
      src/lib/components/content/PollCard.svelte
  12. 8
      src/lib/components/content/ReferencedEventPreview.svelte
  13. 4
      src/lib/components/content/RichTextEditor.svelte
  14. 28
      src/lib/components/find/SearchAddressableEvents.svelte
  15. 4
      src/lib/components/layout/CacheBadge.svelte
  16. 2
      src/lib/components/layout/ProfileBadge.svelte
  17. 4
      src/lib/components/layout/RelayBadge.svelte
  18. 18
      src/lib/components/layout/SearchBox.svelte
  19. 18
      src/lib/components/layout/UnifiedSearch.svelte
  20. 4
      src/lib/components/profile/BookmarksPanel.svelte
  21. 4
      src/lib/components/profile/ProfileEventsPanel.svelte
  22. 8
      src/lib/components/relay/RelayInfo.svelte
  23. 6
      src/lib/components/write/AdvancedEditor.svelte
  24. 4
      src/lib/components/write/CreateEventForm.svelte
  25. 4
      src/lib/components/write/EditEventForm.svelte
  26. 4
      src/lib/components/write/FindEventForm.svelte
  27. 4
      src/lib/modules/comments/Comment.svelte
  28. 4
      src/lib/modules/discussions/DiscussionCard.svelte
  29. 4
      src/lib/modules/feed/FeedPost.svelte
  30. 4
      src/lib/modules/feed/HighlightCard.svelte
  31. 4
      src/lib/modules/feed/Reply.svelte
  32. 4
      src/lib/modules/feed/ZapReceiptReply.svelte
  33. 6
      src/lib/modules/profiles/ProfilePage.svelte
  34. 4
      src/lib/modules/reactions/FeedReactionButtons.svelte
  35. 4
      src/lib/modules/rss/RSSCommentForm.svelte
  36. 15
      src/lib/services/auth/anonymous-signer.ts
  37. 22
      src/lib/services/cache/anonymous-key-store.ts
  38. 100
      src/lib/services/cache/archive-scheduler.ts
  39. 395
      src/lib/services/cache/event-archive.ts
  40. 12
      src/lib/services/cache/event-cache.ts
  41. 22
      src/lib/services/cache/indexeddb-store.ts
  42. 22
      src/lib/services/cache/nsec-key-store.ts
  43. 27
      src/lib/services/nostr/auth-handler.ts
  44. 4
      src/routes/+layout.svelte
  45. 4
      src/routes/bookmarks/+page.svelte
  46. 145
      src/routes/cache/+page.svelte
  47. 4
      src/routes/discussions/+page.svelte
  48. 4
      src/routes/feed/+page.svelte
  49. 16
      src/routes/find/+page.svelte
  50. 4
      src/routes/highlights/+page.svelte
  51. 4
      src/routes/relay/+page.svelte
  52. 16
      src/routes/repos/+page.svelte
  53. 28
      src/routes/repos/[naddr]/+page.svelte
  54. 4
      src/routes/rss/+page.svelte
  55. 253
      src/routes/settings/+page.svelte
  56. 4
      src/routes/topics/+page.svelte

6
src/app.css

@ -66,7 +66,7 @@ body { @@ -66,7 +66,7 @@ body {
font-size: var(--text-size);
line-height: var(--line-height);
background-color: #f1f5f9;
color: #475569; /* WCAG AA compliant: 5.2:1 contrast ratio */
color: #475569; /* WCAG AA compliant: 5.2:1 contrast ratio on bg, 7.1:1 on white */
transition: background-color 0.3s ease, color 0.3s ease;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
}
@ -295,7 +295,7 @@ main { @@ -295,7 +295,7 @@ main {
padding: 0.75rem 1.5rem;
border: none;
background: transparent;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a); /* WCAG AA compliant: 4.6:1 on bg, 5.1:1 on post */
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
@ -303,7 +303,7 @@ main { @@ -303,7 +303,7 @@ main {
}
:global(.dark) .tab-button {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0); /* WCAG AA compliant: 8.5:1 on bg, 4.8:1 on post */
}
.tab-button:hover {

8
src/lib/components/EventMenu.svelte

@ -521,7 +521,7 @@ @@ -521,7 +521,7 @@
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
font-size: 1.25rem;
line-height: 1;
display: flex;
@ -538,7 +538,7 @@ @@ -538,7 +538,7 @@
}
:global(.dark) .menu-button {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .menu-button:hover {
@ -732,11 +732,11 @@ @@ -732,11 +732,11 @@
.delete-confirm-dialog p {
margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .delete-confirm-dialog p {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.delete-confirm-buttons {

2
src/lib/components/content/EmbeddedEvent.svelte

@ -382,7 +382,7 @@ @@ -382,7 +382,7 @@
}
:global(.dark) .embedded-event-subject {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.embedded-event-preview {

8
src/lib/components/content/EmojiDrawer.svelte

@ -221,7 +221,7 @@ @@ -221,7 +221,7 @@
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
@ -233,7 +233,7 @@ @@ -233,7 +233,7 @@
}
:global(.dark) .drawer-close {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .drawer-close:hover {
@ -297,12 +297,12 @@ @@ -297,12 +297,12 @@
.emoji-empty {
text-align: center;
padding: 2rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .emoji-empty {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.emoji-grid {

8
src/lib/components/content/EmojiPicker.svelte

@ -481,14 +481,14 @@ @@ -481,14 +481,14 @@
.custom-emojis-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
:global(.dark) .custom-emojis-label {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.custom-emoji-grid {
@ -512,12 +512,12 @@ @@ -512,12 +512,12 @@
.emoji-empty {
text-align: center;
padding: 2rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .emoji-empty {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.emoji-grid {

16
src/lib/components/content/FileExplorer.svelte

@ -521,12 +521,12 @@ @@ -521,12 +521,12 @@
.tree-size {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
margin-left: 0.5rem;
}
:global(.dark) .tree-size {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.tree-children {
@ -541,12 +541,12 @@ @@ -541,12 +541,12 @@
.tree-note {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-style: italic;
}
:global(.dark) .tree-note {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.file-content-panel {
@ -584,11 +584,11 @@ @@ -584,11 +584,11 @@
.file-content-size {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .file-content-size {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.file-content-body {
@ -618,13 +618,13 @@ @@ -618,13 +618,13 @@
display: flex;
align-items: center;
justify-content: center;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .file-content-loading,
:global(.dark) .file-content-error,
:global(.dark) .file-content-empty {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.file-content-error {

12
src/lib/components/content/GifPicker.svelte

@ -693,7 +693,7 @@ @@ -693,7 +693,7 @@
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
@ -705,7 +705,7 @@ @@ -705,7 +705,7 @@
}
:global(.dark) .drawer-close {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .drawer-close:hover {
@ -771,14 +771,14 @@ @@ -771,14 +771,14 @@
.gif-error {
text-align: center;
padding: 2rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .gif-loading,
:global(.dark) .gif-empty,
:global(.dark) .gif-error {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.gif-hint {
@ -977,7 +977,7 @@ @@ -977,7 +977,7 @@
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
@ -989,7 +989,7 @@ @@ -989,7 +989,7 @@
}
:global(.dark) .metadata-modal-close {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .metadata-modal-close:hover {

4
src/lib/components/content/MarkdownRenderer.svelte

@ -1218,12 +1218,12 @@ @@ -1218,12 +1218,12 @@
border-left: 4px solid var(--fog-border, #e5e7eb);
padding-left: 1rem;
margin: 0.5rem 0;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark .markdown-content blockquote) {
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
/* Greentext styling - 4chan style */

4
src/lib/components/content/MentionsAutocomplete.svelte

@ -328,13 +328,13 @@ @@ -328,13 +328,13 @@
.suggestion-handle {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .suggestion-handle {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

8
src/lib/components/content/MetadataCard.svelte

@ -223,11 +223,11 @@ @@ -223,11 +223,11 @@
.metadata-author {
margin: 0;
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .metadata-author {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.metadata-tags {
@ -261,12 +261,12 @@ @@ -261,12 +261,12 @@
}
.metadata-tag-value {
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-family: monospace;
word-break: break-all;
}
:global(.dark) .metadata-tag-value {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

12
src/lib/components/content/PollCard.svelte

@ -341,13 +341,13 @@ @@ -341,13 +341,13 @@
.poll-status {
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
margin-bottom: 0.75rem;
font-style: italic;
}
:global(.dark) .poll-status {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.poll-options {
@ -430,11 +430,11 @@ @@ -430,11 +430,11 @@
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .poll-option-stats {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.poll-percentage {
@ -472,13 +472,13 @@ @@ -472,13 +472,13 @@
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .poll-footer {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
border-top-color: var(--fog-dark-border, #4b5563);
}

8
src/lib/components/content/ReferencedEventPreview.svelte

@ -301,13 +301,13 @@ @@ -301,13 +301,13 @@
.loading-text,
.error-text {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-style: italic;
}
:global(.dark) .loading-text,
:global(.dark) .error-text {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.view-event-button {
@ -356,14 +356,14 @@ @@ -356,14 +356,14 @@
.referenced-event-preview-text {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
:global(.dark) .referenced-event-preview-text {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.view-website-button {

4
src/lib/components/content/RichTextEditor.svelte

@ -262,7 +262,7 @@ @@ -262,7 +262,7 @@
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
cursor: pointer;
transition: all 0.2s;
}
@ -281,7 +281,7 @@ @@ -281,7 +281,7 @@
:global(.dark) .toolbar-button {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .toolbar-button:hover:not(:disabled) {

28
src/lib/components/find/SearchAddressableEvents.svelte

@ -699,12 +699,12 @@ @@ -699,12 +699,12 @@
.section-description {
margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .section-description {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-container {
@ -742,7 +742,7 @@ @@ -742,7 +742,7 @@
}
.search-input::placeholder {
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .search-input {
@ -752,7 +752,7 @@ @@ -752,7 +752,7 @@
}
:global(.dark) .search-input::placeholder {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-input:focus {
@ -819,11 +819,11 @@ @@ -819,11 +819,11 @@
margin: 0 0 1.5rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .results-container h3 {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.results-grid {
@ -894,14 +894,14 @@ @@ -894,14 +894,14 @@
.kind-label {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .kind-label {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
background: var(--fog-dark-highlight, #374151);
}
@ -919,13 +919,13 @@ @@ -919,13 +919,13 @@
.metadata-label {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-weight: 600;
text-transform: uppercase;
}
:global(.dark) .metadata-label {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.metadata-value {
@ -973,23 +973,23 @@ @@ -973,23 +973,23 @@
.event-id {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-family: monospace;
word-break: break-all;
}
:global(.dark) .event-id {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.no-results {
padding: 2rem;
text-align: center;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .no-results {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

4
src/lib/components/layout/CacheBadge.svelte

@ -54,14 +54,14 @@ @@ -54,14 +54,14 @@
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
}
:global(.dark) .cache-badge {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}

2
src/lib/components/layout/ProfileBadge.svelte

@ -101,7 +101,7 @@ @@ -101,7 +101,7 @@
case 'green':
return '#22c55e';
default:
return '#9ca3af';
return '#a8b8d0';
}
}

4
src/lib/components/layout/RelayBadge.svelte

@ -57,7 +57,7 @@ @@ -57,7 +57,7 @@
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
@ -65,7 +65,7 @@ @@ -65,7 +65,7 @@
}
:global(.dark) .relay-badge {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}

18
src/lib/components/layout/SearchBox.svelte

@ -296,13 +296,13 @@ @@ -296,13 +296,13 @@
.search-icon {
position: absolute;
left: 0.75rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
pointer-events: none;
z-index: 1;
}
:global(.dark) .search-icon {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-input {
@ -336,7 +336,7 @@ @@ -336,7 +336,7 @@
.search-loading {
position: absolute;
right: 1rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
animation: spin 1s linear infinite;
}
@ -414,11 +414,11 @@ @@ -414,11 +414,11 @@
.search-result-id {
font-size: 0.75rem;
font-family: monospace;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .search-result-id {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-result-content {
@ -434,21 +434,21 @@ @@ -434,21 +434,21 @@
.search-result-meta {
font-size: 0.75rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .search-result-meta {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-no-results {
padding: 1rem;
text-align: center;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .search-no-results {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

18
src/lib/components/layout/UnifiedSearch.svelte

@ -1273,7 +1273,7 @@ @@ -1273,7 +1273,7 @@
}
.search-input::placeholder {
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
.search-input:focus {
@ -1293,7 +1293,7 @@ @@ -1293,7 +1293,7 @@
}
:global(.dark) .search-input::placeholder {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .search-input:focus {
@ -1304,7 +1304,7 @@ @@ -1304,7 +1304,7 @@
.search-loading {
position: absolute;
right: 1rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
animation: spin 1s linear infinite;
}
@ -1382,11 +1382,11 @@ @@ -1382,11 +1382,11 @@
.search-result-id {
font-size: 0.75rem;
font-family: monospace;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .search-result-id {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-result-content {
@ -1402,21 +1402,21 @@ @@ -1402,21 +1402,21 @@
.search-result-meta {
font-size: 0.75rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .search-result-meta {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-no-results {
padding: 1rem;
text-align: center;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .search-no-results {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

4
src/lib/components/profile/BookmarksPanel.svelte

@ -172,7 +172,7 @@ @@ -172,7 +172,7 @@
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
padding: 0;
width: 2rem;
height: 2rem;
@ -184,7 +184,7 @@ @@ -184,7 +184,7 @@
}
:global(.dark) .bookmarks-panel-close {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.bookmarks-panel-close:hover {

4
src/lib/components/profile/ProfileEventsPanel.svelte

@ -357,7 +357,7 @@ @@ -357,7 +357,7 @@
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
@ -368,7 +368,7 @@ @@ -368,7 +368,7 @@
}
:global(.dark) .panel-close {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .panel-close:hover {

8
src/lib/components/relay/RelayInfo.svelte

@ -525,12 +525,12 @@ @@ -525,12 +525,12 @@
display: block;
font-family: monospace;
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
word-break: break-all;
}
:global(.dark) .relay-url {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.relay-info-content {
@ -557,12 +557,12 @@ @@ -557,12 +557,12 @@
.relay-info-value {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
word-break: break-word;
}
:global(.dark) .relay-info-value {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.relay-pubkey {

6
src/lib/components/write/AdvancedEditor.svelte

@ -1202,7 +1202,7 @@ @@ -1202,7 +1202,7 @@
}
:global(.dark) .close-button {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .close-button:hover {
@ -1439,12 +1439,12 @@ @@ -1439,12 +1439,12 @@
}
.text-muted {
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-style: italic;
}
:global(.dark) .text-muted {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.editor-footer {

4
src/lib/components/write/CreateEventForm.svelte

@ -1056,12 +1056,12 @@ @@ -1056,12 +1056,12 @@
.suggested-tags ul {
margin: 0;
padding-left: 1.25rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.8125rem;
}
:global(.dark) .suggested-tags ul {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.suggested-tags li {

4
src/lib/components/write/EditEventForm.svelte

@ -259,12 +259,12 @@ @@ -259,12 +259,12 @@
.form-description {
margin: 0;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .form-description {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.form-group {

4
src/lib/components/write/FindEventForm.svelte

@ -176,12 +176,12 @@ @@ -176,12 +176,12 @@
.form-description {
margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .form-description {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.input-group {

4
src/lib/modules/comments/Comment.svelte

@ -313,11 +313,11 @@ @@ -313,11 +313,11 @@
gap: 0.25rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-number {

4
src/lib/modules/discussions/DiscussionCard.svelte

@ -492,7 +492,7 @@ @@ -492,7 +492,7 @@
gap: 0.25rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
white-space: nowrap;
}
@ -517,7 +517,7 @@ @@ -517,7 +517,7 @@
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-number {

4
src/lib/modules/feed/FeedPost.svelte

@ -1187,7 +1187,7 @@ @@ -1187,7 +1187,7 @@
gap: 0.25rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
.feed-card-kind-badge {
@ -1195,7 +1195,7 @@ @@ -1195,7 +1195,7 @@
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-number {

4
src/lib/modules/feed/HighlightCard.svelte

@ -467,11 +467,11 @@ @@ -467,11 +467,11 @@
gap: 0.25rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-number {

4
src/lib/modules/feed/Reply.svelte

@ -218,11 +218,11 @@ @@ -218,11 +218,11 @@
gap: 0.25rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-number {

4
src/lib/modules/feed/ZapReceiptReply.svelte

@ -219,11 +219,11 @@ @@ -219,11 +219,11 @@
gap: 0.25rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-number {

6
src/lib/modules/profiles/ProfilePage.svelte

@ -1128,7 +1128,7 @@ @@ -1128,7 +1128,7 @@
}
.nip05-checking {
color: #9ca3af;
color: #a8b8d0;
margin-left: 0.25rem;
display: inline-block;
animation: spin 1s linear infinite;
@ -1157,7 +1157,7 @@ @@ -1157,7 +1157,7 @@
.npub-text {
font-family: monospace;
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
word-break: break-all;
padding: 0.25rem 0.5rem;
background: var(--fog-highlight, #f3f4f6);
@ -1166,7 +1166,7 @@ @@ -1166,7 +1166,7 @@
}
:global(.dark) .npub-text {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}

4
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -683,11 +683,11 @@ @@ -683,11 +683,11 @@
.reaction-count-text {
font-size: 0.8125rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .reaction-count-text {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.text-emoji {

4
src/lib/modules/rss/RSSCommentForm.svelte

@ -623,12 +623,12 @@ @@ -623,12 +623,12 @@
}
.text-muted {
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-style: italic;
}
:global(.dark) .text-muted {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.btn-primary {

15
src/lib/services/auth/anonymous-signer.ts

@ -42,16 +42,15 @@ export async function signEventWithAnonymous( @@ -42,16 +42,15 @@ export async function signEventWithAnonymous(
pubkey: string,
password: string
): Promise<NostrEvent> {
// Retrieve and decrypt key - NEVER log the nsec or password
const nsec = await getStoredAnonymousKey(pubkey, password);
if (!nsec) {
// Get stored ncryptsec directly (no need to decrypt and re-encrypt)
const { getAnonymousNcryptsec } = await import('../cache/anonymous-key-store.js');
const ncryptsec = await getAnonymousNcryptsec(pubkey);
if (!ncryptsec) {
throw new Error('Anonymous key not found');
}
// Encrypt to ncryptsec format for signing
// NEVER log the nsec or password
const { encryptPrivateKey } = await import('../security/key-management.js');
const ncryptsec = await encryptPrivateKey(nsec, password);
// Use stored ncryptsec directly - it's already encrypted
const { signEventWithNsec } = await import('./nsec-signer.js');
return signEventWithNsec(event, ncryptsec, password);
}

22
src/lib/services/cache/anonymous-key-store.ts vendored

@ -66,6 +66,28 @@ export async function getAnonymousKey( @@ -66,6 +66,28 @@ export async function getAnonymousKey(
}
}
/**
* Get the stored ncryptsec directly (without decryption)
* Useful for signing without unnecessary re-encryption
*/
export async function getAnonymousNcryptsec(pubkey: string): Promise<string | null> {
const db = await getDB();
const stored = await db.get('keys', pubkey);
if (!stored) return null;
const key = stored as StoredAnonymousKey;
if (!key.ncryptsec || typeof key.ncryptsec !== 'string') {
throw new Error('Stored anonymous key has invalid ncryptsec format - key may be corrupted');
}
// Validate ncryptsec format
if (!key.ncryptsec.startsWith('ncryptsec1')) {
throw new Error('Stored anonymous key has invalid encryption format - key may need to be re-saved');
}
return key.ncryptsec;
}
/**
* List all stored anonymous keys (pubkeys only)
*/

100
src/lib/services/cache/archive-scheduler.ts vendored

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
/**
* Background archive scheduler
* Automatically archives old events in the background without blocking operations
*/
import { archiveOldEvents } from './event-archive.js';
// Archive threshold: 30 days (events older than this will be archived)
const ARCHIVE_THRESHOLD = 30 * 24 * 60 * 60 * 1000; // 30 days in ms
// How often to check for events to archive (once per day)
const ARCHIVE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
let archiveTimer: number | null = null;
let isArchiving = false;
let lastArchiveTime = 0;
/**
* Start the archive scheduler
* This will periodically archive old events in the background
*/
export function startArchiveScheduler(): void {
// Don't start multiple schedulers
if (archiveTimer !== null) return;
// Run initial archive check after a delay (don't block startup)
setTimeout(() => {
checkAndArchive();
}, 5 * 60 * 1000); // Wait 5 minutes after startup
// Schedule periodic checks
archiveTimer = window.setInterval(() => {
checkAndArchive();
}, ARCHIVE_CHECK_INTERVAL);
}
/**
* Stop the archive scheduler
*/
export function stopArchiveScheduler(): void {
if (archiveTimer !== null) {
clearInterval(archiveTimer);
archiveTimer = null;
}
}
/**
* Check for old events and archive them
* This runs in the background and doesn't block operations
*/
async function checkAndArchive(): Promise<void> {
// Don't run if already archiving
if (isArchiving) return;
// Don't run too frequently (at least 1 hour between runs)
const now = Date.now();
if (now - lastArchiveTime < 60 * 60 * 1000) return;
isArchiving = true;
lastArchiveTime = now;
try {
// Archive old events in the background
// This is non-blocking and won't affect day-to-day operations
const archived = await archiveOldEvents(ARCHIVE_THRESHOLD);
if (archived > 0) {
// Archive completed successfully
}
} catch (error) {
// Archive failed (non-critical, continue)
} finally {
isArchiving = false;
}
}
/**
* Manually trigger archive (for testing or manual cleanup)
*/
export async function triggerArchive(): Promise<number> {
if (isArchiving) return 0;
isArchiving = true;
try {
const archived = await archiveOldEvents(ARCHIVE_THRESHOLD);
lastArchiveTime = Date.now();
return archived;
} catch (error) {
return 0;
} finally {
isArchiving = false;
}
}
/**
* Check if archive is currently running
*/
export function isArchiveRunning(): boolean {
return isArchiving;
}

395
src/lib/services/cache/event-archive.ts vendored

@ -0,0 +1,395 @@ @@ -0,0 +1,395 @@
/**
* Event archiving with compression
* Archives old events to save space while keeping them accessible
*/
import { getDB } from './indexeddb-store.js';
import type { NostrEvent } from '../../types/nostr.js';
import type { CachedEvent } from './event-cache.js';
export interface ArchivedEvent {
id: string;
compressed: Uint8Array; // Compressed JSON bytes
created_at: number; // Original event created_at
kind: number;
pubkey: string;
archived_at: number; // When it was archived
}
// Default archive threshold: 30 days
const DEFAULT_ARCHIVE_THRESHOLD = 30 * 24 * 60 * 60 * 1000; // 30 days in ms
/**
* Check if CompressionStream API is available
*/
function isCompressionSupported(): boolean {
return typeof CompressionStream !== 'undefined';
}
/**
* Compress a JSON object to Uint8Array using CompressionStream API
*/
async function compressJSON(data: unknown): Promise<Uint8Array> {
if (!isCompressionSupported()) {
// Fallback: Use JSON string (no compression if API not available)
// In practice, modern browsers support CompressionStream
const jsonString = JSON.stringify(data);
return new TextEncoder().encode(jsonString);
}
const jsonString = JSON.stringify(data);
const stream = new CompressionStream('gzip');
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Write data
writer.write(new TextEncoder().encode(jsonString));
writer.close();
// Read compressed chunks
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
// Combine chunks
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
/**
* Decompress Uint8Array to JSON object
*/
async function decompressJSON(compressed: Uint8Array): Promise<unknown> {
if (!isCompressionSupported()) {
// Fallback: Assume it's uncompressed JSON
return JSON.parse(new TextDecoder().decode(compressed));
}
const stream = new DecompressionStream('gzip');
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Write compressed data
writer.write(new Uint8Array(compressed));
writer.close();
// Read decompressed chunks
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
// Combine chunks
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
// Parse JSON
return JSON.parse(new TextDecoder().decode(result));
}
/**
* Archive an event (compress and move to archive store)
*/
export async function archiveEvent(event: CachedEvent): Promise<void> {
try {
const db = await getDB();
// Compress the event
const compressed = await compressJSON(event);
// Create archived event
const archived: ArchivedEvent = {
id: event.id,
compressed,
created_at: event.created_at,
kind: event.kind,
pubkey: event.pubkey,
archived_at: Date.now()
};
// Store in archive
await db.put('eventArchive', archived);
// Remove from main events store
await db.delete('events', event.id);
} catch (error) {
// Archive failed (non-critical)
// Don't throw - archiving failures shouldn't break the app
}
}
/**
* Archive multiple events in batch
*/
export async function archiveEvents(events: CachedEvent[]): Promise<void> {
if (events.length === 0) return;
try {
const db = await getDB();
const tx = db.transaction(['events', 'eventArchive'], 'readwrite');
// Compress all events in parallel
const archivePromises = events.map(async (event) => {
const compressed = await compressJSON(event);
return {
id: event.id,
compressed,
created_at: event.created_at,
kind: event.kind,
pubkey: event.pubkey,
archived_at: Date.now()
} as ArchivedEvent;
});
const archived = await Promise.all(archivePromises);
// Store all archived events
await Promise.all(archived.map(a => tx.objectStore('eventArchive').put(a)));
// Delete from main events store
await Promise.all(events.map(e => tx.objectStore('events').delete(e.id)));
await tx.done;
} catch (error) {
// Archive failed (non-critical)
}
}
/**
* Retrieve an archived event (decompress and return)
*/
export async function getArchivedEvent(id: string): Promise<CachedEvent | undefined> {
try {
const db = await getDB();
const archived = await db.get('eventArchive', id);
if (!archived) return undefined;
// Decompress
const decompressed = await decompressJSON(archived.compressed);
// Return as CachedEvent
return decompressed as CachedEvent;
} catch (error) {
// Archive read failed (non-critical)
return undefined;
}
}
/**
* Archive old events (older than threshold)
* This is a background operation that doesn't block day-to-day operations
*/
export async function archiveOldEvents(
threshold: number = DEFAULT_ARCHIVE_THRESHOLD
): Promise<number> {
try {
const db = await getDB();
const now = Date.now();
const cutoffTime = now - threshold;
// Find old events
const tx = db.transaction('events', 'readonly');
const index = tx.store.index('created_at');
const oldEvents: CachedEvent[] = [];
for await (const cursor of index.iterate()) {
if (cursor.value.created_at < cutoffTime) {
oldEvents.push(cursor.value as CachedEvent);
}
}
await tx.done;
if (oldEvents.length === 0) return 0;
// Archive in batches to avoid blocking
const BATCH_SIZE = 50;
let archived = 0;
for (let i = 0; i < oldEvents.length; i += BATCH_SIZE) {
const batch = oldEvents.slice(i, i + BATCH_SIZE);
await archiveEvents(batch);
archived += batch.length;
// Yield to browser between batches to avoid blocking
await new Promise(resolve => setTimeout(resolve, 0));
}
return archived;
} catch (error) {
// Archive failed (non-critical)
return 0;
}
}
/**
* Get archived events by kind (decompressed)
*/
export async function getArchivedEventsByKind(
kind: number,
limit?: number
): Promise<CachedEvent[]> {
try {
const db = await getDB();
const tx = db.transaction('eventArchive', 'readonly');
const index = tx.store.index('kind');
const archived = await index.getAll(kind);
await tx.done;
// Decompress in parallel
const decompressed = await Promise.all(
archived.map(a => decompressJSON(a.compressed))
);
const events = decompressed as CachedEvent[];
// Sort and limit
const sorted = events.sort((a, b) => b.created_at - a.created_at);
return limit ? sorted.slice(0, limit) : sorted;
} catch (error) {
// Archive read failed (non-critical)
return [];
}
}
/**
* Get archived events by pubkey (decompressed)
*/
export async function getArchivedEventsByPubkey(
pubkey: string,
limit?: number
): Promise<CachedEvent[]> {
try {
const db = await getDB();
const tx = db.transaction('eventArchive', 'readonly');
const index = tx.store.index('pubkey');
const archived = await index.getAll(pubkey);
await tx.done;
// Decompress in parallel
const decompressed = await Promise.all(
archived.map(a => decompressJSON(a.compressed))
);
const events = decompressed as CachedEvent[];
// Sort and limit
const sorted = events.sort((a, b) => b.created_at - a.created_at);
return limit ? sorted.slice(0, limit) : sorted;
} catch (error) {
// Archive read failed (non-critical)
return [];
}
}
/**
* Restore an archived event back to main cache (unarchive)
*/
export async function unarchiveEvent(id: string): Promise<void> {
try {
const archived = await getArchivedEvent(id);
if (!archived) return;
const db = await getDB();
// Put back in main events store
await db.put('events', archived);
// Remove from archive
await db.delete('eventArchive', id);
} catch (error) {
// Unarchive failed (non-critical)
}
}
/**
* Clear old archived events (permanent deletion)
*/
export async function clearOldArchivedEvents(olderThan: number): Promise<number> {
try {
const db = await getDB();
const tx = db.transaction('eventArchive', 'readwrite');
const index = tx.store.index('created_at');
const idsToDelete: string[] = [];
for await (const cursor of index.iterate()) {
if (cursor.value.created_at < olderThan) {
idsToDelete.push(cursor.value.id);
}
}
await tx.done;
// Delete in batch
if (idsToDelete.length > 0) {
const deleteTx = db.transaction('eventArchive', 'readwrite');
await Promise.all(idsToDelete.map(id => deleteTx.store.delete(id)));
await deleteTx.done;
}
return idsToDelete.length;
} catch (error) {
// Clear failed (non-critical)
return 0;
}
}
/**
* Get archive statistics
*/
export async function getArchiveStats(): Promise<{
totalArchived: number;
totalSize: number; // Approximate size in bytes
oldestArchived: number | null;
}> {
try {
const db = await getDB();
const tx = db.transaction('eventArchive', 'readonly');
let totalArchived = 0;
let totalSize = 0;
let oldestArchived: number | null = null;
for await (const cursor of tx.store.iterate()) {
totalArchived++;
totalSize += cursor.value.compressed.length;
if (!oldestArchived || cursor.value.created_at < oldestArchived) {
oldestArchived = cursor.value.created_at;
}
}
await tx.done;
return {
totalArchived,
totalSize,
oldestArchived
};
} catch (error) {
return {
totalArchived: 0,
totalSize: 0,
oldestArchived: null
};
}
}

12
src/lib/services/cache/event-cache.ts vendored

@ -70,12 +70,18 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> { @@ -70,12 +70,18 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> {
}
/**
* Get event by ID from cache
* Get event by ID from cache (checks both main cache and archive)
*/
export async function getEvent(id: string): Promise<CachedEvent | undefined> {
try {
const db = await getDB();
return await db.get('events', id);
const db = await getDB();
// First check main cache
const event = await db.get('events', id);
if (event) return event as CachedEvent;
// If not found, check archive
const { getArchivedEvent } = await import('./event-archive.js');
return await getArchivedEvent(id);
} catch (error) {
// Cache read failed (non-critical)
return undefined;

22
src/lib/services/cache/indexeddb-store.ts vendored

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard';
const DB_VERSION = 8; // Version 7: Added RSS cache store. Version 8: Added markdown cache store
const DB_VERSION = 9; // Version 7: Added RSS cache store. Version 8: Added markdown cache store. Version 9: Added event archive store
export interface DatabaseSchema {
events: {
@ -43,6 +43,11 @@ export interface DatabaseSchema { @@ -43,6 +43,11 @@ export interface DatabaseSchema {
value: unknown;
indexes: { cached_at: number };
};
eventArchive: {
key: string; // event id
value: unknown;
indexes: { kind: number; pubkey: string; created_at: number };
};
}
let dbInstance: IDBPDatabase<DatabaseSchema> | null = null;
@ -105,6 +110,14 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -105,6 +110,14 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
const markdownStore = db.createObjectStore('markdown', { keyPath: 'hash' });
markdownStore.createIndex('cached_at', 'cached_at', { unique: false });
}
// Event archive store (compressed old events)
if (!db.objectStoreNames.contains('eventArchive')) {
const archiveStore = db.createObjectStore('eventArchive', { keyPath: 'id' });
archiveStore.createIndex('kind', 'kind', { unique: false });
archiveStore.createIndex('pubkey', 'pubkey', { unique: false });
archiveStore.createIndex('created_at', 'created_at', { unique: false });
}
},
blocked() {
// IndexedDB blocked (another tab may have it open)
@ -126,7 +139,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -126,7 +139,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
!dbInstance.objectStoreNames.contains('preferences') ||
!dbInstance.objectStoreNames.contains('drafts') ||
!dbInstance.objectStoreNames.contains('rss') ||
!dbInstance.objectStoreNames.contains('markdown')) {
!dbInstance.objectStoreNames.contains('markdown') ||
!dbInstance.objectStoreNames.contains('eventArchive')) {
// Database is corrupted - close and delete it, then recreate
// Database schema outdated, recreating
dbInstance.close();
@ -162,6 +176,10 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -162,6 +176,10 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
rssStore.createIndex('cached_at', 'cached_at', { unique: false });
const markdownStore = db.createObjectStore('markdown', { keyPath: 'hash' });
markdownStore.createIndex('cached_at', 'cached_at', { unique: false });
const archiveStore = db.createObjectStore('eventArchive', { keyPath: 'id' });
archiveStore.createIndex('kind', 'kind', { unique: false });
archiveStore.createIndex('pubkey', 'pubkey', { unique: false });
archiveStore.createIndex('created_at', 'created_at', { unique: false });
},
blocked() {
// IndexedDB blocked (another tab may have it open)

22
src/lib/services/cache/nsec-key-store.ts vendored

@ -89,6 +89,28 @@ export async function getNsecKey( @@ -89,6 +89,28 @@ export async function getNsecKey(
}
}
/**
* Get the stored ncryptsec directly (without decryption)
* Useful for signing without unnecessary re-encryption
*/
export async function getNcryptsec(pubkey: string): Promise<string | null> {
const db = await getDB();
const stored = await db.get('keys', pubkey);
if (!stored) return null;
const key = stored as StoredNsecKey;
if (!key.ncryptsec || typeof key.ncryptsec !== 'string') {
throw new Error('Stored nsec key has invalid ncryptsec format - key may be corrupted');
}
// Validate ncryptsec format
if (!key.ncryptsec.startsWith('ncryptsec1')) {
throw new Error('Stored nsec key has invalid encryption format - key may need to be re-saved');
}
return key.ncryptsec;
}
/**
* Check if an nsec key exists for a pubkey
*/

27
src/lib/services/nostr/auth-handler.ts

@ -73,19 +73,17 @@ export async function authenticateWithStoredNsec( @@ -73,19 +73,17 @@ export async function authenticateWithStoredNsec(
method: 'nsec',
password, // Store in memory for signing - never persisted to localStorage
signer: async (event) => {
// Retrieve and decrypt key when signing - never log it
// Retrieve stored ncryptsec directly (no need to decrypt and re-encrypt)
const session = sessionManager.getSession();
if (!session || !session.password) {
throw new Error('Session password not available');
}
const { getNsecKey } = await import('../cache/nsec-key-store.js');
const decryptedNsec = await getNsecKey(pubkey, session.password);
if (!decryptedNsec) {
const { getNcryptsec } = await import('../cache/nsec-key-store.js');
const ncryptsec = await getNcryptsec(pubkey);
if (!ncryptsec) {
throw new Error('Stored nsec key not found');
}
// Encrypt to ncryptsec format for signing
const { encryptPrivateKey } = await import('../security/key-management.js');
const ncryptsec = await encryptPrivateKey(decryptedNsec, session.password);
// Use stored ncryptsec directly - it's already encrypted
return signEventWithNsec(event, ncryptsec, session.password);
},
createdAt: Date.now()
@ -152,19 +150,14 @@ export async function authenticateWithNsec( @@ -152,19 +150,14 @@ export async function authenticateWithNsec(
if (!session || !session.password) {
throw new Error('Session password not available');
}
const { getNsecKey } = await import('../cache/nsec-key-store.js');
const { getNcryptsec } = await import('../cache/nsec-key-store.js');
try {
const decryptedNsec = await getNsecKey(pubkey, session.password);
if (!decryptedNsec) {
// Get stored ncryptsec directly (no need to decrypt and re-encrypt)
const ncryptsec = await getNcryptsec(pubkey);
if (!ncryptsec) {
throw new Error('Stored nsec key not found');
}
// Verify the decrypted nsec matches the expected pubkey
const derivedPubkey = await getPublicKeyFromNsec(decryptedNsec);
if (derivedPubkey !== pubkey) {
throw new Error('Stored nsec key does not match the expected pubkey - key may be corrupted');
}
// Encrypt to ncryptsec format for signing
const ncryptsec = await encryptPrivateKey(decryptedNsec, session.password);
// Use stored ncryptsec directly - it's already encrypted
return signEventWithNsec(event, ncryptsec, session.password);
} catch (error) {
// Provide better error message without exposing sensitive data

4
src/routes/+layout.svelte

@ -54,6 +54,10 @@ @@ -54,6 +54,10 @@
if (!sessionManager.isLoggedIn()) {
await sessionManager.restoreSession();
}
// Start archive scheduler (background compression of old events)
const { startArchiveScheduler } = await import('../lib/services/cache/archive-scheduler.js');
startArchiveScheduler();
} catch (error) {
console.error('Failed to restore session:', error);
}

4
src/routes/bookmarks/+page.svelte

@ -920,11 +920,11 @@ @@ -920,11 +920,11 @@
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.profile-results {

145
src/routes/cache/+page.svelte vendored

@ -14,6 +14,8 @@ @@ -14,6 +14,8 @@
import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByKinds, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js';
import { cacheEvent } from '../../lib/services/cache/event-cache.js';
import type { CachedEvent } from '../../lib/services/cache/event-cache.js';
import { getArchiveStats, clearOldArchivedEvents } from '../../lib/services/cache/event-archive.js';
import { triggerArchive } from '../../lib/services/cache/archive-scheduler.js';
import { KIND, getKindInfo } from '../../lib/types/kind-lookup.js';
import { nip19 } from 'nostr-tools';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
@ -21,12 +23,14 @@ @@ -21,12 +23,14 @@
import type { NostrEvent } from '../../lib/types/nostr.js';
let stats = $state<CacheStats | null>(null);
let archiveStats = $state<{ totalArchived: number; totalSize: number; oldestArchived: number | null } | null>(null);
let events = $state<CachedEvent[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let offset = $state(0);
const PAGE_SIZE = 50;
let archiving = $state(false);
// Filters
let selectedKind = $state<number | null>(null);
@ -39,6 +43,7 @@ @@ -39,6 +43,7 @@
onMount(async () => {
await loadStats();
await loadArchiveStats();
await loadEvents();
});
@ -50,6 +55,47 @@ @@ -50,6 +55,47 @@
}
}
async function loadArchiveStats() {
try {
archiveStats = await getArchiveStats();
} catch (error) {
// Failed to load archive stats
}
}
async function handleArchiveOldEvents() {
if (!confirm('Archive events older than 30 days? This will compress them to save space but keep them accessible.')) {
return;
}
archiving = true;
try {
const archived = await triggerArchive();
await loadStats();
await loadArchiveStats();
alert(`Archived ${archived} events. They are now compressed and stored in the archive.`);
} catch (error) {
alert('Failed to archive events');
} finally {
archiving = false;
}
}
async function handleClearOldArchived(days: number) {
const olderThan = Date.now() - (days * 24 * 60 * 60 * 1000);
if (!confirm(`Are you sure you want to permanently delete archived events older than ${days} days? This cannot be undone.`)) {
return;
}
try {
const deleted = await clearOldArchivedEvents(olderThan);
await loadArchiveStats();
alert(`Deleted ${deleted} archived events.`);
} catch (error) {
alert('Failed to delete archived events');
}
}
async function loadEvents(reset = false) {
if (reset) {
offset = 0;
@ -551,6 +597,45 @@ @@ -551,6 +597,45 @@
</div>
</div>
<!-- Archive Section -->
{#if archiveStats}
<div class="archive-section">
<h2>Event Archive</h2>
<div class="archive-stats">
<p>
<strong>Archived Events:</strong> {archiveStats.totalArchived.toLocaleString()}
</p>
<p>
<strong>Archive Size:</strong> {(archiveStats.totalSize / 1024 / 1024).toFixed(2)} MB
</p>
{#if archiveStats.oldestArchived}
<p>
<strong>Oldest Archived:</strong> {new Date(archiveStats.oldestArchived).toLocaleDateString()}
</p>
{/if}
</div>
<div class="archive-actions">
<button
class="bulk-action-button"
onclick={handleArchiveOldEvents}
disabled={archiving}
>
{archiving ? 'Archiving...' : 'Archive Events Older Than 30 Days'}
</button>
<button
class="bulk-action-button"
onclick={() => handleClearOldArchived(365)}
>
Clear Archived Events Older Than 1 Year
</button>
</div>
<p class="archive-note">
Archived events are compressed to save space but remain accessible.
They are automatically decompressed when needed.
</p>
</div>
{/if}
<!-- Bulk Actions -->
<div class="bulk-actions-section">
<h2>Bulk Actions</h2>
@ -733,6 +818,50 @@ @@ -733,6 +818,50 @@
.stats-section,
.filters-section,
.archive-section {
margin-bottom: 2rem;
padding: 1.5rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .archive-section {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
}
.archive-stats {
margin-bottom: 1rem;
}
.archive-stats p {
margin: 0.5rem 0;
color: var(--fog-text, #1f2937);
}
:global(.dark) .archive-stats p {
color: var(--fog-dark-text, #f9fafb);
}
.archive-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.archive-note {
font-size: 0.875em;
color: var(--fog-text-light, #52667a);
margin: 0;
font-style: italic;
}
:global(.dark) .archive-note {
color: var(--fog-dark-text-light, #a8b8d0);
}
.bulk-actions-section,
.events-section {
margin-bottom: 2rem;
@ -771,12 +900,12 @@ @@ -771,12 +900,12 @@
.stat-label {
font-size: 0.875em;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
margin-bottom: 0.5rem;
}
:global(.dark) .stat-label {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.stat-value {
@ -833,11 +962,11 @@ @@ -833,11 +962,11 @@
.kind-count {
margin-right: 1rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .kind-count {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
@ -932,13 +1061,13 @@ @@ -932,13 +1061,13 @@
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875em;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
word-wrap: break-word;
overflow-wrap: break-word;
}
:global(.dark) .event-meta {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.event-meta code {
@ -1003,7 +1132,7 @@ @@ -1003,7 +1132,7 @@
.event-content-preview {
margin: 0 0 0.5rem 0;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.875em;
word-wrap: break-word;
overflow-wrap: break-word;
@ -1012,7 +1141,7 @@ @@ -1012,7 +1141,7 @@
}
:global(.dark) .event-content-preview {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.action-button.delete-action:hover {

4
src/routes/discussions/+page.svelte

@ -241,11 +241,11 @@ @@ -241,11 +241,11 @@
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.profile-results {

4
src/routes/feed/+page.svelte

@ -273,11 +273,11 @@ @@ -273,11 +273,11 @@
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.profile-results {

16
src/routes/find/+page.svelte

@ -1122,12 +1122,12 @@ @@ -1122,12 +1122,12 @@
.section-description {
margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .section-description {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-container {
@ -1297,11 +1297,11 @@ @@ -1297,11 +1297,11 @@
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.profile-results {
@ -1404,12 +1404,12 @@ @@ -1404,12 +1404,12 @@
.no-results {
padding: 2rem;
text-align: center;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .no-results {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.advanced-filters-section {
@ -1515,12 +1515,12 @@ @@ -1515,12 +1515,12 @@
.filter-hint {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
margin-top: 0.25rem;
display: block;
}
:global(.dark) .filter-hint {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

4
src/routes/highlights/+page.svelte

@ -507,11 +507,11 @@ @@ -507,11 +507,11 @@
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.profile-results {

4
src/routes/relay/+page.svelte

@ -478,13 +478,13 @@ @@ -478,13 +478,13 @@
padding: 0.125rem 0.375rem;
border-radius: 0.125rem;
background: var(--fog-border, #e5e7eb);
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-family: monospace;
}
:global(.dark) .relay-category-badge {
background: var(--fog-dark-border, #475569);
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.relay-item:hover .relay-category-badge {

16
src/routes/repos/+page.svelte

@ -423,7 +423,7 @@ @@ -423,7 +423,7 @@
.repo-kind {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
@ -432,12 +432,12 @@ @@ -432,12 +432,12 @@
}
:global(.dark) .repo-kind {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
background: var(--fog-dark-highlight, #374151);
}
.repo-description {
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
margin: 0.5rem 0;
line-height: 1.5;
word-break: break-word;
@ -446,7 +446,7 @@ @@ -446,7 +446,7 @@
}
:global(.dark) .repo-description {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.repo-meta {
@ -454,14 +454,14 @@ @@ -454,14 +454,14 @@
gap: 1rem;
margin-top: 0.75rem;
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
word-break: break-word;
overflow-wrap: break-word;
word-wrap: break-word;
}
:global(.dark) .repo-meta {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-results-section {
@ -500,11 +500,11 @@ @@ -500,11 +500,11 @@
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.profile-results {

28
src/routes/repos/[naddr]/+page.svelte

@ -1312,7 +1312,7 @@ @@ -1312,7 +1312,7 @@
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
@ -1325,7 +1325,7 @@ @@ -1325,7 +1325,7 @@
}
:global(.dark) .tab-button {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.tab-button:hover {
@ -1478,11 +1478,11 @@ @@ -1478,11 +1478,11 @@
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .commit-meta {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.branches-list {
@ -1547,12 +1547,12 @@ @@ -1547,12 +1547,12 @@
.branch-message {
flex: 1;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .branch-message {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
@ -1621,11 +1621,11 @@ @@ -1621,11 +1621,11 @@
.filter-count {
margin-left: auto;
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .filter-count {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.issues-list {
@ -1757,12 +1757,12 @@ @@ -1757,12 +1757,12 @@
.status-changing {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
font-style: italic;
}
:global(.dark) .status-changing {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.issue-comments {
@ -2024,14 +2024,14 @@ @@ -2024,14 +2024,14 @@
}
.doc-kind {
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .doc-kind {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
background: var(--fog-dark-highlight, #475569);
}
@ -2100,10 +2100,10 @@ @@ -2100,10 +2100,10 @@
.pagination-info {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .pagination-info {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

4
src/routes/rss/+page.svelte

@ -711,11 +711,11 @@ @@ -711,11 +711,11 @@
.rss-item-time {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .rss-item-time {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.rss-item-title {

253
src/routes/settings/+page.svelte

@ -18,77 +18,94 @@ @@ -18,77 +18,94 @@
let expiringEvents = $state(false);
let includeClientTag = $state(true);
// PWA install state
let deferredPrompt = $state<BeforeInstallPromptEvent | null>(null);
let showInstallButton = $state(false);
let isInstalled = $state(false);
// Type for beforeinstallprompt event
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
onMount(() => {
// Read current preferences from localStorage (preferred) or DOM (fallback)
const storedTextSize = localStorage.getItem('textSize') as TextSize | null;
const storedLineSpacing = localStorage.getItem('lineSpacing') as LineSpacing | null;
const storedContentWidth = localStorage.getItem('contentWidth') as ContentWidth | null;
const storedTheme = localStorage.getItem('theme');
textSize = storedTextSize || 'medium';
lineSpacing = storedLineSpacing || 'normal';
contentWidth = storedContentWidth || 'medium';
// Read theme from localStorage first, then fallback to DOM/system preference
if (storedTheme) {
isDark = storedTheme === 'dark';
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark = document.documentElement.classList.contains('dark') || prefersDark;
// PWA install state
let deferredPrompt = $state<BeforeInstallPromptEvent | null>(null);
let showInstallButton = $state(false);
let isInstalled = $state(false);
let canInstall = $state(false); // Track if installation is possible
// Type for beforeinstallprompt event
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
// Apply preferences immediately to ensure consistent layout
applyPreferences();
// Load expiring events preference
expiringEvents = hasExpiringEventsEnabled();
// Load client tag preference
includeClientTag = shouldIncludeClientTag();
onMount(() => {
// Read current preferences from localStorage (preferred) or DOM (fallback)
const storedTextSize = localStorage.getItem('textSize') as TextSize | null;
const storedLineSpacing = localStorage.getItem('lineSpacing') as LineSpacing | null;
const storedContentWidth = localStorage.getItem('contentWidth') as ContentWidth | null;
const storedTheme = localStorage.getItem('theme');
// Check if PWA is already installed
if (typeof window !== 'undefined') {
// Check if running in standalone mode (installed PWA)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
const isIOSStandalone = (window.navigator as any).standalone === true;
isInstalled = isStandalone || isIOSStandalone;
// Listen for beforeinstallprompt event
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
showInstallButton = true;
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
// Listen for appinstalled event
const handleAppInstalled = () => {
isInstalled = true;
showInstallButton = false;
deferredPrompt = null;
};
window.addEventListener('appinstalled', handleAppInstalled);
textSize = storedTextSize || 'medium';
lineSpacing = storedLineSpacing || 'normal';
contentWidth = storedContentWidth || 'medium';
// Read theme from localStorage first, then fallback to DOM/system preference
if (storedTheme) {
isDark = storedTheme === 'dark';
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark = document.documentElement.classList.contains('dark') || prefersDark;
}
// Cleanup
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}
});
// Apply preferences immediately to ensure consistent layout
applyPreferences();
// Load expiring events preference
expiringEvents = hasExpiringEventsEnabled();
// Load client tag preference
includeClientTag = shouldIncludeClientTag();
// Check if PWA is already installed
if (typeof window !== 'undefined') {
// Check if running in standalone mode (installed PWA)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
const isIOSStandalone = (window.navigator as any).standalone === true;
isInstalled = isStandalone || isIOSStandalone;
// Check if PWA installation is supported
// Show install button if:
// 1. Not already installed
// 2. Running on a platform that supports PWA installation
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const isChrome = /Chrome/.test(navigator.userAgent);
const isEdge = /Edg/.test(navigator.userAgent);
const isSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
const isFirefox = /Firefox/.test(navigator.userAgent);
// Enable install button if browser supports it (even if beforeinstallprompt hasn't fired yet)
// Most modern browsers support PWA installation, so be more permissive
canInstall = !isInstalled && (isChrome || isEdge || isFirefox || (isMobile && isSafari) || 'serviceWorker' in navigator);
// Listen for beforeinstallprompt event (Chrome/Edge)
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
showInstallButton = true;
canInstall = true;
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
// Listen for appinstalled event
const handleAppInstalled = () => {
isInstalled = true;
showInstallButton = false;
canInstall = false;
deferredPrompt = null;
};
window.addEventListener('appinstalled', handleAppInstalled);
// Cleanup
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}
});
function applyPreferences() {
// Apply text size
@ -152,26 +169,34 @@ @@ -152,26 +169,34 @@
}
async function handlePWAInstall() {
if (!deferredPrompt) return;
try {
// Show the install prompt
await deferredPrompt.prompt();
// Wait for user response
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User accepted the install prompt');
} else {
console.log('User dismissed the install prompt');
// If we have a deferred prompt (Chrome/Edge), use it
if (deferredPrompt) {
try {
// Show the install prompt
await deferredPrompt.prompt();
// Wait for user response
const { outcome } = await deferredPrompt.userChoice;
// Clear the deferred prompt
deferredPrompt = null;
showInstallButton = false;
} catch (error) {
// Failed to show install prompt
}
// Clear the deferred prompt
deferredPrompt = null;
showInstallButton = false;
} catch (error) {
console.error('Error showing install prompt:', error);
return;
}
// For iOS Safari or other browsers, show instructions
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
const isSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
if (isIOS && isSafari) {
// iOS Safari: Show instructions
alert('To install aitherboard on iOS:\n\n1. Tap the Share button (square with arrow)\n2. Scroll down and tap "Add to Home Screen"\n3. Tap "Add"');
} else {
// Other browsers: Show general instructions
alert('To install aitherboard:\n\n1. Look for an install icon in your browser\'s address bar\n2. Or use your browser\'s menu to "Install App" or "Add to Home Screen"');
}
}
</script>
@ -193,6 +218,32 @@ @@ -193,6 +218,32 @@
</div>
<div class="space-y-6">
<!-- PWA Install - Always visible -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded" style="display: block !important; visibility: visible !important;">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">Install App</span>
</div>
<div class="preference-controls">
{#if isInstalled}
<div class="flex items-center gap-2 text-fog-text dark:text-fog-dark-text">
<Icon name="check" size={16} />
<span>App is installed</span>
</div>
{:else}
<button
onclick={handlePWAInstall}
class="toggle-button"
aria-label="Install aitherboard as a Progressive Web App"
>
<span>📱 Download the PWA</span>
</button>
{/if}
</div>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2" style="font-size: 0.875em;">
Install aitherboard as a Progressive Web App to use it offline and access it from your home screen.
</p>
</div>
<!-- Theme Toggle -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
@ -356,36 +407,6 @@ @@ -356,36 +407,6 @@
</p>
</div>
<!-- PWA Install -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">Install App</span>
</div>
<div class="preference-controls">
{#if isInstalled}
<div class="flex items-center gap-2 text-fog-text dark:text-fog-dark-text">
<Icon name="check" size={16} />
<span>App is installed</span>
</div>
{:else if showInstallButton}
<button
onclick={handlePWAInstall}
class="toggle-button"
aria-label="Install aitherboard as a Progressive Web App"
>
<span>📱 Install App</span>
</button>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">
Install prompt not available. Make sure you're using a supported browser and the app meets PWA requirements.
</p>
{/if}
</div>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2" style="font-size: 0.875em;">
Install aitherboard as a Progressive Web App to use it offline and access it from your home screen.
</p>
</div>
<!-- Keyboard Shortcuts -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">

4
src/routes/topics/+page.svelte

@ -424,11 +424,11 @@ @@ -424,11 +424,11 @@
.topic-count {
font-size: 0.75rem;
opacity: 0.7;
color: var(--fog-text-light, #6b7280);
color: var(--fog-text-light, #52667a);
}
:global(.dark) .topic-count {
color: var(--fog-dark-text-light, #9ca3af);
color: var(--fog-dark-text-light, #a8b8d0);
}
.topic-item:hover .topic-count {

Loading…
Cancel
Save