diff --git a/src/app.css b/src/app.css index 1272609..5fe0e1b 100644 --- a/src/app.css +++ b/src/app.css @@ -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 { 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 { } :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 { diff --git a/src/lib/components/EventMenu.svelte b/src/lib/components/EventMenu.svelte index 98254bd..96a41a9 100644 --- a/src/lib/components/EventMenu.svelte +++ b/src/lib/components/EventMenu.svelte @@ -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 @@ } :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 @@ .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 { diff --git a/src/lib/components/content/EmbeddedEvent.svelte b/src/lib/components/content/EmbeddedEvent.svelte index fa2cb32..25fe923 100644 --- a/src/lib/components/content/EmbeddedEvent.svelte +++ b/src/lib/components/content/EmbeddedEvent.svelte @@ -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 { diff --git a/src/lib/components/content/EmojiDrawer.svelte b/src/lib/components/content/EmojiDrawer.svelte index 3c7b7b1..175d2b3 100644 --- a/src/lib/components/content/EmojiDrawer.svelte +++ b/src/lib/components/content/EmojiDrawer.svelte @@ -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 @@ } :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 @@ .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 { diff --git a/src/lib/components/content/EmojiPicker.svelte b/src/lib/components/content/EmojiPicker.svelte index 0ea5c8c..10d8c00 100644 --- a/src/lib/components/content/EmojiPicker.svelte +++ b/src/lib/components/content/EmojiPicker.svelte @@ -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 @@ .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 { diff --git a/src/lib/components/content/FileExplorer.svelte b/src/lib/components/content/FileExplorer.svelte index ffa1b4e..9a9298d 100644 --- a/src/lib/components/content/FileExplorer.svelte +++ b/src/lib/components/content/FileExplorer.svelte @@ -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 @@ .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 @@ .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 @@ 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 { diff --git a/src/lib/components/content/GifPicker.svelte b/src/lib/components/content/GifPicker.svelte index cd4f891..1e3b798 100644 --- a/src/lib/components/content/GifPicker.svelte +++ b/src/lib/components/content/GifPicker.svelte @@ -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 @@ } :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 @@ .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 @@ 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 @@ } :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 { diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 04fcafe..f1e5c20 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -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 */ diff --git a/src/lib/components/content/MentionsAutocomplete.svelte b/src/lib/components/content/MentionsAutocomplete.svelte index 90c24b9..bbe1972 100644 --- a/src/lib/components/content/MentionsAutocomplete.svelte +++ b/src/lib/components/content/MentionsAutocomplete.svelte @@ -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); } diff --git a/src/lib/components/content/MetadataCard.svelte b/src/lib/components/content/MetadataCard.svelte index 443adda..72bf187 100644 --- a/src/lib/components/content/MetadataCard.svelte +++ b/src/lib/components/content/MetadataCard.svelte @@ -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 @@ } .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); } diff --git a/src/lib/components/content/PollCard.svelte b/src/lib/components/content/PollCard.svelte index f667b52..c3b39b2 100644 --- a/src/lib/components/content/PollCard.svelte +++ b/src/lib/components/content/PollCard.svelte @@ -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 @@ 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 @@ 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); } diff --git a/src/lib/components/content/ReferencedEventPreview.svelte b/src/lib/components/content/ReferencedEventPreview.svelte index 108ad22..b501cd0 100644 --- a/src/lib/components/content/ReferencedEventPreview.svelte +++ b/src/lib/components/content/ReferencedEventPreview.svelte @@ -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 @@ .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 { diff --git a/src/lib/components/content/RichTextEditor.svelte b/src/lib/components/content/RichTextEditor.svelte index 0efe699..e9869ed 100644 --- a/src/lib/components/content/RichTextEditor.svelte +++ b/src/lib/components/content/RichTextEditor.svelte @@ -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 @@ :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) { diff --git a/src/lib/components/find/SearchAddressableEvents.svelte b/src/lib/components/find/SearchAddressableEvents.svelte index 87d73c4..d60e5e2 100644 --- a/src/lib/components/find/SearchAddressableEvents.svelte +++ b/src/lib/components/find/SearchAddressableEvents.svelte @@ -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 @@ } .search-input::placeholder { - color: var(--fog-text-light, #9ca3af); + color: var(--fog-text-light, #52667a); } :global(.dark) .search-input { @@ -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 @@ 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 @@ .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 @@ .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 @@ .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); } diff --git a/src/lib/components/layout/CacheBadge.svelte b/src/lib/components/layout/CacheBadge.svelte index 62bbf3d..fb82fce 100644 --- a/src/lib/components/layout/CacheBadge.svelte +++ b/src/lib/components/layout/CacheBadge.svelte @@ -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); } diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte index f608598..18f0bfd 100644 --- a/src/lib/components/layout/ProfileBadge.svelte +++ b/src/lib/components/layout/ProfileBadge.svelte @@ -101,7 +101,7 @@ case 'green': return '#22c55e'; default: - return '#9ca3af'; + return '#a8b8d0'; } } diff --git a/src/lib/components/layout/RelayBadge.svelte b/src/lib/components/layout/RelayBadge.svelte index 6a7d1aa..9988593 100644 --- a/src/lib/components/layout/RelayBadge.svelte +++ b/src/lib/components/layout/RelayBadge.svelte @@ -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 @@ } :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); } diff --git a/src/lib/components/layout/SearchBox.svelte b/src/lib/components/layout/SearchBox.svelte index a11d573..9874593 100644 --- a/src/lib/components/layout/SearchBox.svelte +++ b/src/lib/components/layout/SearchBox.svelte @@ -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 @@ .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 @@ .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 @@ .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); } diff --git a/src/lib/components/layout/UnifiedSearch.svelte b/src/lib/components/layout/UnifiedSearch.svelte index ec013b6..daf3e77 100644 --- a/src/lib/components/layout/UnifiedSearch.svelte +++ b/src/lib/components/layout/UnifiedSearch.svelte @@ -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 @@ } :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 @@ .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 @@ .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 @@ .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); } diff --git a/src/lib/components/profile/BookmarksPanel.svelte b/src/lib/components/profile/BookmarksPanel.svelte index 84398e2..e2e0eb5 100644 --- a/src/lib/components/profile/BookmarksPanel.svelte +++ b/src/lib/components/profile/BookmarksPanel.svelte @@ -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 @@ } :global(.dark) .bookmarks-panel-close { - color: var(--fog-dark-text-light, #9ca3af); + color: var(--fog-dark-text-light, #a8b8d0); } .bookmarks-panel-close:hover { diff --git a/src/lib/components/profile/ProfileEventsPanel.svelte b/src/lib/components/profile/ProfileEventsPanel.svelte index b420a84..b742d12 100644 --- a/src/lib/components/profile/ProfileEventsPanel.svelte +++ b/src/lib/components/profile/ProfileEventsPanel.svelte @@ -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 @@ } :global(.dark) .panel-close { - color: var(--fog-dark-text-light, #6b7280); + color: var(--fog-dark-text-light, #a8b8d0); } :global(.dark) .panel-close:hover { diff --git a/src/lib/components/relay/RelayInfo.svelte b/src/lib/components/relay/RelayInfo.svelte index 86cdf0d..edd1ecf 100644 --- a/src/lib/components/relay/RelayInfo.svelte +++ b/src/lib/components/relay/RelayInfo.svelte @@ -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 @@ .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 { diff --git a/src/lib/components/write/AdvancedEditor.svelte b/src/lib/components/write/AdvancedEditor.svelte index e653a90..dacb9a9 100644 --- a/src/lib/components/write/AdvancedEditor.svelte +++ b/src/lib/components/write/AdvancedEditor.svelte @@ -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 @@ } .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 { diff --git a/src/lib/components/write/CreateEventForm.svelte b/src/lib/components/write/CreateEventForm.svelte index 20111d0..f85e50a 100644 --- a/src/lib/components/write/CreateEventForm.svelte +++ b/src/lib/components/write/CreateEventForm.svelte @@ -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 { diff --git a/src/lib/components/write/EditEventForm.svelte b/src/lib/components/write/EditEventForm.svelte index e000592..8cbc6da 100644 --- a/src/lib/components/write/EditEventForm.svelte +++ b/src/lib/components/write/EditEventForm.svelte @@ -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 { diff --git a/src/lib/components/write/FindEventForm.svelte b/src/lib/components/write/FindEventForm.svelte index 205002f..0e4f8ed 100644 --- a/src/lib/components/write/FindEventForm.svelte +++ b/src/lib/components/write/FindEventForm.svelte @@ -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 { diff --git a/src/lib/modules/comments/Comment.svelte b/src/lib/modules/comments/Comment.svelte index 562b61c..29ce1b7 100644 --- a/src/lib/modules/comments/Comment.svelte +++ b/src/lib/modules/comments/Comment.svelte @@ -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 { diff --git a/src/lib/modules/discussions/DiscussionCard.svelte b/src/lib/modules/discussions/DiscussionCard.svelte index 50b8206..bcc1bde 100644 --- a/src/lib/modules/discussions/DiscussionCard.svelte +++ b/src/lib/modules/discussions/DiscussionCard.svelte @@ -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 @@ } :global(.dark) .kind-badge { - color: var(--fog-dark-text-light, #6b7280); + color: var(--fog-dark-text-light, #a8b8d0); } .kind-number { diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index a0fcd3b..bb8acfb 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -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 @@ } :global(.dark) .kind-badge { - color: var(--fog-dark-text-light, #6b7280); + color: var(--fog-dark-text-light, #a8b8d0); } .kind-number { diff --git a/src/lib/modules/feed/HighlightCard.svelte b/src/lib/modules/feed/HighlightCard.svelte index fc1ca22..7789ac8 100644 --- a/src/lib/modules/feed/HighlightCard.svelte +++ b/src/lib/modules/feed/HighlightCard.svelte @@ -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 { diff --git a/src/lib/modules/feed/Reply.svelte b/src/lib/modules/feed/Reply.svelte index eaf09eb..9afbde1 100644 --- a/src/lib/modules/feed/Reply.svelte +++ b/src/lib/modules/feed/Reply.svelte @@ -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 { diff --git a/src/lib/modules/feed/ZapReceiptReply.svelte b/src/lib/modules/feed/ZapReceiptReply.svelte index bd2b57f..1173827 100644 --- a/src/lib/modules/feed/ZapReceiptReply.svelte +++ b/src/lib/modules/feed/ZapReceiptReply.svelte @@ -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 { diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index 2adcc2f..2bf0a08 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -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 @@ .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 @@ } :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); } diff --git a/src/lib/modules/reactions/FeedReactionButtons.svelte b/src/lib/modules/reactions/FeedReactionButtons.svelte index 195c436..2819366 100644 --- a/src/lib/modules/reactions/FeedReactionButtons.svelte +++ b/src/lib/modules/reactions/FeedReactionButtons.svelte @@ -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 { diff --git a/src/lib/modules/rss/RSSCommentForm.svelte b/src/lib/modules/rss/RSSCommentForm.svelte index d9d5dbe..006de6c 100644 --- a/src/lib/modules/rss/RSSCommentForm.svelte +++ b/src/lib/modules/rss/RSSCommentForm.svelte @@ -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 { diff --git a/src/lib/services/auth/anonymous-signer.ts b/src/lib/services/auth/anonymous-signer.ts index 658bcab..bbd53b0 100644 --- a/src/lib/services/auth/anonymous-signer.ts +++ b/src/lib/services/auth/anonymous-signer.ts @@ -42,16 +42,15 @@ export async function signEventWithAnonymous( pubkey: string, password: string ): Promise { - // 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); } diff --git a/src/lib/services/cache/anonymous-key-store.ts b/src/lib/services/cache/anonymous-key-store.ts index f7353f1..9a77e29 100644 --- a/src/lib/services/cache/anonymous-key-store.ts +++ b/src/lib/services/cache/anonymous-key-store.ts @@ -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 { + 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) */ diff --git a/src/lib/services/cache/archive-scheduler.ts b/src/lib/services/cache/archive-scheduler.ts new file mode 100644 index 0000000..8a0eb12 --- /dev/null +++ b/src/lib/services/cache/archive-scheduler.ts @@ -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 { + // 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 { + 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; +} diff --git a/src/lib/services/cache/event-archive.ts b/src/lib/services/cache/event-archive.ts new file mode 100644 index 0000000..78759e5 --- /dev/null +++ b/src/lib/services/cache/event-archive.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + }; + } +} diff --git a/src/lib/services/cache/event-cache.ts b/src/lib/services/cache/event-cache.ts index 862886f..da57ada 100644 --- a/src/lib/services/cache/event-cache.ts +++ b/src/lib/services/cache/event-cache.ts @@ -70,12 +70,18 @@ export async function cacheEvents(events: NostrEvent[]): Promise { } /** - * 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 { 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; diff --git a/src/lib/services/cache/indexeddb-store.ts b/src/lib/services/cache/indexeddb-store.ts index b16cec5..5594dca 100644 --- a/src/lib/services/cache/indexeddb-store.ts +++ b/src/lib/services/cache/indexeddb-store.ts @@ -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 { value: unknown; indexes: { cached_at: number }; }; + eventArchive: { + key: string; // event id + value: unknown; + indexes: { kind: number; pubkey: string; created_at: number }; + }; } let dbInstance: IDBPDatabase | null = null; @@ -105,6 +110,14 @@ export async function getDB(): Promise> { 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> { !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> { 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) diff --git a/src/lib/services/cache/nsec-key-store.ts b/src/lib/services/cache/nsec-key-store.ts index a97f594..31a7515 100644 --- a/src/lib/services/cache/nsec-key-store.ts +++ b/src/lib/services/cache/nsec-key-store.ts @@ -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 { + 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 */ diff --git a/src/lib/services/nostr/auth-handler.ts b/src/lib/services/nostr/auth-handler.ts index 7c05794..adf57a5 100644 --- a/src/lib/services/nostr/auth-handler.ts +++ b/src/lib/services/nostr/auth-handler.ts @@ -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( 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 diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 37dbb6b..93e1765 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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); } diff --git a/src/routes/bookmarks/+page.svelte b/src/routes/bookmarks/+page.svelte index 5fe284b..242bd87 100644 --- a/src/routes/bookmarks/+page.svelte +++ b/src/routes/bookmarks/+page.svelte @@ -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 { diff --git a/src/routes/cache/+page.svelte b/src/routes/cache/+page.svelte index aa34d22..538ff69 100644 --- a/src/routes/cache/+page.svelte +++ b/src/routes/cache/+page.svelte @@ -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 @@ import type { NostrEvent } from '../../lib/types/nostr.js'; let stats = $state(null); + let archiveStats = $state<{ totalArchived: number; totalSize: number; oldestArchived: number | null } | null>(null); let events = $state([]); 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(null); @@ -39,6 +43,7 @@ onMount(async () => { await loadStats(); + await loadArchiveStats(); await loadEvents(); }); @@ -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 @@ + + {#if archiveStats} +
+

Event Archive

+
+

+ Archived Events: {archiveStats.totalArchived.toLocaleString()} +

+

+ Archive Size: {(archiveStats.totalSize / 1024 / 1024).toFixed(2)} MB +

+ {#if archiveStats.oldestArchived} +

+ Oldest Archived: {new Date(archiveStats.oldestArchived).toLocaleDateString()} +

+ {/if} +
+
+ + +
+

+ Archived events are compressed to save space but remain accessible. + They are automatically decompressed when needed. +

+
+ {/if} +

Bulk Actions

@@ -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 @@ .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 @@ .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 @@ 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 @@ .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 @@ } :global(.dark) .event-content-preview { - color: var(--fog-dark-text-light, #9ca3af); + color: var(--fog-dark-text-light, #a8b8d0); } .action-button.delete-action:hover { diff --git a/src/routes/discussions/+page.svelte b/src/routes/discussions/+page.svelte index a97d97b..0ee134c 100644 --- a/src/routes/discussions/+page.svelte +++ b/src/routes/discussions/+page.svelte @@ -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 { diff --git a/src/routes/feed/+page.svelte b/src/routes/feed/+page.svelte index e52847d..5acc3c3 100644 --- a/src/routes/feed/+page.svelte +++ b/src/routes/feed/+page.svelte @@ -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 { diff --git a/src/routes/find/+page.svelte b/src/routes/find/+page.svelte index c3ef541..c3b8023 100644 --- a/src/routes/find/+page.svelte +++ b/src/routes/find/+page.svelte @@ -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 @@ 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 @@ .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 @@ .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); } diff --git a/src/routes/highlights/+page.svelte b/src/routes/highlights/+page.svelte index dee8217..b88ab54 100644 --- a/src/routes/highlights/+page.svelte +++ b/src/routes/highlights/+page.svelte @@ -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 { diff --git a/src/routes/relay/+page.svelte b/src/routes/relay/+page.svelte index a48f7eb..fef256f 100644 --- a/src/routes/relay/+page.svelte +++ b/src/routes/relay/+page.svelte @@ -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 { diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte index 5ce37ef..28fafe1 100644 --- a/src/routes/repos/+page.svelte +++ b/src/routes/repos/+page.svelte @@ -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 @@ } :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 @@ } :global(.dark) .repo-description { - color: var(--fog-dark-text-light, #9ca3af); + color: var(--fog-dark-text-light, #a8b8d0); } .repo-meta { @@ -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 @@ 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 { diff --git a/src/routes/repos/[naddr]/+page.svelte b/src/routes/repos/[naddr]/+page.svelte index 0c70d6e..1a73af1 100644 --- a/src/routes/repos/[naddr]/+page.svelte +++ b/src/routes/repos/[naddr]/+page.svelte @@ -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 @@ } :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 @@ 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 @@ .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 @@ .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 @@ .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 @@ } .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 @@ .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); } diff --git a/src/routes/rss/+page.svelte b/src/routes/rss/+page.svelte index 11050fa..c3cc7cf 100644 --- a/src/routes/rss/+page.svelte +++ b/src/routes/rss/+page.svelte @@ -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 { diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 114a904..6379af2 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -18,77 +18,94 @@ let expiringEvents = $state(false); let includeClientTag = $state(true); - // PWA install state - let deferredPrompt = $state(null); - let showInstallButton = $state(false); - let isInstalled = $state(false); - - // Type for beforeinstallprompt event - interface BeforeInstallPromptEvent extends Event { - prompt(): Promise; - 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(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; + 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 @@ } 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"'); } } @@ -193,6 +218,32 @@
+ +
+
+ Install App +
+
+ {#if isInstalled} +
+ + App is installed +
+ {:else} + + {/if} +
+

+ Install aitherboard as a Progressive Web App to use it offline and access it from your home screen. +

+
+
@@ -356,36 +407,6 @@

- -
-
- Install App -
-
- {#if isInstalled} -
- - App is installed -
- {:else if showInstallButton} - - {:else} -

- Install prompt not available. Make sure you're using a supported browser and the app meets PWA requirements. -

- {/if} -
-

- Install aitherboard as a Progressive Web App to use it offline and access it from your home screen. -

-
-
diff --git a/src/routes/topics/+page.svelte b/src/routes/topics/+page.svelte index 15d2ab1..fd33a3f 100644 --- a/src/routes/topics/+page.svelte +++ b/src/routes/topics/+page.svelte @@ -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 {