Browse Source

add favorite relays to the relay page and a field for entering a relay manually

master
Silberengel 1 month ago
parent
commit
77c45b3a6c
  1. 12
      src/app.css
  2. 3
      src/lib/components/content/MediaAttachments.svelte
  3. 76
      src/lib/components/preferences/UserPreferences.svelte
  4. 70
      src/lib/components/write/CreateEventForm.svelte
  5. 32
      src/lib/components/write/FindEventForm.svelte
  6. 4
      src/lib/modules/discussions/DiscussionCard.svelte
  7. 885
      src/lib/modules/feed/FeedPage.svelte
  8. 263
      src/lib/modules/feed/FeedPost.svelte
  9. 7
      src/lib/services/nostr/nostr-client.ts
  10. 6
      src/routes/cache/+page.svelte
  11. 36
      src/routes/discussions/+page.svelte
  12. 26
      src/routes/feed/+page.svelte
  13. 40
      src/routes/feed/relay/[relay]/+page.svelte
  14. 18
      src/routes/find/+page.svelte
  15. 300
      src/routes/relay/+page.svelte
  16. 23
      src/routes/replaceable/[d_tag]/+page.svelte
  17. 35
      src/routes/repos/+page.svelte
  18. 6
      src/routes/rss/+page.svelte
  19. 3
      src/routes/topics/+page.svelte
  20. 23
      src/routes/topics/[name]/+page.svelte
  21. 3
      src/routes/write/+page.svelte

12
src/app.css

@ -108,11 +108,11 @@ h1 {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
margin-top: 0; margin-top: 0;
font-weight: 700; font-weight: 700;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
} }
:global(.dark) h1 { :global(.dark) h1 {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
h2 { h2 {
@ -121,11 +121,11 @@ h2 {
margin-bottom: 0.875rem; margin-bottom: 0.875rem;
margin-top: 0; margin-top: 0;
font-weight: 600; font-weight: 600;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
} }
:global(.dark) h2 { :global(.dark) h2 {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
h3 { h3 {
@ -134,11 +134,11 @@ h3 {
margin-bottom: 0.625rem; margin-bottom: 0.625rem;
margin-top: 1.25rem; margin-top: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
} }
:global(.dark) h3 { :global(.dark) h3 {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
h4, h5, h6 { h4, h5, h6 {

3
src/lib/components/content/MediaAttachments.svelte

@ -384,6 +384,7 @@
.cover-image img { .cover-image img {
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
max-width: 600px;
} }
:global(.dark) .cover-image img { :global(.dark) .cover-image img {
@ -412,7 +413,7 @@
.media-item img, .media-item img,
.media-item video { .media-item video {
max-width: 100%; max-width: 600px;
height: auto; height: auto;
} }

76
src/lib/components/preferences/UserPreferences.svelte

@ -126,13 +126,21 @@
$effect(() => { $effect(() => {
if (showPreferences) { if (showPreferences) {
// Prevent body scroll when modal is open
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
showPreferences = false; showPreferences = false;
} }
}; };
document.addEventListener('keydown', handleEscape); document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
return () => {
document.body.style.overflow = originalOverflow;
document.removeEventListener('keydown', handleEscape);
};
} }
}); });
</script> </script>
@ -304,7 +312,7 @@
border-radius: 0.25rem; border-radius: 0.25rem;
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
@ -312,7 +320,7 @@
:global(.dark) .preferences-button { :global(.dark) .preferences-button {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.preferences-button:hover { .preferences-button:hover {
@ -331,31 +339,37 @@
} }
.preferences-backdrop { .preferences-backdrop {
position: fixed; position: fixed !important;
top: 0; top: 0 !important;
left: 0; left: 0 !important;
right: 0; right: 0 !important;
bottom: 0; bottom: 0 !important;
background: rgba(0, 0, 0, 0.5); width: 100vw !important;
height: 100vh !important;
background: rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
z-index: 999; -webkit-backdrop-filter: blur(4px);
z-index: 99999 !important;
animation: fadeIn 0.2s ease-out; animation: fadeIn 0.2s ease-out;
pointer-events: auto !important;
isolation: isolate;
} }
.preferences-panel { .preferences-panel {
position: fixed; position: fixed !important;
top: 0; top: 0 !important;
left: 0; left: 0 !important;
width: 320px; width: 320px;
height: 100vh; height: 100vh;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
border-right: 1px solid var(--fog-border, #e5e7eb); border-right: 1px solid var(--fog-border, #e5e7eb);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1); box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
z-index: 1000; z-index: 100000 !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
animation: slideIn 0.3s ease-out; animation: slideIn 0.3s ease-out;
overflow: hidden; overflow: hidden;
isolation: isolate;
} }
:global(.dark) .preferences-panel { :global(.dark) .preferences-panel {
@ -399,11 +413,11 @@
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
} }
:global(.dark) .preferences-header h2 { :global(.dark) .preferences-header h2 {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.close-button { .close-button {
@ -417,13 +431,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
border-radius: 0.25rem; border-radius: 0.25rem;
transition: background 0.2s; transition: background 0.2s;
} }
:global(.dark) .close-button { :global(.dark) .close-button {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.close-button:hover { .close-button:hover {
@ -450,13 +464,13 @@
display: block; display: block;
font-weight: 600; font-weight: 600;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
padding: 0; padding: 0;
font-size: 0.875rem; font-size: 0.875rem;
} }
:global(.dark) .preference-label { :global(.dark) .preference-label {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.preference-options { .preference-options {
@ -470,7 +484,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
font-size: 0.875rem; font-size: 0.875rem;
@ -485,7 +499,7 @@
:global(.dark) .preference-option { :global(.dark) .preference-option {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.preference-option:hover { .preference-option:hover {
@ -499,13 +513,13 @@
.preference-option.active { .preference-option.active {
background: var(--fog-accent, #64748b); background: var(--fog-accent, #64748b);
color: white; color: var(--fog-text, #f1f5f9);
border-color: var(--fog-accent, #64748b); border-color: var(--fog-accent, #64748b);
} }
:global(.dark) .preference-option.active { :global(.dark) .preference-option.active {
background: var(--fog-dark-accent, #94a3b8); background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937); color: var(--fog-dark-text, #f1f5f9);
border-color: var(--fog-dark-accent, #94a3b8); border-color: var(--fog-dark-accent, #94a3b8);
} }
@ -514,12 +528,12 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
cursor: pointer; cursor: pointer;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
} }
:global(.dark) .checkbox-label { :global(.dark) .checkbox-label {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.checkbox-input { .checkbox-input {
@ -545,14 +559,14 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
} }
:global(.dark) .text-input { :global(.dark) .text-input {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.manage-cache-button { .manage-cache-button {
@ -561,7 +575,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-accent, #64748b); background: var(--fog-accent, #64748b);
color: white; color: var(--fog-text, #f1f5f9);
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
@ -595,11 +609,11 @@
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
} }
:global(.dark) .about-title { :global(.dark) .about-title {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.about-text { .about-text {

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

@ -1135,7 +1135,7 @@
.create-form-container { .create-form-container {
display: flex; display: flex;
gap: 2rem; gap: 2rem;
max-width: 1200px; max-width: var(--content-width);
width: 100%; width: 100%;
} }
@ -1175,14 +1175,14 @@
.form-title { .form-title {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
flex-shrink: 0; flex-shrink: 0;
} }
:global(.dark) .form-title { :global(.dark) .form-title {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.help-text-panel { .help-text-panel {
@ -1215,13 +1215,13 @@
.help-description { .help-description {
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
line-height: 1.5; line-height: 1.5;
flex: 1; flex: 1;
} }
:global(.dark) .help-description { :global(.dark) .help-description {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.example-button-wrapper { .example-button-wrapper {
@ -1234,7 +1234,7 @@
border-radius: 50%; border-radius: 50%;
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
@ -1248,12 +1248,12 @@
:global(.dark) .example-button { :global(.dark) .example-button {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.example-button:hover { .example-button:hover {
background: var(--fog-accent, #64748b); background: var(--fog-accent, #64748b);
color: white; color: var(--fog-text, #f1f5f9);
border-color: var(--fog-accent, #64748b); border-color: var(--fog-accent, #64748b);
} }
@ -1283,14 +1283,14 @@
margin: 0; margin: 0;
font-size: 0.75rem; font-size: 0.75rem;
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
line-height: 1.4; line-height: 1.4;
} }
:global(.dark) .example-json { :global(.dark) .example-json {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.suggested-tags { .suggested-tags {
@ -1300,12 +1300,12 @@
.suggested-tags strong { .suggested-tags strong {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.8125rem; font-size: 0.8125rem;
} }
:global(.dark) .suggested-tags strong { :global(.dark) .suggested-tags strong {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.suggested-tags ul { .suggested-tags ul {
@ -1331,12 +1331,12 @@
.form-label { .form-label {
font-weight: 500; font-weight: 500;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
} }
:global(.dark) .form-label { :global(.dark) .form-label {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.kind-select { .kind-select {
@ -1344,14 +1344,14 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
} }
:global(.dark) .kind-select { :global(.dark) .kind-select {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.custom-kind-input { .custom-kind-input {
@ -1366,14 +1366,14 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
} }
:global(.dark) .kind-id-input { :global(.dark) .kind-id-input {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.content-input { .content-input {
@ -1384,7 +1384,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
font-family: monospace; font-family: monospace;
resize: vertical; resize: vertical;
@ -1399,7 +1399,7 @@
:global(.dark) .content-input { :global(.dark) .content-input {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.tags-list { .tags-list {
@ -1433,14 +1433,14 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
} }
:global(.dark) .tag-input { :global(.dark) .tag-input {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.tag-add-value, .tag-add-value,
@ -1449,7 +1449,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6); background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
min-width: 2rem; min-width: 2rem;
@ -1459,7 +1459,7 @@
:global(.dark) .tag-remove { :global(.dark) .tag-remove {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.tag-add-value:hover, .tag-add-value:hover,
@ -1481,7 +1481,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6); background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -1489,7 +1489,7 @@
:global(.dark) .add-tag-button { :global(.dark) .add-tag-button {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.add-tag-button:hover { .add-tag-button:hover {
@ -1520,7 +1520,7 @@
.publish-button { .publish-button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b); background: var(--fog-accent, #64748b);
color: white; color: var(--fog-text, #f1f5f9);
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
@ -1556,18 +1556,18 @@
.republish-text { .republish-text {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
} }
:global(.dark) .republish-text { :global(.dark) .republish-text {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.republish-button { .republish-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b); background: var(--fog-accent, #64748b);
color: white; color: var(--fog-text, #f1f5f9);
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
@ -1608,7 +1608,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
@ -1627,7 +1627,7 @@
:global(.dark) .toolbar-button { :global(.dark) .toolbar-button {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
} }
@ -1660,7 +1660,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6); background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
transition: all 0.2s; transition: all 0.2s;
@ -1669,7 +1669,7 @@
:global(.dark) .content-button { :global(.dark) .content-button {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.content-button:hover:not(:disabled) { .content-button:hover:not(:disabled) {

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

@ -146,23 +146,23 @@
.find-form { .find-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem;
} }
.form-title { .form-title {
margin: 0; margin: 0 0 1.5rem 0;
font-size: 1.5rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
} }
:global(.dark) .form-title { :global(.dark) .form-title {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.form-description { .form-description {
margin: 0; margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280); color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
} }
:global(.dark) .form-description { :global(.dark) .form-description {
@ -180,14 +180,15 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
font-family: monospace;
} }
:global(.dark) .event-input { :global(.dark) .event-input {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.event-input:disabled { .event-input:disabled {
@ -198,12 +199,13 @@
.find-button { .find-button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b); background: var(--fog-accent, #64748b);
color: white; color: var(--fog-text, #f1f5f9);
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
font-family: monospace;
} }
:global(.dark) .find-button { :global(.dark) .find-button {
@ -220,6 +222,7 @@
} }
.error-message { .error-message {
margin-top: 1rem;
padding: 0.75rem; padding: 0.75rem;
background: var(--fog-danger-light, #fee2e2); background: var(--fog-danger-light, #fee2e2);
color: var(--fog-danger, #dc2626); color: var(--fog-danger, #dc2626);
@ -255,11 +258,11 @@
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
} }
:global(.dark) .event-title { :global(.dark) .event-title {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.view-link { .view-link {
@ -291,24 +294,25 @@
.event-json pre { .event-json pre {
margin: 0; margin: 0;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
:global(.dark) .event-json pre { :global(.dark) .event-json pre {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.edit-button { .edit-button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b); background: var(--fog-accent, #64748b);
color: white; color: var(--fog-text, #f1f5f9);
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
font-family: monospace;
} }
:global(.dark) .edit-button { :global(.dark) .edit-button {

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

@ -193,7 +193,7 @@
<a href="/event/{thread.id}" class="card-link"> <a href="/event/{thread.id}" class="card-link">
<div class="card-content" class:expanded bind:this={contentElement}> <div class="card-content" class:expanded bind:this={contentElement}>
<div class="flex justify-between items-start mb-2"> <div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold"> <h3 class="text-lg font-semibold text-fog-text dark:text-fog-dark-text">
{getTitle()} {getTitle()}
</h3> </h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -211,7 +211,7 @@
{/if} {/if}
</div> </div>
<p class="text-sm mb-2">{getPreview()}</p> <p class="text-sm mb-2 text-fog-text dark:text-fog-dark-text">{getPreview()}</p>
{#if getTopics().length > 0} {#if getTopics().length > 0}
<div class="flex gap-2 topic-tags"> <div class="flex gap-2 topic-tags">

885
src/lib/modules/feed/FeedPage.svelte

File diff suppressed because it is too large Load Diff

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

@ -45,19 +45,12 @@
onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded
onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer
previewMode?: boolean; // If true, show only title and first 150 chars of content
reactions?: NostrEvent[]; // Optional pre-loaded reactions (for performance)
zapCount?: number; // Optional pre-loaded zap count (for performance)
} }
let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent, previewMode = false, reactions, zapCount: providedZapCount }: Props = $props(); let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null); let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false); let loadingParent = $state(false);
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
let zapCount = $state(0);
// Check if this event is bookmarked (async, so we use state) // Check if this event is bookmarked (async, so we use state)
// Only check if user is logged in // Only check if user is logged in
@ -73,8 +66,6 @@
bookmarked = false; bookmarked = false;
} }
}); });
// Vote counting is handled by DiscussionVoteButtons component - no calculation here
// Derive the effective parent event: prefer provided, fall back to loaded // Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent); let parentEvent = $derived(providedParentEvent || loadedParentEvent);
@ -82,14 +73,10 @@
// Sync provided parent event changes and load if needed // Sync provided parent event changes and load if needed
$effect(() => { $effect(() => {
if (providedParentEvent) { if (providedParentEvent) {
// If provided parent event is available, use it
return; return;
} }
// Only load if not provided and this is a reply (fallback for edge cases)
// In most cases, FeedPage will have pre-loaded the parent
if (!loadedParentEvent && isReply()) { if (!loadedParentEvent && isReply()) {
// Delay loading to give FeedPage time to batch load
setTimeout(() => { setTimeout(() => {
if (!providedParentEvent && !loadedParentEvent && isReply()) { if (!providedParentEvent && !loadedParentEvent && isReply()) {
loadParentEvent(); loadParentEvent();
@ -97,15 +84,6 @@
}, 1000); }, 1000);
} }
}); });
// Sync provided zap count - initialize and update when prop changes
$effect(() => {
if (providedZapCount !== undefined) {
zapCount = providedZapCount;
} else {
zapCount = 0;
}
});
// Lazy load PollCard when post is a poll and component is visible // Lazy load PollCard when post is a poll and component is visible
$effect(() => { $effect(() => {
@ -120,101 +98,10 @@
} }
}); });
let isMounted = $state(true);
let zapCountTimeout: ReturnType<typeof setTimeout> | null = null;
onMount(() => { onMount(() => {
isMounted = true; // No zap count loading needed
// Only load zap count if not provided (fallback for edge cases)
// In most cases, FeedPage will have pre-loaded the zap count
// Wait longer to give FeedPage time to batch load all zaps at once
if (providedZapCount === undefined) {
// Delay loading significantly to give FeedPage time to batch load
// FeedPage loads secondary data after posts are displayed
zapCountTimeout = setTimeout(() => {
if (providedZapCount === undefined && isMounted) {
loadZapCount();
}
}, 3000); // Wait 3 seconds for FeedPage to batch load
}
// Votes are now calculated as derived values, no need to load separately
return () => {
isMounted = false;
if (zapCountTimeout) {
clearTimeout(zapCountTimeout);
zapCountTimeout = null;
}
};
}); });
async function loadZapCount() {
try {
const config = nostrClient.getConfig();
const threshold = config.zapThreshold;
const zapRelays = relayManager.getZapReceiptReadRelays();
const filters = [{
kinds: [KIND.ZAP_RECEIPT],
'#e': [post.id]
}];
const receipts = await nostrClient.fetchEvents(
filters,
zapRelays,
{
useCache: true,
cacheResults: true,
onUpdate: (updated: NostrEvent[]) => {
// Recalculate zap count when new receipts arrive
const validReceipts = updated.filter((receipt) => {
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return !isNaN(amount) && amount >= threshold;
}
return false;
});
// Get all receipts for this post (including cached ones)
nostrClient.fetchEvents(
filters,
zapRelays,
{ useCache: true, cacheResults: false }
).then(allReceipts => {
const allValidReceipts = allReceipts.filter((receipt) => {
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return !isNaN(amount) && amount >= threshold;
}
return false;
});
zapCount = allValidReceipts.length;
}).catch(error => {
console.error('Error recalculating zap count:', error);
});
}
}
);
// Filter by threshold and count
const validReceipts = receipts.filter((receipt) => {
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return !isNaN(amount) && amount >= threshold;
}
return false;
});
zapCount = validReceipts.length;
} catch (error) {
console.error('Error loading zap count:', error);
zapCount = 0;
}
}
function getRelativeTime(): string { function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const diff = now - post.created_at; const diff = now - post.created_at;
@ -288,32 +175,6 @@
} }
} }
$effect(() => {
if (contentElement) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
function getTitle(): string { function getTitle(): string {
const titleTag = post.tags.find((t) => t[0] === 'title'); const titleTag = post.tags.find((t) => t[0] === 'title');
@ -389,56 +250,7 @@
class:cursor-pointer={!!onOpenEvent} class:cursor-pointer={!!onOpenEvent}
{...(onOpenEvent ? { role: "button", tabindex: 0 } : {})} {...(onOpenEvent ? { role: "button", tabindex: 0 } : {})}
> >
{#if previewMode} <div class="card-content">
<!-- Preview mode: show only title and first 150 chars -->
<a href="/event/{post.id}" class="card-link">
<div class="card-content">
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">
{getTitle()}
</h3>
<div class="flex items-center gap-2">
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
<div class="interactive-element">
<EventMenu event={post} showContentActions={true} />
</div>
</div>
</div>
<div class="mb-2 flex items-center gap-2 flex-wrap">
<div class="interactive-element">
<ProfileBadge pubkey={post.pubkey} />
</div>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
{#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = getTopics()}
{#if topics.length === 0}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">General</span>
{:else}
{#each topics.slice(0, 3) as topic}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">{topic}</span>
{/each}
{/if}
{/if}
</div>
<p class="text-sm mb-2">{getPreviewContent()}</p>
{#if post.kind === KIND.POLL}
{@const optionTags = post.tags.filter(t => t[0] === 'option')}
<div class="text-xs text-fog-text-light dark:text-fog-dark-text-light mb-2">
Poll: {optionTags.length} {optionTags.length === 1 ? 'option' : 'options'}
</div>
{/if}
<!-- Vote counts are shown in full mode, not in preview mode -->
</div>
</a>
{:else}
<!-- Full mode: show complete content -->
<div class="card-content" class:expanded bind:this={contentElement}>
{#if isReply()} {#if isReply()}
<ReplyContext <ReplyContext
parentEvent={parentEvent || undefined} parentEvent={parentEvent || undefined}
@ -459,13 +271,13 @@
/> />
{/if} {/if}
<!-- Display title prominently for kind 30040 (book index) and 30041 (chapter sections) --> <!-- Display title prominently for kind 30040 (book index), 30041 (chapter sections), and kind 11 (discussion threads) -->
{#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817} {#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817 || post.kind === KIND.DISCUSSION_THREAD}
{@const title = getTitle()} {@const title = getTitle()}
{#if title && title !== 'Untitled'} {#if title && title !== 'Untitled'}
<h1 class="post-title text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text"> <h2 class="post-title text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">
{title} {title}
</h1> </h2>
{/if} {/if}
{/if} {/if}
@ -494,7 +306,7 @@
</div> </div>
<div class="post-content mb-2"> <div class="post-content mb-2">
<MediaAttachments event={post} /> <MediaAttachments event={post} />
{#if post.kind === KIND.POLL} {#if post.kind === KIND.POLL}
{#if PollCardComponent} {#if PollCardComponent}
{@const PollCard = PollCardComponent} {@const PollCard = PollCardComponent}
@ -515,30 +327,10 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if needsExpansion} <!-- Post actions: emoji button and menu -->
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<!-- Post actions (reactions, etc.) - always visible, outside collapsible content -->
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4"> <div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
{#if zapCount > 0} <FeedReactionButtons event={post} />
<span class="zap-count-display">
<span class="zap-emoji"></span>
<span class="zap-count-number">{zapCount}</span>
</span>
{/if}
{#if post.kind === KIND.DISCUSSION_THREAD}
<!-- DiscussionVoteButtons includes both vote counts and buttons -->
<DiscussionVoteButtons event={post} preloadedReactions={reactions} />
{:else}
<FeedReactionButtons event={post} preloadedReactions={reactions} />
{/if}
{#if onReply} {#if onReply}
<button <button
onclick={() => onReply(post)} onclick={() => onReply(post)}
@ -553,7 +345,6 @@
<span class="kind-number">{getKindInfo(post.kind).number}</span> <span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span> <span class="kind-description">{getKindInfo(post.kind).description}</span>
</div> </div>
{/if}
</article> </article>
<style> <style>
@ -574,6 +365,12 @@
line-height: 1.6; line-height: 1.6;
} }
/* Limit all images to 600px wide */
.post-content :global(img) {
max-width: 600px;
height: auto;
}
.post-actions { .post-actions {
padding-top: 0.5rem; padding-top: 0.5rem;
padding-right: 6rem; /* Reserve space for kind badge */ padding-right: 6rem; /* Reserve space for kind badge */
@ -673,32 +470,6 @@
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
} }
.card-link {
display: block;
color: inherit;
text-decoration: none;
cursor: pointer;
}
.card-link:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .card-link:hover {
background: var(--fog-dark-highlight, #475569);
}
.card-link {
pointer-events: auto;
}
.card-link > * {
pointer-events: none;
}
.card-link .interactive-element {
pointer-events: auto;
}
.post-header { .post-header {
display: flex; display: flex;

7
src/lib/services/nostr/nostr-client.ts

@ -1339,7 +1339,7 @@ class NostrClient {
if (updatedEntry && !updatedEntry.pending) { if (updatedEntry && !updatedEntry.pending) {
if ((Date.now() - updatedEntry.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) { if ((Date.now() - updatedEntry.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) {
const finalAge = Math.round((Date.now() - updatedEntry.cachedAt) / 1000); const finalAge = Math.round((Date.now() - updatedEntry.cachedAt) / 1000);
console.log(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${finalAge}s ago (waited for pending)`); console.debug(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${finalAge}s ago (waited for pending)`);
return []; return [];
} }
break; // No longer pending, but result expired or had data break; // No longer pending, but result expired or had data
@ -1351,7 +1351,8 @@ class NostrClient {
// If still pending after waiting, proceed (might be a slow fetch) // If still pending after waiting, proceed (might be a slow fetch)
} else if (!emptyCacheEntry.pending && age < this.EMPTY_RESULT_CACHE_TTL) { } else if (!emptyCacheEntry.pending && age < this.EMPTY_RESULT_CACHE_TTL) {
const ageSeconds = Math.round(age / 1000); const ageSeconds = Math.round(age / 1000);
console.log(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${ageSeconds}s ago`); // Use debug level to reduce console noise - this is expected behavior
console.debug(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${ageSeconds}s ago`);
return Promise.resolve([]); return Promise.resolve([]);
} }
} }
@ -1364,7 +1365,7 @@ class NostrClient {
const recheck = this.emptyResultCache.get(emptyCacheKey); const recheck = this.emptyResultCache.get(emptyCacheKey);
if (recheck && !recheck.pending && (Date.now() - recheck.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) { if (recheck && !recheck.pending && (Date.now() - recheck.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) {
const finalAge = Math.round((Date.now() - recheck.cachedAt) / 1000); const finalAge = Math.round((Date.now() - recheck.cachedAt) / 1000);
console.log(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${finalAge}s ago (waited for concurrent)`); console.debug(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${finalAge}s ago (waited for concurrent)`);
return []; return [];
} }
} }

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

@ -425,7 +425,7 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="cache-page"> <div class="cache-page">
<h1 class="font-mono">/Cache</h1> <h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Cache</h1>
{#if loading && !stats} {#if loading && !stats}
<div class="loading-state"> <div class="loading-state">
@ -661,7 +661,9 @@
<style> <style>
.cache-page { .cache-page {
max-width: 100%; max-width: var(--content-width);
margin: 0 auto;
padding: 0 1rem;
} }
.stats-section, .stats-section,

36
src/routes/discussions/+page.svelte

@ -13,27 +13,39 @@
<Header /> <Header />
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="discussions-header mb-4"> <div class="discussions-content">
<div> <div class="discussions-header mb-4">
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text font-mono">/Discussions</h1> <div>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr.</p> <h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Discussions</h1>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr.</p>
</div>
<a href="/write?kind=11" class="write-button" title="Write a new thread">
<span class="emoji emoji-grayscale"></span>
</a>
</div> </div>
<a href="/write?kind=11" class="write-button" title="Write a new thread">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<div class="search-section mb-6"> <div class="search-section mb-6">
<SearchBox /> <SearchBox />
</div> </div>
<DiscussionList /> <DiscussionList />
</div>
</main> </main>
<style> <style>
.discussions-content {
max-width: var(--content-width);
margin: 0 auto;
}
.discussions-header { .discussions-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
padding: 0 1rem;
}
.search-section {
padding: 0 1rem;
} }
</style> </style>

26
src/routes/feed/+page.svelte

@ -13,25 +13,33 @@
<Header /> <Header />
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="feed-header mb-6"> <div class="feed-content">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text font-mono">/Feed</h1> <div class="feed-header mb-6">
<div class="feed-controls"> <h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text font-mono">/Feed</h1>
<div class="search-section"> <div class="feed-controls">
<SearchBox /> <div class="search-section">
<SearchBox />
</div>
<a href="/write?kind=1" class="write-button" title="Write a new post">
<span class="emoji emoji-grayscale"></span>
</a>
</div> </div>
<a href="/write?kind=1" class="write-button" title="Write a new post">
<span class="emoji emoji-grayscale"></span>
</a>
</div> </div>
<FeedPage />
</div> </div>
<FeedPage />
</main> </main>
<style> <style>
.feed-content {
max-width: var(--content-width);
margin: 0 auto;
}
.feed-header { .feed-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 0 1rem;
} }
.feed-controls { .feed-controls {

40
src/routes/feed/relay/[relay]/+page.svelte

@ -65,25 +65,35 @@
<Header /> <Header />
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="search-section mb-6"> <div class="relay-feed-content">
<SearchBox /> <div class="search-section mb-6">
</div> <SearchBox />
{#if error}
<div class="error-state">
<p class="text-fog-text dark:text-fog-dark-text">{error}</p>
</div>
{:else if decodedRelay}
<RelayInfo relayUrl={decodedRelay} />
<FeedPage singleRelay={decodedRelay} />
{:else}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading relay feed...</p>
</div> </div>
{/if}
{#if error}
<div class="error-state">
<p class="text-fog-text dark:text-fog-dark-text">{error}</p>
</div>
{:else if decodedRelay}
<RelayInfo relayUrl={decodedRelay} />
<FeedPage singleRelay={decodedRelay} />
{:else}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading relay feed...</p>
</div>
{/if}
</div>
</main> </main>
<style> <style>
.relay-feed-content {
max-width: var(--content-width);
margin: 0 auto;
}
.search-section {
padding: 0 1rem;
}
.error-state, .error-state,
.loading-state { .loading-state {

18
src/routes/find/+page.svelte

@ -151,8 +151,9 @@
<style> <style>
.find-page { .find-page {
max-width: 800px; max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
padding: 0 1rem;
} }
.find-sections { .find-sections {
@ -173,6 +174,17 @@
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
} }
.find-section h2 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .find-section h2 {
color: var(--fog-dark-text, #cbd5e1);
}
.section-description { .section-description {
margin: 0 0 1.5rem 0; margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280); color: var(--fog-text-light, #6b7280);
@ -194,7 +206,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-size: 0.875rem; font-size: 0.875rem;
font-family: monospace; font-family: monospace;
} }
@ -202,7 +214,7 @@
:global(.dark) .user-input { :global(.dark) .user-input {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.user-input:disabled { .user-input:disabled {

300
src/routes/relay/+page.svelte

@ -6,6 +6,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { sessionManager } from '../../lib/services/auth/session-manager.js'; import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { KIND } from '../../lib/types/kind-lookup.js';
interface RelayInfo { interface RelayInfo {
url: string; url: string;
@ -14,7 +15,9 @@
} }
let relays = $state<RelayInfo[]>([]); let relays = $state<RelayInfo[]>([]);
let favoriteRelays = $state<RelayInfo[]>([]);
let loading = $state(true); let loading = $state(true);
let customRelayUrl = $state('');
function categorizeRelay(url: string): string[] { function categorizeRelay(url: string): string[] {
const categories: string[] = []; const categories: string[] = [];
@ -78,9 +81,80 @@
}); });
relays = relayList; relays = relayList;
// Load favorite relays if user is logged in
await loadFavoriteRelays();
loading = false; loading = false;
} }
async function loadFavoriteRelays() {
const currentPubkey = sessionManager.getCurrentPubkey();
if (!currentPubkey) {
favoriteRelays = [];
return;
}
try {
// Fetch kind 10012 (FAVORITE_RELAYS) event for current user
// Fetch multiple in case there are multiple replaceable events
const favoriteEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.FAVORITE_RELAYS], authors: [currentPubkey], limit: 10 }],
relayManager.getProfileReadRelays(),
{ useCache: true, cacheResults: true, timeout: 5000 }
);
if (favoriteEvents.length === 0) {
favoriteRelays = [];
return;
}
// Get the latest event (replaceable event, so should be only one, but take latest just in case)
const latestEvent = favoriteEvents.sort((a, b) => b.created_at - a.created_at)[0];
// Extract relay URLs from 'relay' tags (kind 10012 uses 'relay' tags)
const favoriteRelayUrls = new Set<string>();
for (const tag of latestEvent.tags) {
if (tag[0] === 'relay' && tag[1]) {
// Normalize URL: trim, remove trailing slash, ensure protocol
let url = tag[1].trim();
// Remove trailing slash
url = url.replace(/\/$/, '');
// If no protocol, assume wss://
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
url = `wss://${url}`;
}
favoriteRelayUrls.add(url);
}
}
// Get connection status - normalize URLs for comparison
const connectedRelays = nostrClient.getConnectedRelays();
const normalizeUrlForComparison = (url: string): string => {
return url.replace(/\/$/, '').toLowerCase();
};
const favoriteRelayList: RelayInfo[] = Array.from(favoriteRelayUrls).map(url => {
const normalizedUrl = normalizeUrlForComparison(url);
const isConnected = connectedRelays.some(connected =>
normalizeUrlForComparison(connected) === normalizedUrl
);
return {
url,
categories: ['Favorite'],
connected: isConnected
};
});
// Sort by URL
favoriteRelayList.sort((a, b) => a.url.localeCompare(b.url));
favoriteRelays = favoriteRelayList;
} catch (err) {
console.error('Error loading favorite relays:', err);
favoriteRelays = [];
}
}
function handleRelayClick(url: string) { function handleRelayClick(url: string) {
// Extract hostname from relay URL (e.g., wss://thecitadel.nostr1.com -> thecitadel.nostr1.com) // Extract hostname from relay URL (e.g., wss://thecitadel.nostr1.com -> thecitadel.nostr1.com)
// The route expects just the domain without protocol or port // The route expects just the domain without protocol or port
@ -99,8 +173,37 @@
goto(`/feed/relay/${relayPath}`); goto(`/feed/relay/${relayPath}`);
} }
function handleCustomRelaySubmit() {
const url = customRelayUrl.trim();
if (!url) return;
// Validate URL format
try {
// Try to parse as URL, add protocol if missing
let fullUrl = url;
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
fullUrl = `wss://${url}`;
}
new URL(fullUrl); // Validate URL format
// Navigate to the relay
handleRelayClick(fullUrl);
customRelayUrl = ''; // Clear input after navigation
} catch {
alert('Please enter a valid relay URL (e.g., relay.example.com or wss://relay.example.com)');
}
}
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
// Ensure session is restored before loading favorite relays
if (!sessionManager.isLoggedIn()) {
try {
await sessionManager.restoreSession();
} catch (error) {
console.error('Failed to restore session in relay page:', error);
}
}
await loadRelays(); await loadRelays();
}); });
</script> </script>
@ -111,10 +214,69 @@
<div class="relay-page"> <div class="relay-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Relay</h1> <h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Relay</h1>
<!-- Custom Relay Input -->
<section class="relay-category custom-relay-section">
<h2 class="category-title">Custom Relay</h2>
<div class="custom-relay-input">
<input
type="text"
placeholder="Enter relay URL (e.g., relay.example.com or wss://relay.example.com)"
bind:value={customRelayUrl}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCustomRelaySubmit();
}
}}
class="custom-relay-text-input"
/>
<button
onclick={handleCustomRelaySubmit}
class="custom-relay-button"
disabled={!customRelayUrl.trim()}
>
Go
</button>
</div>
</section>
{#if loading} {#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading relays...</p> <p class="text-fog-text dark:text-fog-dark-text">Loading relays...</p>
{:else} {:else}
<div class="relay-categories"> <div class="relay-categories">
<!-- Favorite Relays Section -->
{#if favoriteRelays.length > 0}
<section class="relay-category">
<h2 class="category-title">Favorite</h2>
<div class="relay-list">
{#each favoriteRelays as relay}
<div
class="relay-item"
onclick={() => handleRelayClick(relay.url)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRelayClick(relay.url);
}
}}
>
<div class="relay-info">
<span class="relay-url">{relay.url}</span>
<div class="relay-meta">
<span class="relay-status" class:connected={relay.connected} class:disconnected={!relay.connected}>
{relay.connected ? '● Connected' : '○ Disconnected'}
</span>
</div>
</div>
<span class="relay-arrow"></span>
</div>
{/each}
</div>
</section>
{/if}
{#each ['Default', 'Profile', 'Thread Publish', 'GIF', 'Other'] as category} {#each ['Default', 'Profile', 'Thread Publish', 'GIF', 'Other'] as category}
{@const categoryRelays = relays.filter(r => r.categories.includes(category))} {@const categoryRelays = relays.filter(r => r.categories.includes(category))}
{#if categoryRelays.length > 0} {#if categoryRelays.length > 0}
@ -137,14 +299,17 @@
<div class="relay-info"> <div class="relay-info">
<span class="relay-url">{relay.url}</span> <span class="relay-url">{relay.url}</span>
<div class="relay-meta"> <div class="relay-meta">
{#if relay.categories.length > 1}
<span class="relay-categories-badge">
{relay.categories.length} categories
</span>
{/if}
<span class="relay-status" class:connected={relay.connected} class:disconnected={!relay.connected}> <span class="relay-status" class:connected={relay.connected} class:disconnected={!relay.connected}>
{relay.connected ? '● Connected' : '○ Disconnected'} {relay.connected ? '● Connected' : '○ Disconnected'}
</span> </span>
{#if relay.categories.length > 1}
{@const otherCategories = relay.categories.filter(c => c !== category)}
{#if otherCategories.length > 0}
{#each otherCategories as cat}
<span class="relay-category-badge">{cat}</span>
{/each}
{/if}
{/if}
</div> </div>
</div> </div>
<span class="relay-arrow"></span> <span class="relay-arrow"></span>
@ -162,8 +327,9 @@
<style> <style>
.relay-page { .relay-page {
max-width: 1000px; max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
padding: 0 1rem;
} }
.relay-categories { .relay-categories {
@ -188,14 +354,14 @@
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 600; font-weight: 600;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
font-family: monospace; font-family: monospace;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
:global(.dark) .category-title { :global(.dark) .category-title {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.relay-list { .relay-list {
@ -206,8 +372,8 @@
.relay-item { .relay-item {
display: flex; display: flex;
justify-content: space-between; align-items: flex-start;
align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
@ -217,6 +383,10 @@
font-family: monospace; font-family: monospace;
} }
.relay-item > .relay-info {
flex: 1;
}
:global(.dark) .relay-item { :global(.dark) .relay-item {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
@ -243,6 +413,7 @@
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
flex: 1; flex: 1;
min-width: 0;
} }
.relay-meta { .relay-meta {
@ -252,61 +423,126 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.relay-categories-badge { .relay-status {
font-size: 0.75rem;
opacity: 0.7;
flex-shrink: 0;
}
.relay-status.connected {
color: #10b981;
}
.relay-status.disconnected {
color: #ef4444;
}
.relay-item:hover .relay-status {
color: white;
opacity: 1;
}
.relay-category-badge {
font-size: 0.7rem; font-size: 0.7rem;
opacity: 0.6; 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, #6b7280);
font-family: monospace; font-family: monospace;
} }
:global(.dark) .relay-categories-badge { :global(.dark) .relay-category-badge {
background: var(--fog-dark-border, #475569);
color: var(--fog-dark-text-light, #9ca3af); color: var(--fog-dark-text-light, #9ca3af);
} }
.relay-item:hover .relay-categories-badge { .relay-item:hover .relay-category-badge {
background: rgba(255, 255, 255, 0.2);
color: white; color: white;
opacity: 0.8;
} }
.relay-url { .relay-url {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--fog-text, #1f2937); color: var(--fog-text, #475569);
word-break: break-all; word-break: break-all;
} }
:global(.dark) .relay-url { :global(.dark) .relay-url {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #cbd5e1);
} }
.relay-item:hover .relay-url { .relay-item:hover .relay-url {
color: white; color: white;
} }
.relay-status { .relay-arrow {
font-size: 0.75rem; font-size: 1.25rem;
opacity: 0.7; opacity: 0.5;
flex-shrink: 0;
margin-top: 0.125rem;
} }
.relay-status.connected { .relay-item:hover .relay-arrow {
color: #10b981; opacity: 1;
} }
.relay-status.disconnected { .custom-relay-section {
color: #ef4444; margin-bottom: 2rem;
} }
.relay-item:hover .relay-status { .custom-relay-input {
color: white; display: flex;
opacity: 1; gap: 0.5rem;
align-items: center;
} }
.relay-arrow { .custom-relay-text-input {
font-size: 1.25rem; flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #475569);
font-family: monospace;
font-size: 0.875rem;
}
:global(.dark) .custom-relay-text-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #cbd5e1);
}
.custom-relay-text-input:focus {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
}
.custom-relay-button {
padding: 0.75rem 1.5rem;
border: 1px solid var(--fog-accent, #64748b);
border-radius: 0.25rem;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
font-family: monospace;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.custom-relay-button:hover:not(:disabled) {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.custom-relay-button:disabled {
opacity: 0.5; opacity: 0.5;
margin-left: 1rem; cursor: not-allowed;
} }
.relay-item:hover .relay-arrow { .custom-relay-button:focus {
opacity: 1; outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
} }
</style> </style>

23
src/routes/replaceable/[d_tag]/+page.svelte

@ -94,14 +94,15 @@
<Header /> <Header />
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="replaceable-header mb-6"> <div class="replaceable-content">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text"> <div class="replaceable-header mb-6">
Replaceable Events: {dTag} <h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text">
</h1> Replaceable Events: {dTag}
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2"> </h1>
{events.length} {events.length === 1 ? 'event' : 'events'} found <p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">
</p> {events.length} {events.length === 1 ? 'event' : 'events'} found
</div> </p>
</div>
{#if loading} {#if loading}
<div class="loading-state"> <div class="loading-state">
@ -131,6 +132,7 @@
{/each} {/each}
</div> </div>
{/if} {/if}
</div>
</main> </main>
{#if drawerOpen && drawerEvent} {#if drawerOpen && drawerEvent}
@ -138,8 +140,13 @@
{/if} {/if}
<style> <style>
.replaceable-content {
max-width: var(--content-width);
margin: 0 auto;
}
.replaceable-header { .replaceable-header {
padding: 0 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
padding-bottom: 1rem; padding-bottom: 1rem;
} }

35
src/routes/repos/+page.svelte

@ -142,21 +142,22 @@
<Header /> <Header />
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="repos-header mb-6"> <div class="repos-content">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text font-mono">/Repos</h1> <div class="repos-header mb-6">
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2 mb-4"> <h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text font-mono">/Repos</h1>
Discover and explore repositories announced on Nostr <p class="text-fog-text-light dark:text-fog-dark-text-light mt-2 mb-4">
</p> Discover and explore repositories announced on Nostr
</p>
<div class="search-container mb-4">
<input <div class="search-container mb-4">
type="text" <input
bind:value={searchQuery} type="text"
placeholder="Search repositories..." bind:value={searchQuery}
class="search-input" placeholder="Search repositories..."
/> class="search-input"
/>
</div>
</div> </div>
</div>
{#if loading} {#if loading}
<div class="loading-state"> <div class="loading-state">
@ -199,11 +200,17 @@
{/each} {/each}
</div> </div>
{/if} {/if}
</div>
</main> </main>
<style> <style>
.repos-content {
max-width: var(--content-width);
margin: 0 auto;
}
.repos-header { .repos-header {
padding: 0 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
padding-bottom: 1rem; padding-bottom: 1rem;
} }

6
src/routes/rss/+page.svelte

@ -63,7 +63,7 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="rss-page"> <div class="rss-page">
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">RSS Feed</h1> <h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/RSS</h1>
{#if loading} {#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading...</p> <p class="text-fog-text dark:text-fog-dark-text">Loading...</p>
@ -123,7 +123,9 @@
<style> <style>
.rss-page { .rss-page {
max-width: 600px; max-width: var(--content-width);
margin: 0 auto;
padding: 0 1rem;
} }
.rss-setup { .rss-setup {

3
src/routes/topics/+page.svelte

@ -145,8 +145,9 @@
<style> <style>
.topics-page { .topics-page {
max-width: 1000px; max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
padding: 0 1rem;
} }
.topics-list { .topics-list {

23
src/routes/topics/[name]/+page.svelte

@ -78,14 +78,15 @@
<Header /> <Header />
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="topic-header mb-6"> <div class="topic-content">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text"> <div class="topic-header mb-6">
Topic: #{topicName} <h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text">
</h1> Topic: #{topicName}
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2"> </h1>
{events.length} {events.length === 1 ? 'event' : 'events'} found <p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">
</p> {events.length} {events.length === 1 ? 'event' : 'events'} found
</div> </p>
</div>
{#if loading} {#if loading}
<div class="loading-state"> <div class="loading-state">
@ -104,11 +105,17 @@
{/each} {/each}
</div> </div>
{/if} {/if}
</div>
</main> </main>
<style> <style>
.topic-content {
max-width: var(--content-width);
margin: 0 auto;
}
.topic-header { .topic-header {
padding: 0 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
padding-bottom: 1rem; padding-bottom: 1rem;
} }

3
src/routes/write/+page.svelte

@ -87,8 +87,9 @@
<style> <style>
.write-page { .write-page {
max-width: 800px; max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
padding: 0 1rem;
} }
.form-container { .form-container {

Loading…
Cancel
Save