Browse Source

render all raw json with highlight.js

change the syntax highlighting for terminal theme
make terminal theme more persistent
master
Silberengel 1 month ago
parent
commit
a84a77ea76
  1. 792
      src/app.css
  2. 265
      src/lib/components/content/FileExplorer.svelte
  3. 4
      src/lib/components/layout/UnifiedSearch.svelte
  4. 65
      src/lib/components/write/CreateEventForm.svelte
  5. 66
      src/lib/components/write/FindEventForm.svelte
  6. 96
      src/lib/modules/discussions/DiscussionVoteButtons.svelte
  7. 21
      src/lib/modules/feed/HighlightCard.svelte
  8. 24
      src/lib/modules/reactions/FeedReactionButtons.svelte
  9. 119
      src/lib/services/content/git-repo-fetcher.ts
  10. 53
      src/lib/services/nostr/relay-manager.ts
  11. 35
      src/routes/feed/relay/[relay]/+page.svelte
  12. 53
      src/routes/find/+page.svelte
  13. 21
      src/routes/highlights/+page.svelte
  14. 80
      src/routes/relay/+page.svelte
  15. 396
      src/routes/repos/+page.svelte
  16. 4
      src/routes/repos/[naddr]/+page.svelte
  17. 13
      src/routes/rss/+page.svelte

792
src/app.css

@ -1143,6 +1143,632 @@ body::before { @@ -1143,6 +1143,632 @@ body::before {
text-transform: uppercase !important;
}
/* Terminal Theme - Toolbar and Editor */
[data-design-theme="terminal"] .editor-toolbar,
[data-design-theme="terminal"] .toolbar-group {
background: #000000 !important;
border-color: #00ff00 !important;
}
/* Terminal Theme - Textarea Buttons Toolbar (GIF/Emoji) - Transparent */
[data-design-theme="terminal"] .textarea-buttons {
background: transparent !important;
border: none !important;
}
/* Terminal Theme - Advanced Search Container */
[data-design-theme="terminal"] .advanced-filters-grid {
background: #000000 !important;
border-color: #00ff00 !important;
}
/* Terminal Theme - Event Relay Badge (in search results) */
[data-design-theme="terminal"] .event-relay-badge {
background: #000000 !important;
border-color: #00ff00 !important;
}
/* Terminal Theme - Reply Context and Quoted Context */
[data-design-theme="terminal"] .reply-context,
[data-design-theme="terminal"] .quoted-context {
background: #000000 !important;
background-color: #000000 !important;
border-left-color: #00ff00 !important;
color: #00ff00 !important;
border: 1px solid #00ff00 !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.3) !important;
}
/* Override Tailwind background classes - need to target both light and dark variants */
[data-design-theme="terminal"] .reply-context.bg-fog-highlight,
[data-design-theme="terminal"] .dark .reply-context.bg-fog-dark-highlight,
[data-design-theme="terminal"] .reply-context.dark\:bg-fog-dark-highlight,
[data-design-theme="terminal"] .quoted-context.bg-fog-highlight,
[data-design-theme="terminal"] .dark .quoted-context.bg-fog-dark-highlight,
[data-design-theme="terminal"] .quoted-context.dark\:bg-fog-dark-highlight {
background: #000000 !important;
background-color: #000000 !important;
}
[data-design-theme="terminal"] .reply-context-content,
[data-design-theme="terminal"] .quoted-context-content,
[data-design-theme="terminal"] .reply-preview,
[data-design-theme="terminal"] .quoted-preview,
[data-design-theme="terminal"] .reply-kind-info,
[data-design-theme="terminal"] .quoted-kind-info {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
/* Override Tailwind text color classes - need to target both light and dark variants */
[data-design-theme="terminal"] .reply-context.text-fog-text-light,
[data-design-theme="terminal"] .dark .reply-context.text-fog-dark-text-light,
[data-design-theme="terminal"] .reply-context.dark\:text-fog-dark-text-light,
[data-design-theme="terminal"] .quoted-context.text-fog-text-light,
[data-design-theme="terminal"] .dark .quoted-context.text-fog-dark-text-light,
[data-design-theme="terminal"] .quoted-context.dark\:text-fog-dark-text-light {
color: #00ff00 !important;
}
/* Terminal Theme - Referenced Event Preview */
[data-design-theme="terminal"] .referenced-event-preview {
background: #000000 !important;
background-color: #000000 !important;
border-color: #00ff00 !important;
border-left-color: #00ff00 !important;
color: #00ff00 !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.3) !important;
}
[data-design-theme="terminal"] .referenced-event-label,
[data-design-theme="terminal"] .referenced-event-title,
[data-design-theme="terminal"] .referenced-event-preview-text,
[data-design-theme="terminal"] .loading-text,
[data-design-theme="terminal"] .error-text {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .view-event-button,
[data-design-theme="terminal"] .view-website-button {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .view-event-button:hover,
[data-design-theme="terminal"] .view-website-button:hover {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
}
[data-design-theme="terminal"] .website-link {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
/* Terminal Theme - JSON Preview - Sharp Text Rendering */
[data-design-theme="terminal"] .json-preview code,
[data-design-theme="terminal"] .json-preview code.hljs,
[data-design-theme="terminal"] .json-content code,
[data-design-theme="terminal"] .json-content code.hljs,
[data-design-theme="terminal"] .example-modal .json-preview code,
[data-design-theme="terminal"] .example-modal .json-preview code.hljs,
[data-design-theme="terminal"] .event-json .json-preview code,
[data-design-theme="terminal"] .event-json .json-preview code.hljs {
text-shadow: none !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
font-smoothing: antialiased !important;
text-rendering: optimizeLegibility !important;
}
/* Remove text-shadow from highlight.js spans inside JSON */
[data-design-theme="terminal"] .json-preview code.hljs span,
[data-design-theme="terminal"] .json-content code.hljs span,
[data-design-theme="terminal"] .example-modal .json-preview code.hljs span,
[data-design-theme="terminal"] .event-json .json-preview code.hljs span,
[data-design-theme="terminal"] .json-preview code.hljs *,
[data-design-theme="terminal"] .json-content code.hljs *,
[data-design-theme="terminal"] .example-modal .json-preview code.hljs *,
[data-design-theme="terminal"] .event-json .json-preview code.hljs * {
text-shadow: none !important;
}
/* Terminal Theme - RSS Page */
[data-design-theme="terminal"] .rss-setup,
[data-design-theme="terminal"] .rss-info,
[data-design-theme="terminal"] .rss-items-section,
[data-design-theme="terminal"] .rss-comment-form {
background: #000000 !important;
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .rss-feed-item,
[data-design-theme="terminal"] .rss-item {
background: #000000 !important;
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .rss-feed-link,
[data-design-theme="terminal"] .rss-item-link {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .rss-feed-link:hover,
[data-design-theme="terminal"] .rss-item-link:hover {
color: #00ff00 !important;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.8) !important;
}
[data-design-theme="terminal"] .rss-feed-badge {
background: #000000 !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .rss-item-time,
[data-design-theme="terminal"] .rss-item-description,
[data-design-theme="terminal"] .feed-error {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .create-rss-button,
[data-design-theme="terminal"] .edit-rss-button,
[data-design-theme="terminal"] .pagination-button {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .create-rss-button:hover:not(:disabled),
[data-design-theme="terminal"] .edit-rss-button:hover:not(:disabled),
[data-design-theme="terminal"] .pagination-button:hover:not(:disabled) {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
}
[data-design-theme="terminal"] .pagination-button:disabled {
opacity: 0.5 !important;
border-color: rgba(0, 255, 0, 0.3) !important;
}
[data-design-theme="terminal"] .btn-action,
[data-design-theme="terminal"] .btn-primary,
[data-design-theme="terminal"] .btn-secondary {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .btn-action:hover:not(:disabled),
[data-design-theme="terminal"] .btn-primary:hover:not(:disabled),
[data-design-theme="terminal"] .btn-secondary:hover:not(:disabled) {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
}
[data-design-theme="terminal"] .btn-action:disabled,
[data-design-theme="terminal"] .btn-primary:disabled,
[data-design-theme="terminal"] .btn-secondary:disabled {
opacity: 0.5 !important;
border-color: rgba(0, 255, 0, 0.3) !important;
}
/* Terminal Theme - Relay Page */
[data-design-theme="terminal"] .relay-category,
[data-design-theme="terminal"] .custom-relay-section {
background: #000000 !important;
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .category-title {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .relay-item {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .relay-item:hover {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .relay-url {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .relay-item:hover .relay-url {
color: #00ff00 !important;
}
[data-design-theme="terminal"] .relay-status.connected {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .relay-status.disconnected {
color: #ff0000 !important;
text-shadow: 0 0 3px rgba(255, 0, 0, 0.6) !important;
}
[data-design-theme="terminal"] .relay-item:hover .relay-status {
color: #00ff00 !important;
}
[data-design-theme="terminal"] .relay-category-badge {
background: #000000 !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .relay-item:hover .relay-category-badge {
background: rgba(0, 255, 0, 0.1) !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .relay-arrow {
color: #00ff00 !important;
}
[data-design-theme="terminal"] .custom-relay-text-input {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .custom-relay-text-input:focus {
box-shadow: 0 0 10px rgba(0, 255, 0, 0.6) !important;
outline: none !important;
}
[data-design-theme="terminal"] .custom-relay-button {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .custom-relay-button:hover:not(:disabled) {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
}
[data-design-theme="terminal"] .custom-relay-button:disabled {
opacity: 0.5 !important;
border-color: rgba(0, 255, 0, 0.3) !important;
}
/* Terminal Theme - Relay Feed Info Bar */
[data-design-theme="terminal"] .relay-info {
background: #000000 !important;
border: 1px solid #00ff00 !important;
border-radius: 0.25rem !important;
padding: 0.75rem 1rem !important;
margin-bottom: 1rem !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.3) !important;
}
[data-design-theme="terminal"] .relay-info-text {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
margin: 0 !important;
}
[data-design-theme="terminal"] .relay-info-text code.relay-url {
color: #00ff00 !important;
background: transparent !important;
border: none !important;
padding: 0 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .relay-info-text .relay-port {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
/* Terminal Theme - Topics Page */
[data-design-theme="terminal"] .topic-item {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .topic-item:hover {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .topic-item.interest {
border-left-color: #00ff00 !important;
}
[data-design-theme="terminal"] .topic-name,
[data-design-theme="terminal"] .topic-count {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .topic-item:hover .topic-name,
[data-design-theme="terminal"] .topic-item:hover .topic-count {
color: #00ff00 !important;
}
[data-design-theme="terminal"] .pagination-button {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .pagination-button:hover:not(:disabled) {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
}
[data-design-theme="terminal"] .pagination-button:disabled {
opacity: 0.5 !important;
border-color: rgba(0, 255, 0, 0.3) !important;
}
[data-design-theme="terminal"] .pagination-info {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
/* Terminal Theme - Repos Page */
[data-design-theme="terminal"] .repo-item,
[data-design-theme="terminal"] .search-results-section,
[data-design-theme="terminal"] .profile-result-card,
[data-design-theme="terminal"] .event-result-card {
background: #000000 !important;
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .repo-item:hover,
[data-design-theme="terminal"] .profile-result-card:hover,
[data-design-theme="terminal"] .event-result-card:hover {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .repo-name,
[data-design-theme="terminal"] .repo-description,
[data-design-theme="terminal"] .repo-meta,
[data-design-theme="terminal"] .repo-kind,
[data-design-theme="terminal"] .results-title,
[data-design-theme="terminal"] .results-group h3 {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .repo-kind {
background: #000000 !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
}
/* Terminal Theme - Repo Detail Page */
[data-design-theme="terminal"] .repo-meta-section,
[data-design-theme="terminal"] .readme-container,
[data-design-theme="terminal"] .commit-card,
[data-design-theme="terminal"] .branch-item,
[data-design-theme="terminal"] .issue-item,
[data-design-theme="terminal"] .documentation-item {
background: #000000 !important;
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .metadata-label,
[data-design-theme="terminal"] .metadata-value,
[data-design-theme="terminal"] .clone-url,
[data-design-theme="terminal"] .event-id,
[data-design-theme="terminal"] .naddr-code {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .relay-link {
background: #000000 !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .relay-link:hover {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .metadata-link {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .primary-badge {
background: #000000 !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .maintainer-item.is-owner {
background: #000000 !important;
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .branch-item.default {
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .branch-name,
[data-design-theme="terminal"] .branch-commit,
[data-design-theme="terminal"] .branch-message,
[data-design-theme="terminal"] .commit-sha,
[data-design-theme="terminal"] .commit-message,
[data-design-theme="terminal"] .commit-meta {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .branch-badge {
background: #000000 !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .issues-filter {
background: #000000 !important;
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .filter-label,
[data-design-theme="terminal"] .filter-count {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .status-filter-select {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .status-filter-select:hover,
[data-design-theme="terminal"] .status-filter-select:focus {
box-shadow: 0 0 10px rgba(0, 255, 0, 0.6) !important;
outline: none !important;
}
[data-design-theme="terminal"] .issue-header {
background: #000000 !important;
border-color: #00ff00 !important;
}
[data-design-theme="terminal"] .status-label,
[data-design-theme="terminal"] .status-changing,
[data-design-theme="terminal"] .comments-header {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .status-select {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .status-select:hover:not(:disabled) {
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
}
[data-design-theme="terminal"] .status-select.open,
[data-design-theme="terminal"] .status-select.closed,
[data-design-theme="terminal"] .status-select.resolved,
[data-design-theme="terminal"] .status-select.draft {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .issue-comments {
border-top-color: #00ff00 !important;
}
[data-design-theme="terminal"] .comment-item {
border-left-color: #00ff00 !important;
}
[data-design-theme="terminal"] .doc-header {
border-bottom-color: #00ff00 !important;
}
[data-design-theme="terminal"] .doc-kind {
background: #000000 !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .doc-event-link {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .toolbar-button {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .toolbar-button:hover:not(:disabled) {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
}
[data-design-theme="terminal"] .toolbar-separator {
background: #00ff00 !important;
}
/* Terminal Theme - Modals */
[data-design-theme="terminal"] .modal-overlay {
background: rgba(0, 0, 0, 0.8) !important;
}
[data-design-theme="terminal"] .modal-content,
[data-design-theme="terminal"] .example-modal {
background: #000000 !important;
border-color: #00ff00 !important;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.5) !important;
}
[data-design-theme="terminal"] .modal-header,
[data-design-theme="terminal"] .modal-body,
[data-design-theme="terminal"] .modal-footer {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .modal-header h2,
[data-design-theme="terminal"] .modal-header h3 {
color: #00ff00 !important;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.8) !important;
}
[data-design-theme="terminal"] .close-button {
background: transparent !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .close-button:hover {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
}
/* Terminal Theme - General Buttons */
[data-design-theme="terminal"] button:not(.toolbar-button):not(.close-button):not(.view-button):not(.toggle-button):not(.option-button):not(.action-button):not(.back-button):not(.write-button):not(.see-new-events-btn-header):not(.see-more-events-btn-header):not(.find-button):not(.create-rss-button):not(.edit-rss-button):not(.bulk-action-button):not(.load-more-button):not(.clear-kind-button) {
background: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] button:not(.toolbar-button):not(.close-button):not(.view-button):not(.toggle-button):not(.option-button):not(.action-button):not(.back-button):not(.write-button):not(.see-new-events-btn-header):not(.see-more-events-btn-header):not(.find-button):not(.create-rss-button):not(.edit-rss-button):not(.bulk-action-button):not(.load-more-button):not(.clear-kind-button):hover:not(:disabled) {
background: rgba(0, 255, 0, 0.1) !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5) !important;
}
[data-design-theme="terminal"] body::before {
background:
repeating-linear-gradient(
@ -1945,3 +2571,169 @@ audio { @@ -1945,3 +2571,169 @@ audio {
background-color: #1a8cd8 !important; /* Darker blue on hover */
opacity: 1 !important;
}
/* Terminal Theme - Cache Page */
[data-design-theme="terminal"] .cache-page,
[data-design-theme="terminal"] .stats-section,
[data-design-theme="terminal"] .filters-section,
[data-design-theme="terminal"] .archive-section,
[data-design-theme="terminal"] .bulk-actions-section,
[data-design-theme="terminal"] .events-section {
background-color: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .back-button,
[data-design-theme="terminal"] .bulk-action-button,
[data-design-theme="terminal"] .action-button,
[data-design-theme="terminal"] .load-more-button,
[data-design-theme="terminal"] .clear-kind-button {
background-color: rgba(0, 255, 0, 0.1) !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.3) !important;
}
[data-design-theme="terminal"] .back-button:hover:not(:disabled),
[data-design-theme="terminal"] .bulk-action-button:hover:not(:disabled),
[data-design-theme="terminal"] .action-button:hover:not(:disabled),
[data-design-theme="terminal"] .load-more-button:hover:not(:disabled),
[data-design-theme="terminal"] .clear-kind-button:hover:not(:disabled) {
background-color: rgba(0, 255, 0, 0.2) !important;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .stat-card,
[data-design-theme="terminal"] .kind-item,
[data-design-theme="terminal"] .event-card {
background-color: rgba(0, 255, 0, 0.05) !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .kind-item:hover {
background-color: rgba(0, 255, 0, 0.15) !important;
box-shadow: 0 0 8px rgba(0, 255, 0, 0.4) !important;
}
[data-design-theme="terminal"] .kind-item:hover .kind-name,
[data-design-theme="terminal"] .kind-item:hover .kind-count {
color: #00ff00 !important;
}
[data-design-theme="terminal"] .stat-label,
[data-design-theme="terminal"] .stat-value,
[data-design-theme="terminal"] .kind-name,
[data-design-theme="terminal"] .kind-count,
[data-design-theme="terminal"] .event-id,
[data-design-theme="terminal"] .event-meta,
[data-design-theme="terminal"] .event-content-preview {
color: #00ff00 !important;
}
[data-design-theme="terminal"] .filter-label,
[data-design-theme="terminal"] .filter-input,
[data-design-theme="terminal"] .filter-select {
background-color: rgba(0, 255, 0, 0.05) !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .filter-input:focus,
[data-design-theme="terminal"] .filter-select:focus {
outline: 2px solid #00ff00 !important;
box-shadow: 0 0 8px rgba(0, 255, 0, 0.4) !important;
}
[data-design-theme="terminal"] .event-id-code,
[data-design-theme="terminal"] .event-json {
background-color: rgba(0, 255, 0, 0.05) !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .event-json code,
[data-design-theme="terminal"] .event-meta code {
color: #00ff00 !important;
}
[data-design-theme="terminal"] .action-button.delete-action {
border-color: #ff0000 !important;
color: #ff0000 !important;
}
[data-design-theme="terminal"] .action-button.delete-action:hover {
background-color: rgba(255, 0, 0, 0.2) !important;
box-shadow: 0 0 10px rgba(255, 0, 0, 0.6) !important;
}
[data-design-theme="terminal"] .action-button.delete-request-action {
border-color: #ffaa00 !important;
color: #ffaa00 !important;
}
[data-design-theme="terminal"] .action-button.delete-request-action:hover {
background-color: rgba(255, 170, 0, 0.2) !important;
box-shadow: 0 0 10px rgba(255, 170, 0, 0.6) !important;
}
[data-design-theme="terminal"] .archive-stats p,
[data-design-theme="terminal"] .archive-note,
[data-design-theme="terminal"] .recover-event-section h3 {
color: #00ff00 !important;
}
[data-design-theme="terminal"] .loading-state,
[data-design-theme="terminal"] .empty-state {
color: #00ff00 !important;
}
/* Terminal Theme - About Page */
[data-design-theme="terminal"] .about-page,
[data-design-theme="terminal"] .about-section {
background-color: #000000 !important;
border-color: #00ff00 !important;
color: #00ff00 !important;
}
[data-design-theme="terminal"] .back-button {
background-color: rgba(0, 255, 0, 0.1) !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.3) !important;
}
[data-design-theme="terminal"] .back-button:hover {
background-color: rgba(0, 255, 0, 0.2) !important;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.6) !important;
}
[data-design-theme="terminal"] .section-title,
[data-design-theme="terminal"] .section-content,
[data-design-theme="terminal"] .section-content p {
color: #00ff00 !important;
}
[data-design-theme="terminal"] .link {
color: #00ff00 !important;
text-decoration: underline;
}
[data-design-theme="terminal"] .link:hover {
color: #00ff00 !important;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.8) !important;
}
[data-design-theme="terminal"] .version-badge {
background-color: rgba(0, 255, 0, 0.2) !important;
border: 1px solid #00ff00 !important;
color: #00ff00 !important;
box-shadow: 0 0 5px rgba(0, 255, 0, 0.3) !important;
}
[data-design-theme="terminal"] .features-list li,
[data-design-theme="terminal"] .changelog-list li,
[data-design-theme="terminal"] .links-list li {
color: #00ff00 !important;
}

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

@ -1,5 +1,8 @@ @@ -1,5 +1,8 @@
<script lang="ts">
import type { GitFile, GitRepoInfo } from '../../services/content/git-repo-fetcher.js';
// @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
interface Props {
files: GitFile[];
@ -16,6 +19,8 @@ @@ -16,6 +19,8 @@
let fileContent = $state<string | null>(null);
let loadingContent = $state(false);
let contentError = $state<string | null>(null);
let codeRef = $state<HTMLElement | null>(null);
let fileUrl = $state<string | null>(null); // For images and media files
// Build tree structure
function buildTree(files: GitFile[]): any {
@ -59,7 +64,7 @@ @@ -59,7 +64,7 @@
}
async function fetchFileContent(file: GitFile) {
if (selectedFile?.path === file.path && fileContent) {
if (selectedFile?.path === file.path && (fileContent || fileUrl)) {
return; // Already loaded
}
@ -67,6 +72,14 @@ @@ -67,6 +72,14 @@
loadingContent = true;
contentError = null;
fileContent = null;
fileUrl = null;
// For images and media files, we just need the URL, not the content
if (isImageFile(file) || isVideoFile(file) || isAudioFile(file)) {
fileUrl = getRawFileUrl(file);
loadingContent = false;
return;
}
try {
// Parse the repo URL to determine platform
@ -146,6 +159,104 @@ @@ -146,6 +159,104 @@
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getFileExtension(file: GitFile): string {
const parts = file.name.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
}
function isImageFile(file: GitFile): boolean {
const ext = getFileExtension(file);
return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico'].includes(ext);
}
function isVideoFile(file: GitFile): boolean {
const ext = getFileExtension(file);
return ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv', 'wmv', 'flv'].includes(ext);
}
function isAudioFile(file: GitFile): boolean {
const ext = getFileExtension(file);
return ['mp3', 'wav', 'ogg', 'oga', 'aac', 'm4a', 'flac', 'wma'].includes(ext);
}
function isCodeFile(file: GitFile): boolean {
const ext = getFileExtension(file);
const codeExtensions = [
'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'h', 'hpp',
'html', 'css', 'scss', 'sass', 'less', 'json', 'yaml', 'yml',
'xml', 'md', 'txt', 'sh', 'bash', 'zsh', 'rs', 'go', 'php', 'rb',
'swift', 'kt', 'dart', 'vue', 'svelte', 'r', 'sql', 'pl', 'lua',
'clj', 'hs', 'elm', 'ex', 'exs', 'ml', 'fs', 'vb', 'cs', 'd',
'pas', 'ada', 'erl', 'hrl', 'vim', 'vimrc', 'zshrc', 'bashrc',
'dockerfile', 'makefile', 'cmake', 'gradle', 'maven', 'pom',
'toml', 'ini', 'conf', 'config', 'properties', 'env', 'gitignore',
'dockerignore', 'editorconfig', 'eslintrc', 'prettierrc'
];
return codeExtensions.includes(ext);
}
function getLanguageFromExtension(file: GitFile): string {
const ext = getFileExtension(file);
const languageMap: Record<string, string> = {
'js': 'javascript', 'ts': 'typescript', 'jsx': 'javascript', 'tsx': 'typescript',
'py': 'python', 'java': 'java', 'cpp': 'cpp', 'c': 'c', 'h': 'c', 'hpp': 'cpp',
'html': 'html', 'css': 'css', 'scss': 'scss', 'sass': 'sass', 'less': 'less',
'json': 'json', 'yaml': 'yaml', 'yml': 'yaml', 'xml': 'xml',
'md': 'markdown', 'txt': 'plaintext', 'sh': 'bash', 'bash': 'bash', 'zsh': 'bash',
'rs': 'rust', 'go': 'go', 'php': 'php', 'rb': 'ruby',
'swift': 'swift', 'kt': 'kotlin', 'dart': 'dart', 'vue': 'vue', 'svelte': 'svelte',
'r': 'r', 'sql': 'sql', 'pl': 'perl', 'lua': 'lua',
'clj': 'clojure', 'hs': 'haskell', 'elm': 'elm', 'ex': 'elixir', 'exs': 'elixir',
'ml': 'ocaml', 'fs': 'fsharp', 'vb': 'vbnet', 'cs': 'csharp', 'd': 'd',
'pas': 'pascal', 'ada': 'ada', 'erl': 'erlang', 'hrl': 'erlang',
'vim': 'vim', 'vimrc': 'vim', 'zshrc': 'bash', 'bashrc': 'bash',
'dockerfile': 'dockerfile', 'makefile': 'makefile', 'cmake': 'cmake',
'gradle': 'gradle', 'maven': 'xml', 'pom': 'xml',
'toml': 'toml', 'ini': 'ini', 'conf': 'ini', 'config': 'ini',
'properties': 'properties', 'env': 'bash', 'gitignore': 'gitignore',
'dockerignore': 'gitignore', 'editorconfig': 'ini', 'eslintrc': 'json', 'prettierrc': 'json'
};
return languageMap[ext] || 'plaintext';
}
function getRawFileUrl(file: GitFile): string {
const url = repoInfo.url;
const branch = repoInfo.defaultBranch;
if (url.includes('github.com')) {
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
if (match) {
const [, owner, repo] = match;
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${file.path}`;
}
} else if (url.includes('gitlab.com')) {
const match = url.match(/gitlab\.com\/([^/]+)\/([^/]+)/);
if (match) {
const [, owner, repo] = match;
const projectPath = encodeURIComponent(`${owner}/${repo}`);
return `https://gitlab.com/${owner}/${repo}/-/raw/${branch}/${file.path}`;
}
} else {
// Try Gitea pattern
const match = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+)/);
if (match) {
const [, baseUrl, owner, repo] = match;
return `${baseUrl}/${owner}/${repo}/raw/branch/${branch}/${file.path}`;
}
}
return '';
}
// Apply syntax highlighting when file content changes
$effect(() => {
if (fileContent && selectedFile && isCodeFile(selectedFile) && codeRef) {
const language = getLanguageFromExtension(selectedFile);
codeRef.innerHTML = hljs.highlight(fileContent, { language }).value;
codeRef.className = `language-${language}`;
}
});
</script>
<div class="file-explorer">
@ -346,13 +457,37 @@ @@ -346,13 +457,37 @@
<div class="file-content-error">
<p>Error: {contentError}</p>
</div>
{:else if selectedFile && fileContent !== null}
{:else if selectedFile && (fileContent !== null || fileUrl)}
<div class="file-content-header">
<h3 class="file-content-title">{selectedFile.path}</h3>
<span class="file-content-size">{formatFileSize(selectedFile.size)}</span>
</div>
<div class="file-content-body">
<pre class="file-content-code"><code>{fileContent}</code></pre>
{#if isImageFile(selectedFile) && fileUrl}
<div class="file-image-container">
<img src={fileUrl} alt={selectedFile.name} class="file-image" />
</div>
{:else if isVideoFile(selectedFile) && fileUrl}
<div class="file-media-container">
<video controls class="file-video">
<source src={fileUrl} type="video/{getFileExtension(selectedFile)}" />
Your browser does not support the video tag.
</video>
</div>
{:else if isAudioFile(selectedFile) && fileUrl}
<div class="file-media-container">
<audio controls class="file-audio">
<source src={fileUrl} type="audio/{getFileExtension(selectedFile)}" />
Your browser does not support the audio tag.
</audio>
</div>
{:else if fileContent !== null}
{#if isCodeFile(selectedFile)}
<pre class="file-content-code"><code bind:this={codeRef} class="language-{getLanguageFromExtension(selectedFile)}">{fileContent}</code></pre>
{:else}
<pre class="file-content-code"><code>{fileContent}</code></pre>
{/if}
{/if}
</div>
{:else}
<div class="file-content-empty">
@ -599,16 +734,128 @@ @@ -599,16 +734,128 @@
.file-content-code {
margin: 0;
font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem;
line-height: 1.5;
color: var(--fog-text, #1f2937);
white-space: pre-wrap;
background: #1e1e1e !important; /* VS Code dark background, same as JSON preview */
border: 1px solid #3e3e3e;
border-radius: 4px;
padding: 1rem;
overflow-x: auto;
white-space: pre;
word-wrap: break-word;
}
:global(.dark) .file-content-code {
color: var(--fog-dark-text, #f9fafb);
background: #1e1e1e !important;
border-color: #3e3e3e;
}
.file-content-code code {
display: block;
overflow-x: auto;
padding: 0;
background: transparent !important;
color: #d4d4d4; /* VS Code text color */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
.file-content-code :global(code.hljs),
.file-content-code :global(code.hljs *),
.file-content-code :global(code.hljs span),
.file-content-code :global(code.hljs .hljs-keyword),
.file-content-code :global(code.hljs .hljs-string),
.file-content-code :global(code.hljs .hljs-comment),
.file-content-code :global(code.hljs .hljs-number),
.file-content-code :global(code.hljs .hljs-function),
.file-content-code :global(code.hljs .hljs-variable),
.file-content-code :global(code.hljs .hljs-class),
.file-content-code :global(code.hljs .hljs-title),
.file-content-code :global(code.hljs .hljs-attr),
.file-content-code :global(code.hljs .hljs-tag),
.file-content-code :global(code.hljs .hljs-name),
.file-content-code :global(code.hljs .hljs-selector-id),
.file-content-code :global(code.hljs .hljs-selector-class),
.file-content-code :global(code.hljs .hljs-attribute),
.file-content-code :global(code.hljs .hljs-built_in),
.file-content-code :global(code.hljs .hljs-literal),
.file-content-code :global(code.hljs .hljs-type),
.file-content-code :global(code.hljs .hljs-property),
.file-content-code :global(code.hljs .hljs-operator),
.file-content-code :global(code.hljs .hljs-punctuation),
.file-content-code :global(code.hljs .hljs-meta),
.file-content-code :global(code.hljs .hljs-doctag),
.file-content-code :global(code.hljs .hljs-section),
.file-content-code :global(code.hljs .hljs-addition),
.file-content-code :global(code.hljs .hljs-deletion),
.file-content-code :global(code.hljs .hljs-emphasis),
.file-content-code :global(code.hljs .hljs-strong) {
text-shadow: none !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
font-smoothing: antialiased !important;
text-rendering: geometricPrecision !important;
transform: translateZ(0);
backface-visibility: hidden;
filter: none !important;
will-change: auto;
}
.file-image-container {
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.375rem;
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .file-image-container {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.file-image {
max-width: 100%;
max-height: 70vh;
height: auto;
border-radius: 0.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.dark) .file-image {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.file-media-container {
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.375rem;
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .file-media-container {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.file-video {
max-width: 100%;
max-height: 70vh;
border-radius: 0.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.dark) .file-video {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.file-audio {
width: 100%;
max-width: 600px;
}
.file-content-loading,

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

@ -43,7 +43,7 @@ @@ -43,7 +43,7 @@
let cacheProfiles: string[] = [];
// Map to track which relay each event came from
const eventRelayMap = new Map<string, string>();
const CACHE_SEARCH_DEBOUNCE = 500; // 500ms debounce for cache search
const CACHE_SEARCH_DEBOUNCE = 1000; // 1000ms debounce for cache search
// Clear results at start of search
function clearResults() {
@ -1073,7 +1073,7 @@ @@ -1073,7 +1073,7 @@
searching = true;
searchTimeout = setTimeout(() => {
performSearch();
}, 300);
}, 800);
} else {
searchResults = [];
showResults = false;

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

@ -98,6 +98,7 @@ @@ -98,6 +98,7 @@
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let showJsonModal = $state(false);
let showPreviewModal = $state(false);
let exampleJsonPreviewRef: HTMLElement | null = $state(null);
let previewContent = $state<string>('');
let previewEvent = $state<NostrEvent | null>(null);
let showExampleModal = $state(false);
@ -201,6 +202,21 @@ @@ -201,6 +202,21 @@
const exampleJSON = $derived(getExampleJSON());
// Highlight example JSON when it changes
$effect(() => {
if (exampleJsonPreviewRef && exampleJSON && exampleJsonPreviewRef instanceof HTMLElement) {
try {
const highlighted = hljs.highlight(exampleJSON, { language: 'json' }).value;
exampleJsonPreviewRef.innerHTML = highlighted;
exampleJsonPreviewRef.className = 'hljs language-json';
} catch (err) {
// Fallback to plain text if highlighting fails
exampleJsonPreviewRef.textContent = exampleJSON;
exampleJsonPreviewRef.className = 'language-json';
}
}
});
const isKind30040 = $derived(selectedKind === KIND.PUBLICATION_INDEX);
const isKind10895 = $derived(selectedKind === KIND.RSS_FEED);
@ -888,7 +904,7 @@ @@ -888,7 +904,7 @@
</button>
</div>
<div class="modal-body">
<pre class="example-json">{exampleJSON}</pre>
<pre class="json-preview"><code bind:this={exampleJsonPreviewRef} class="language-json">{exampleJSON}</code></pre>
</div>
<div class="modal-footer">
<button onclick={() => {
@ -1065,18 +1081,47 @@ @@ -1065,18 +1081,47 @@
}
}
.example-json {
.example-modal .json-preview {
background: #1e1e1e !important; /* VS Code dark background, same as code blocks */
border: 1px solid #3e3e3e;
border-radius: 4px;
padding: 1rem;
margin: 0;
font-size: 0.75rem;
font-family: 'Courier New', Courier, monospace;
color: var(--fog-text, #475569);
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.4;
overflow-x: auto;
max-height: 60vh;
}
:global(.dark) .example-json {
color: var(--fog-dark-text, #cbd5e1);
.example-modal .json-preview code {
display: block;
overflow-x: auto;
padding: 0;
background: transparent !important;
color: #d4d4d4; /* VS Code text color */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
@media (max-width: 768px) {
.example-modal .json-preview {
padding: 0.75rem;
}
.example-modal .json-preview code {
font-size: 0.8125rem;
}
}
@media (max-width: 640px) {
.example-modal .json-preview {
padding: 0.5rem;
border-radius: 0;
max-height: calc(100vh - 200px);
}
.example-modal .json-preview code {
font-size: 0.75rem;
}
}
.suggested-tags {

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

@ -6,6 +6,9 @@ @@ -6,6 +6,9 @@
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
// @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
interface Props {
initialEventId?: string | null;
@ -18,6 +21,23 @@ @@ -18,6 +21,23 @@
let foundEvent = $state<NostrEvent | null>(null);
let error = $state<string | null>(null);
let showEdit = $state(false);
let jsonPreviewRef: HTMLElement | null = $state(null);
// Highlight JSON when foundEvent changes
$effect(() => {
if (jsonPreviewRef && foundEvent && jsonPreviewRef instanceof HTMLElement) {
const jsonText = JSON.stringify(foundEvent, null, 2);
try {
const highlighted = hljs.highlight(jsonText, { language: 'json' }).value;
jsonPreviewRef.innerHTML = highlighted;
jsonPreviewRef.className = 'hljs language-json';
} catch (err) {
// Fallback to plain text if highlighting fails
jsonPreviewRef.textContent = jsonText;
jsonPreviewRef.className = 'language-json';
}
}
});
// Auto-load event if initialEventId is provided
$effect(() => {
@ -143,7 +163,7 @@ @@ -143,7 +163,7 @@
</div>
<div class="event-json">
<pre>{JSON.stringify(foundEvent, null, 2)}</pre>
<pre class="json-preview"><code bind:this={jsonPreviewRef} class="language-json">{JSON.stringify(foundEvent, null, 2)}</code></pre>
</div>
<button class="edit-button" onclick={startEdit}>
@ -296,26 +316,46 @@ @@ -296,26 +316,46 @@
.event-json {
margin-bottom: 1rem;
}
.event-json .json-preview {
background: #1e1e1e !important; /* VS Code dark background, same as code blocks */
border: 1px solid #3e3e3e;
border-radius: 4px;
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
margin: 0;
overflow-x: auto;
}
:global(.dark) .event-json {
background: var(--fog-dark-highlight, #374151);
.event-json .json-preview code {
display: block;
overflow-x: auto;
padding: 0;
background: transparent !important;
color: #d4d4d4; /* VS Code text color */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
.event-json pre {
margin: 0;
font-size: 0.75rem;
color: var(--fog-text, #475569);
white-space: pre-wrap;
word-wrap: break-word;
@media (max-width: 768px) {
.event-json .json-preview {
padding: 0.75rem;
}
.event-json .json-preview code {
font-size: 0.8125rem;
}
}
:global(.dark) .event-json pre {
color: var(--fog-dark-text, #cbd5e1);
@media (max-width: 640px) {
.event-json .json-preview {
padding: 0.5rem;
}
.event-json .json-preview code {
font-size: 0.75rem;
}
}
.edit-button {

96
src/lib/modules/discussions/DiscussionVoteButtons.svelte

@ -32,27 +32,41 @@ @@ -32,27 +32,41 @@
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
// Count upvotes and downvotes
let upvotes = $derived.by(() => {
let count = 0;
// Count upvotes and downvotes - only count the most recent vote from each pubkey
// Compute both counts in a single pass for efficiency
let { upvotes, downvotes } = $derived.by(() => {
// Group reactions by pubkey and keep only the most recent one per pubkey
const votesByPubkey = new Map<string, NostrEvent>();
for (const reaction of allReactionsMap.values()) {
const content = reaction.content.trim();
if (content === '+' || content === '⬆' || content === '↑') {
count++;
const normalizedContent = content === '⬆' || content === '↑' ? '+' :
content === '⬇' || content === '↓' ? '-' : content;
// Only count valid votes
if (normalizedContent !== '+' && normalizedContent !== '-') {
continue;
}
const existing = votesByPubkey.get(reaction.pubkey);
// Keep the most recent vote from each pubkey
if (!existing || reaction.created_at > existing.created_at) {
votesByPubkey.set(reaction.pubkey, reaction);
}
}
return count;
});
let downvotes = $derived.by(() => {
let count = 0;
for (const reaction of allReactionsMap.values()) {
// Count upvotes and downvotes in a single pass
let upCount = 0;
let downCount = 0;
for (const reaction of votesByPubkey.values()) {
const content = reaction.content.trim();
if (content === '-' || content === '⬇' || content === '↓') {
count++;
if (content === '+' || content === '⬆' || content === '↑') {
upCount++;
} else if (content === '-' || content === '⬇' || content === '↓') {
downCount++;
}
}
return count;
return { upvotes: upCount, downvotes: downCount };
});
// Only show votes as calculated after initial load completes
@ -422,7 +436,16 @@ @@ -422,7 +436,16 @@
content: ''
};
const relays = relayManager.getReactionPublishRelays();
// Extract relay hints from the event being reacted to (r tags)
const reactionRelayHints = event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.map(tag => tag[1])
.filter((url): url is string => {
// Validate it's a websocket URL
return typeof url === 'string' && (url.startsWith('ws://') || url.startsWith('wss://'));
});
const relays = relayManager.getReactionPublishRelays(reactionRelayHints);
const results = await signAndPublish(deletionEvent, relays);
publicationResults = results;
publicationModalOpen = true;
@ -459,7 +482,16 @@ @@ -459,7 +482,16 @@
content: ''
};
const relays = relayManager.getReactionPublishRelays();
// Extract relay hints from the event being reacted to (r tags)
const reactionRelayHints = event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.map(tag => tag[1])
.filter((url): url is string => {
// Validate it's a websocket URL
return typeof url === 'string' && (url.startsWith('ws://') || url.startsWith('wss://'));
});
const relays = relayManager.getReactionPublishRelays(reactionRelayHints);
const results = await signAndPublish(deletionEvent, relays);
publicationResults = results;
publicationModalOpen = true;
@ -496,8 +528,25 @@ @@ -496,8 +528,25 @@
// Sign the event first to get the ID
const signedEvent = await sessionManager.signEvent(reactionEvent);
// Publish the signed event using reaction publish relays (filters read-only relays)
const relays = relayManager.getReactionPublishRelays();
// Extract relay hints from the event being reacted to (r tags)
const reactionRelayHints = event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.map(tag => tag[1])
.filter((url): url is string => {
// Validate it's a websocket URL
return typeof url === 'string' && (url.startsWith('ws://') || url.startsWith('wss://'));
});
// Publish the signed event using reaction publish relays (includes relay hints)
const relays = relayManager.getReactionPublishRelays(reactionRelayHints);
if (relays.length === 0) {
console.error('[DiscussionVoteButtons] No relays available for publishing reaction');
alert('No relays available for publishing. Please check your relay configuration.');
return;
}
console.log('[DiscussionVoteButtons] Publishing reaction to relays:', relays);
const results = await nostrClient.publish(signedEvent, { relays });
publicationResults = results;
publicationModalOpen = true;
@ -506,9 +555,20 @@ @@ -506,9 +555,20 @@
userReaction = content;
userReactionEventId = signedEvent.id;
// Add the new reaction to the map so counts update immediately
// Remove any old reactions from the same pubkey to ensure only the most recent vote is counted
// Then add the new reaction to the map so counts update immediately
// Reassign map to trigger reactivity in Svelte 5
const newMap = new Map(allReactionsMap);
const currentPubkey = sessionManager.getCurrentPubkey()!;
// Remove any existing reactions from this pubkey (keep only the newest)
for (const [reactionId, reaction] of newMap.entries()) {
if (reaction.pubkey === currentPubkey && reaction.id !== signedEvent.id) {
newMap.delete(reactionId);
}
}
// Add the new reaction
newMap.set(signedEvent.id, signedEvent);
allReactionsMap = newMap;
} catch (error) {

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

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
import { nip19 } from 'nostr-tools';
import CommentForm from '../comments/CommentForm.svelte';
interface Props {
highlight: NostrEvent; // The highlight event (kind 9802)
@ -27,6 +28,7 @@ @@ -27,6 +28,7 @@
let sourceEvent = $state<NostrEvent | null>(null);
let loadingSource = $state(false);
let showReplyForm = $state(false);
// Check if this event is bookmarked (async, so we use state)
// Only check if user is logged in
@ -366,10 +368,10 @@ @@ -366,10 +368,10 @@
icon="message-square"
label="Reply"
size={16}
onclick={() => {}}
onclick={() => showReplyForm = !showReplyForm}
/>
{/if}
<EventMenu event={highlight} showContentActions={true} onReply={() => {}} />
<EventMenu event={highlight} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
{/snippet}
</CardHeader>
@ -416,6 +418,21 @@ @@ -416,6 +418,21 @@
<FeedReactionButtons event={highlight} />
</div>
{#if isLoggedIn && showReplyForm}
<div class="reply-form-container mb-4">
<CommentForm
threadId={highlight.id}
rootEvent={highlight}
onPublished={() => {
showReplyForm = false;
}}
onCancel={() => {
showReplyForm = false;
}}
/>
</div>
{/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(highlight.kind).number}</span>
<span class="kind-description">{getKindInfo(highlight.kind).description}</span>

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

@ -411,7 +411,16 @@ @@ -411,7 +411,16 @@
content: ''
};
const relays = relayManager.getReactionPublishRelays();
// Extract relay hints from the event being reacted to (r tags)
const reactionRelayHints = event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.map(tag => tag[1])
.filter((url): url is string => {
// Validate it's a websocket URL
return typeof url === 'string' && (url.startsWith('ws://') || url.startsWith('wss://'));
});
const relays = relayManager.getReactionPublishRelays(reactionRelayHints);
const results = await signAndPublish(deletionEvent, relays);
publicationResults = results;
publicationModalOpen = true;
@ -475,8 +484,17 @@ @@ -475,8 +484,17 @@
// Sign the event first to get the ID
const signedEvent = await sessionManager.signEvent(reactionEvent);
// Publish the signed event using reaction publish relays (filters read-only relays)
const relays = relayManager.getReactionPublishRelays();
// Extract relay hints from the event being reacted to (r tags)
const reactionRelayHints = event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.map(tag => tag[1])
.filter((url): url is string => {
// Validate it's a websocket URL
return typeof url === 'string' && (url.startsWith('ws://') || url.startsWith('wss://'));
});
// Publish the signed event using reaction publish relays (includes relay hints)
const relays = relayManager.getReactionPublishRelays(reactionRelayHints);
const results = await nostrClient.publish(signedEvent, { relays });
publicationResults = results;
publicationModalOpen = true;

119
src/lib/services/content/git-repo-fetcher.ts

@ -199,11 +199,12 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo @@ -199,11 +199,12 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo
})) || [];
// Try to fetch README (prioritize .adoc over .md)
// First try root directory (most common case)
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) {
try {
const readmeData = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}`).then(r => {
const readmeData = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`).then(r => {
if (!r.ok) throw new Error('Not found');
return r.json();
});
@ -221,6 +222,45 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo @@ -221,6 +222,45 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo
}
}
// If not found in root, search the file tree (case-insensitive)
if (!readme && files.length > 0) {
const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i];
let readmePath: string | null = null;
for (const file of files) {
if (file.type === 'file') {
const fileName = file.name;
for (const pattern of readmePatterns) {
if (pattern.test(fileName)) {
readmePath = file.path;
break;
}
}
if (readmePath) break;
}
}
// If found in tree, fetch it
if (readmePath) {
try {
const readmeData = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${readmePath}?ref=${defaultBranch}`).then(r => {
if (!r.ok) throw new Error('Not found');
return r.json();
});
if (readmeData.content) {
const content = atob(readmeData.content.replace(/\s/g, ''));
const format = readmePath.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown';
readme = {
path: readmePath,
content,
format
};
}
} catch {
// Failed to fetch from tree path
}
}
}
return {
name: repoData.name,
description: repoData.description,
@ -283,6 +323,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr @@ -283,6 +323,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
}
// Try to fetch README (prioritize .adoc over .md)
// First try root directory (most common case)
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) {
@ -302,6 +343,42 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr @@ -302,6 +343,42 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
}
}
// If not found in root, search the file tree (case-insensitive)
if (!readme && files.length > 0) {
const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i];
let readmePath: string | null = null;
for (const file of files) {
if (file.type === 'file') {
const fileName = file.name;
for (const pattern of readmePatterns) {
if (pattern.test(fileName)) {
readmePath = file.path;
break;
}
}
if (readmePath) break;
}
}
// If found in tree, fetch it
if (readmePath) {
try {
const fileData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/files/${encodeURIComponent(readmePath)}/raw?ref=${repoData.default_branch}`).then(r => {
if (!r.ok) throw new Error('Not found');
return r.text();
});
const format = readmePath.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown';
readme = {
path: readmePath,
content: fileData,
format
};
} catch {
// Failed to fetch from tree path
}
}
}
return {
name: repoData.name,
description: repoData.description,
@ -361,6 +438,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro @@ -361,6 +438,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
}
// Try to fetch README (prioritize .adoc over .md)
// First try root directory (most common case)
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) {
@ -383,6 +461,45 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro @@ -383,6 +461,45 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
}
}
// If not found in root, search the file tree (case-insensitive)
if (!readme && files.length > 0) {
const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i];
let readmePath: string | null = null;
for (const file of files) {
if (file.type === 'file') {
const fileName = file.name;
for (const pattern of readmePatterns) {
if (pattern.test(fileName)) {
readmePath = file.path;
break;
}
}
if (readmePath) break;
}
}
// If found in tree, fetch it
if (readmePath) {
try {
const fileData = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents/${readmePath}?ref=${repoData.default_branch}`).then(r => {
if (!r.ok) throw new Error('Not found');
return r.json();
});
if (fileData.content) {
const content = atob(fileData.content.replace(/\s/g, ''));
const format = readmePath.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown';
readme = {
path: readmePath,
content,
format
};
}
} catch {
// Failed to fetch from tree path
}
}
}
return {
name: repoData.name,
description: repoData.description,

53
src/lib/services/nostr/relay-manager.ts

@ -326,9 +326,48 @@ class RelayManager { @@ -326,9 +326,48 @@ class RelayManager {
/**
* Get relays for publishing reactions (kind 7)
* If reacting to an event, include relay hints from that event
* Includes user outbox and local relays, excludes read-only relays (aggregators)
*/
getReactionPublishRelays(): string[] {
return this.getPublishRelays(config.defaultRelays);
getReactionPublishRelays(reactionRelayHints?: string[]): string[] {
// Start with default relays, excluding read-only relays
const baseRelays = config.defaultRelays.filter(
relay => !config.readOnlyRelays.includes(relay)
);
// Get publish relays (includes user outbox and local relays)
let relays = this.getPublishRelays(baseRelays, true);
// If reacting to an event with relay hints, add them (but filter out read-only)
if (reactionRelayHints && reactionRelayHints.length > 0) {
const filteredHints = reactionRelayHints.filter(
relay => !config.readOnlyRelays.includes(relay)
);
relays = [...relays, ...filteredHints];
}
// Normalize and filter after combining all relays
relays = this.normalizeRelays(relays);
relays = this.filterBlocked(relays);
// Ensure we always have at least the default relays (minus aggregators) to publish to
if (relays.length === 0) {
// Fallback to default relays if we somehow ended up with an empty list
relays = baseRelays.length > 0 ? baseRelays : config.defaultRelays.filter(
relay => !config.readOnlyRelays.includes(relay)
);
}
// Log for debugging
console.log('[RelayManager] Reaction publish relays:', {
baseRelays,
userOutbox: this.userOutbox,
userLocalRelaysWrite: this.userLocalRelaysWrite,
reactionRelayHints,
finalRelays: relays
});
return relays;
}
/**
@ -355,6 +394,16 @@ class RelayManager { @@ -355,6 +394,16 @@ class RelayManager {
updateBlockedRelays(blocked: Set<string>): void {
this.blockedRelays = blocked;
}
/**
* Get local relays (kind 10432) - both read and write
*/
getLocalRelays(): string[] {
const allLocalRelays = new Set<string>();
this.userLocalRelaysRead.forEach(r => allLocalRelays.add(r));
this.userLocalRelaysWrite.forEach(r => allLocalRelays.add(r));
return Array.from(allLocalRelays);
}
}
export const relayManager = new RelayManager();

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

@ -12,19 +12,36 @@ @@ -12,19 +12,36 @@
function decodeRelayUrl(encoded: string): string | null {
try {
// The relay parameter is just the domain (e.g., "nostr.wine" or "theforest.nostr1.com")
// Construct the full wss:// URL
const domain = encoded.trim();
// The relay parameter might be just the domain or might include protocol and port
let relayUrl = encoded.trim();
// Validate it looks like a domain
if (!domain || domain.includes('/') || domain.includes(':')) {
// If it already has a protocol, use it as-is
if (relayUrl.startsWith('ws://') || relayUrl.startsWith('wss://')) {
return relayUrl;
}
// Check if it's prefixed with ws- to indicate ws:// protocol
if (relayUrl.startsWith('ws-')) {
relayUrl = relayUrl.substring(3); // Remove ws- prefix
return `ws://${relayUrl}`;
}
// Validate it looks like a domain (may include port)
if (!relayUrl || relayUrl.includes('/')) {
return null;
}
// Construct wss:// URL (assume wss:// unless it's localhost)
const relayUrl = domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
? `ws://${domain}`
: `wss://${domain}`;
// Check if port is included (format: hostname:port)
const hasPort = relayUrl.includes(':') && !relayUrl.startsWith('localhost') && !relayUrl.startsWith('127.0.0.1') && !relayUrl.startsWith('192.168.') && !relayUrl.startsWith('10.') && !relayUrl.startsWith('172.');
const portMatch = relayUrl.match(/^([^:]+):(\d+)$/);
// Construct URL (preserve ws:// for localhost/127.0.0.1/local IPs, use wss:// for others)
// Preserve port if it was in the encoded string
if (relayUrl.startsWith('localhost') || relayUrl.startsWith('127.0.0.1') || relayUrl.startsWith('192.168.') || relayUrl.startsWith('10.') || relayUrl.startsWith('172.')) {
relayUrl = `ws://${relayUrl}`;
} else {
relayUrl = `wss://${relayUrl}`;
}
return relayUrl;
} catch (e) {

53
src/routes/find/+page.svelte

@ -147,27 +147,8 @@ @@ -147,27 +147,8 @@
{/if}
</div>
<div class="find-sections">
<section class="find-section">
<NormalSearch
bind:this={normalSearchComponent}
onSearchResults={handleNormalSearchResults}
/>
</section>
<section class="find-section">
<AdvancedSearch
bind:this={advancedSearchComponent}
onSearchResults={handleAdvancedSearchResults}
/>
</section>
<section class="find-section">
<SearchAddressableEvents bind:this={addressableSearchComponent} />
</section>
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0 || searchResults.events.length > 0 || searchResults.profiles.length > 0}
<section class="results-section">
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0 || searchResults.events.length > 0 || searchResults.profiles.length > 0}
<section class="results-section">
<h2>Search Results</h2>
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0}
@ -249,7 +230,26 @@ @@ -249,7 +230,26 @@
</div>
{/if}
</section>
{/if}
{/if}
<div class="find-sections" class:collapsed={hasActiveSearch}>
<section class="find-section">
<NormalSearch
bind:this={normalSearchComponent}
onSearchResults={handleNormalSearchResults}
/>
</section>
<section class="find-section">
<AdvancedSearch
bind:this={advancedSearchComponent}
onSearchResults={handleAdvancedSearchResults}
/>
</section>
<section class="find-section">
<SearchAddressableEvents bind:this={addressableSearchComponent} />
</section>
</div>
</div>
</main>
@ -325,6 +325,10 @@ @@ -325,6 +325,10 @@
gap: 1.5rem;
}
.find-sections.collapsed {
display: none;
}
@media (min-width: 640px) {
.find-sections {
gap: 2rem;
@ -364,7 +368,8 @@ @@ -364,7 +368,8 @@
.results-section {
margin-top: 1.5rem;
margin-top: 0;
margin-bottom: 1.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
@ -374,7 +379,7 @@ @@ -374,7 +379,7 @@
@media (min-width: 640px) {
.results-section {
padding: 1.5rem;
margin-top: 2rem;
margin-bottom: 2rem;
}
}

21
src/routes/highlights/+page.svelte

@ -237,7 +237,7 @@ @@ -237,7 +237,7 @@
<p class="text-fog-text dark:text-fog-dark-text">No highlights found.</p>
</div>
{:else}
<div class="filters-section-sticky mb-4">
<div class="filters-section mb-4">
<div class="filters-row">
<div class="search-filter-section">
<UnifiedSearch
@ -383,25 +383,8 @@ @@ -383,25 +383,8 @@
margin: 0 auto;
}
.filters-section-sticky {
position: sticky;
top: 0;
background: var(--fog-bg, #ffffff);
background-color: var(--fog-bg, #ffffff);
z-index: 10;
padding-top: 1rem;
padding-bottom: 1rem;
.filters-section {
margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
backdrop-filter: none;
opacity: 1;
}
:global(.dark) .filters-section-sticky {
background: var(--fog-dark-bg, #0f172a);
background-color: var(--fog-dark-bg, #0f172a);
opacity: 1;
border-bottom-color: var(--fog-dark-border, #1e293b);
}
.filters-row {

80
src/routes/relay/+page.svelte

@ -63,13 +63,34 @@ @@ -63,13 +63,34 @@
// Add gif relays
config.gifRelays.forEach(r => allRelays.add(r));
// Add local relays if user is logged in
const currentPubkey = sessionManager.getCurrentPubkey();
if (currentPubkey) {
// Ensure relay manager has loaded user preferences
await relayManager.loadUserPreferences(currentPubkey);
// Get local relays (read and write combined)
const localRelays = relayManager.getLocalRelays();
localRelays.forEach(r => allRelays.add(r));
}
// Get connection status from nostrClient
const connectedRelays = nostrClient.getConnectedRelays();
const relayList: RelayInfo[] = Array.from(allRelays).map(url => ({
url,
categories: categorizeRelay(url),
connected: connectedRelays.includes(url)
}));
const relayList: RelayInfo[] = Array.from(allRelays).map(url => {
const categories = categorizeRelay(url);
// Check if it's a local relay
const isLocalRelay = currentPubkey && !config.defaultRelays.includes(url) &&
!config.profileRelays.includes(url) &&
!config.threadPublishRelays.includes(url) &&
!config.gifRelays.includes(url);
if (isLocalRelay && !categories.includes('Local')) {
categories.push('Local');
}
return {
url,
categories,
connected: connectedRelays.includes(url)
};
});
// Sort by first category, then by URL
relayList.sort((a, b) => {
@ -100,9 +121,13 @@ @@ -100,9 +121,13 @@
let url = tag[1].trim();
// Remove trailing slash
url = url.replace(/\/$/, '');
// If no protocol, assume wss://
// If no protocol, use ws:// for local addresses, wss:// for others
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
url = `wss://${url}`;
if (url.startsWith('localhost') || url.startsWith('127.0.0.1') || url.startsWith('192.168.') || url.startsWith('10.') || url.startsWith('172.')) {
url = `ws://${url}`;
} else {
url = `wss://${url}`;
}
}
favoriteRelayUrls.add(url);
}
@ -187,18 +212,26 @@ @@ -187,18 +212,26 @@
}
function handleRelayClick(url: string) {
// Extract hostname from relay URL (e.g., wss://thecitadel.nostr1.com -> thecitadel.nostr1.com)
// The route expects just the domain without protocol or port
// Extract hostname and port from relay URL for navigation
// The route expects domain with optional port, preserving protocol info for ws://
let relayPath = url;
try {
const urlObj = new URL(url);
relayPath = urlObj.hostname;
// Include port if present
relayPath = urlObj.port ? `${urlObj.hostname}:${urlObj.port}` : urlObj.hostname;
// If it's ws:// and not localhost, prefix with ws- to indicate ws:// protocol
if (urlObj.protocol === 'ws:' && !relayPath.startsWith('localhost') && !relayPath.startsWith('127.0.0.1')) {
relayPath = `ws-${relayPath}`;
}
} catch {
// If URL parsing fails, try to extract hostname manually
// Remove protocol (wss:// or ws://) and trailing slash
// If URL parsing fails, try to extract manually
const protocol = url.startsWith('ws://') ? 'ws://' : 'wss://';
relayPath = url.replace(/^wss?:\/\//, '').replace(/\/$/, '');
// Remove port if present (route doesn't support ports in the parameter)
relayPath = relayPath.split(':')[0];
// Keep port if present (don't split on colon if it's part of hostname:port)
// If it's ws:// and not localhost, prefix with ws-
if (protocol === 'ws://' && !relayPath.startsWith('localhost') && !relayPath.startsWith('127.0.0.1')) {
relayPath = `ws-${relayPath}`;
}
}
// Navigate to feed page with relay filter
goto(`/feed/relay/${relayPath}`);
@ -210,12 +243,17 @@ @@ -210,12 +243,17 @@
// 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
// Try to parse as URL, add protocol if missing
let fullUrl = url;
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
// Use ws:// for local addresses, wss:// for others
if (url.startsWith('localhost') || url.startsWith('127.0.0.1') || url.startsWith('192.168.') || url.startsWith('10.') || url.startsWith('172.')) {
fullUrl = `ws://${url}`;
} else {
fullUrl = `wss://${url}`;
}
}
new URL(fullUrl); // Validate URL format
// Navigate to the relay
handleRelayClick(fullUrl);
@ -308,7 +346,7 @@ @@ -308,7 +346,7 @@
</section>
{/if}
{#each ['Default', 'Profile', 'Thread Publish', 'GIF', 'Other'] as category}
{#each ['Local', 'Default', 'Profile', 'Thread Publish', 'GIF', 'Other'] as category}
{@const categoryRelays = relays.filter(r => r.categories.includes(category))}
{#if categoryRelays.length > 0}
<section class="relay-category">

396
src/routes/repos/+page.svelte

@ -11,12 +11,17 @@ @@ -11,12 +11,17 @@
import { goto } from '$app/navigation';
import { getRecentCachedEvents } from '../../lib/services/cache/event-cache.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
let repos = $state<NostrEvent[]>([]);
let loading = $state(true);
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
let searchQuery = $state('');
let showMyRepos = $state(false);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
const currentPubkey = $derived(sessionManager.getCurrentPubkey());
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result;
@ -68,36 +73,38 @@ @@ -68,36 +73,38 @@
}
async function loadRepos() {
// Only show loading spinner if we don't have cached repos
// Check cache first before making any network requests
// If we already have cached repos, we'll enhance with fresh data in the background
const hasCachedRepos = repos.length > 0;
// Only show loading spinner if we don't have cached repos
if (!hasCachedRepos) {
loading = true;
}
try {
const relays = relayManager.getProfileReadRelays();
// Fetch repo announcement events
const allRepos: NostrEvent[] = [];
// Stream fresh data from relays (progressive enhancement)
// Fetch repo announcement events with cache-first strategy
// This will check cache before making network requests
const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 }],
relays,
{
useCache: 'cache-first', // Already shown cache above, now stream updates
cacheResults: true,
useCache: 'cache-first', // Check cache first, then fetch from relays if needed
cacheResults: true, // Cache any new results
onUpdate: (newRepos) => {
// Merge with existing repos as they stream in
// Merge with existing repos as they stream in (progressive enhancement)
const reposByKey = new Map<string, NostrEvent>();
// Add existing repos first
// Add existing repos first (from cache)
for (const repo of repos) {
const dTag = repo.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${repo.pubkey}:${dTag}`;
reposByKey.set(key, repo);
}
// Add/update with new repos
// Add/update with new repos from network
for (const event of newRepos) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${event.pubkey}:${dTag}`;
@ -112,7 +119,7 @@ @@ -112,7 +119,7 @@
}
);
// Final merge of any remaining events
// Final merge of any remaining events (in case onUpdate didn't catch them all)
const reposByKey = new Map<string, NostrEvent>();
// Add existing cached repos first
@ -122,7 +129,7 @@ @@ -122,7 +129,7 @@
reposByKey.set(key, repo);
}
// Add/update with new repos
// Add/update with new repos from network
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${event.pubkey}:${dTag}`;
@ -135,8 +142,11 @@ @@ -135,8 +142,11 @@
// Sort by created_at descending
repos = Array.from(reposByKey.values()).sort((a, b) => b.created_at - a.created_at);
} catch (error) {
// Failed to load repos
repos = [];
// Failed to load repos - but we might still have cached data
// Only clear repos if we don't have any cached data
if (!hasCachedRepos) {
repos = [];
}
} finally {
loading = false;
}
@ -213,29 +223,193 @@ @@ -213,29 +223,193 @@
}
}
// Filter repos based on search query and pubkey filter
let filteredRepos = $derived.by(() => {
let filtered = repos;
// Helper functions to extract repo data
function getCloneUrls(event: NostrEvent): string[] {
if (!Array.isArray(event.tags)) return [];
return event.tags
.filter(t => Array.isArray(t) && t[0] === 'clone' && t[1])
.map(t => String(t[1]));
}
function getWebUrls(event: NostrEvent): string[] {
if (!Array.isArray(event.tags)) return [];
return event.tags
.filter(t => Array.isArray(t) && t[0] === 'web' && t[1])
.map(t => String(t[1]));
}
function getMaintainers(event: NostrEvent): string[] {
if (!Array.isArray(event.tags)) return [];
const maintainerTag = event.tags.find(t => Array.isArray(t) && t[0] === 'maintainers');
if (maintainerTag && maintainerTag.length > 1) {
return maintainerTag.slice(1).filter(m => m && typeof m === 'string') as string[];
}
return [];
}
function getDTagFromEvent(event: NostrEvent): string {
if (!Array.isArray(event.tags)) return '';
const dTag = event.tags.find(t => Array.isArray(t) && t[0] === 'd');
return dTag?.[1] || '';
}
// Filter by pubkey if provided
if (filterResult.value && filterResult.type === 'pubkey') {
const normalizedPubkey = filterResult.value.toLowerCase();
if (/^[a-f0-9]{64}$/i.test(normalizedPubkey)) {
filtered = filtered.filter(repo => repo.pubkey.toLowerCase() === normalizedPubkey);
// Decode bech32 pubkey (npub or nprofile) to hex
function decodePubkeyToHex(input: string): string | null {
if (!input || input.trim() === '') return null;
const trimmed = input.trim();
// If it's already hex (64 chars), return as-is
if (/^[a-f0-9]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
// Try to decode bech32
try {
if (trimmed.startsWith('npub')) {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
return decoded.data;
}
} else if (trimmed.startsWith('nprofile')) {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'nprofile') {
return decoded.data.pubkey;
}
}
} catch (e) {
// Not a valid bech32
}
return null;
}
// Decode bech32 event ID (nevent, naddr, or note) to hex
function decodeEventIdToHex(input: string): string | null {
if (!input || input.trim() === '') return null;
const trimmed = input.trim();
// If it's already hex (64 chars), return as-is
if (/^[a-f0-9]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
// Try to decode bech32
try {
if (trimmed.startsWith('nevent')) {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'nevent') {
return decoded.data.id;
}
} else if (trimmed.startsWith('naddr')) {
// naddr doesn't have an event ID directly, but we can check if it matches
const decoded = nip19.decode(trimmed);
if (decoded.type === 'naddr') {
// Return the naddr as-is for matching
return trimmed;
}
} else if (trimmed.startsWith('note')) {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'note') {
return decoded.data;
}
}
} catch (e) {
// Not a valid bech32
}
// Filter by text search if provided
if (filterResult.value && filterResult.type === 'text') {
const query = filterResult.value.toLowerCase();
return null;
}
// Filter repos based on search query and filters
let filteredRepos = $derived.by(() => {
let filtered = repos;
// Filter by "See my repos" checkbox
if (showMyRepos && currentPubkey) {
filtered = filtered.filter(repo => {
const name = getRepoName(repo).toLowerCase();
const desc = getRepoDescription(repo).toLowerCase();
return name.includes(query) || desc.includes(query);
// Check if repo owner matches
if (repo.pubkey.toLowerCase() === currentPubkey.toLowerCase()) {
return true;
}
// Check if user is a maintainer
const maintainers = getMaintainers(repo);
return maintainers.some(m => m.toLowerCase() === currentPubkey.toLowerCase());
});
}
// If no search query, return filtered list
if (!searchQuery.trim()) {
return filtered;
}
const query = searchQuery.trim().toLowerCase();
// Try to decode as pubkey (hex, npub, or nprofile)
const decodedPubkey = decodePubkeyToHex(query);
if (decodedPubkey) {
return filtered.filter(repo => {
// Match by owner pubkey
if (repo.pubkey.toLowerCase() === decodedPubkey) {
return true;
}
// Match by maintainer pubkey
const maintainers = getMaintainers(repo);
return maintainers.some(m => m.toLowerCase() === decodedPubkey);
});
}
// Try to decode as event ID (hex, note, nevent, or naddr)
const decodedEventId = decodeEventIdToHex(query);
if (decodedEventId) {
return filtered.filter(repo => {
// Match by event ID
if (repo.id.toLowerCase() === decodedEventId.toLowerCase()) {
return true;
}
// Match by naddr
const naddr = getNaddr(repo);
if (naddr && naddr.toLowerCase() === decodedEventId.toLowerCase()) {
return true;
}
return false;
});
}
return filtered;
// Text search across all fields
return filtered.filter(repo => {
// Search in name
const name = getRepoName(repo).toLowerCase();
if (name.includes(query)) return true;
// Search in description
const desc = getRepoDescription(repo).toLowerCase();
if (desc.includes(query)) return true;
// Search in clone URLs
const cloneUrls = getCloneUrls(repo);
if (cloneUrls.some(url => url.toLowerCase().includes(query))) return true;
// Search in web URLs
const webUrls = getWebUrls(repo);
if (webUrls.some(url => url.toLowerCase().includes(query))) return true;
// Search in d-tag
const dTag = getDTagFromEvent(repo).toLowerCase();
if (dTag.includes(query)) return true;
// Search in naddr
const naddr = getNaddr(repo);
if (naddr && naddr.toLowerCase().includes(query)) return true;
// Search in maintainer pubkeys (as hex)
const maintainers = getMaintainers(repo);
if (maintainers.some(m => m.toLowerCase().includes(query))) return true;
return false;
});
});
</script>
@ -244,20 +418,31 @@ @@ -244,20 +418,31 @@
<main class="container mx-auto px-4 py-8">
<div class="repos-content">
<div class="repos-header mb-6">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Repos</h1>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2 mb-4">
Discover and explore repositories announced on Nostr
</p>
<div class="repos-header-top">
<div>
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Repos</h1>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2 mb-4">
Discover and explore repositories announced on Nostr
</p>
</div>
{#if isLoggedIn}
<label class="my-repos-checkbox">
<input
type="checkbox"
bind:checked={showMyRepos}
class="checkbox-input"
/>
<span>See my repos</span>
</label>
{/if}
</div>
<div class="search-container mb-4">
<UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
allowedKinds={[KIND.REPO_ANNOUNCEMENT]}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange}
placeholder="Search kind 30040 repositories by pubkey, p, q tags, or content..."
<input
type="text"
bind:value={searchQuery}
placeholder="Search by name, clone URL, description, webpage, maintainers, d-tag, naddr, note, nevent, hex event ID, pubkey, npub/nprofile..."
class="repo-search-input"
/>
</div>
</div>
@ -334,6 +519,20 @@ @@ -334,6 +519,20 @@
<h3 class="repo-name">{getRepoName(repo)}</h3>
<span class="repo-kind">Kind {repo.kind}</span>
</div>
<div class="repo-owner">
<span class="repo-owner-label">Owner:</span>
<ProfileBadge pubkey={repo.pubkey} inline={true} />
</div>
{#if getMaintainers(repo).length > 0}
<div class="repo-maintainers">
<span class="repo-maintainers-label">Maintainers:</span>
<div class="repo-maintainers-list">
{#each getMaintainers(repo) as maintainerPubkey}
<ProfileBadge pubkey={maintainerPubkey} inline={true} />
{/each}
</div>
</div>
{/if}
{#if getRepoDescription(repo)}
<p class="repo-description">{getRepoDescription(repo)}</p>
{/if}
@ -365,8 +564,79 @@ @@ -365,8 +564,79 @@
border-bottom-color: var(--fog-dark-border, #374151);
}
.repos-header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.my-repos-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
white-space: nowrap;
user-select: none;
transition: all 0.2s;
}
.my-repos-checkbox:hover {
background: var(--fog-highlight, #f1f5f9);
border-color: var(--fog-accent, #94a3b8);
}
:global(.dark) .my-repos-checkbox {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .my-repos-checkbox:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
.checkbox-input {
cursor: pointer;
}
.search-container {
max-width: 500px;
max-width: 100%;
}
.repo-search-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
font-size: 1rem;
transition: all 0.2s;
}
.repo-search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .repo-search-input {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .repo-search-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.loading-state,
@ -449,6 +719,48 @@ @@ -449,6 +719,48 @@
background: var(--fog-dark-highlight, #374151);
}
.repo-owner {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 0;
font-size: 0.875rem;
}
.repo-owner-label {
color: var(--fog-text-light, #52667a);
font-weight: 500;
}
:global(.dark) .repo-owner-label {
color: var(--fog-dark-text-light, #a8b8d0);
}
.repo-maintainers {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.75rem 0;
font-size: 0.875rem;
}
.repo-maintainers-label {
color: var(--fog-text-light, #52667a);
font-weight: 500;
flex-shrink: 0;
}
:global(.dark) .repo-maintainers-label {
color: var(--fog-dark-text-light, #a8b8d0);
}
.repo-maintainers-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.repo-description {
color: var(--fog-text-light, #52667a);
margin: 0.5rem 0;

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

@ -60,9 +60,9 @@ @@ -60,9 +60,9 @@
}
});
// Load git repo when repository tab is clicked
// Load git repo when repository or about tab is clicked (about tab needs README)
$effect(() => {
if (activeTab === 'repository' && repoEvent && !gitRepo && !loadingGitRepo && !gitRepoFetchAttempted) {
if ((activeTab === 'repository' || activeTab === 'about') && repoEvent && !gitRepo && !loadingGitRepo && !gitRepoFetchAttempted) {
loadGitRepo();
}
});

13
src/routes/rss/+page.svelte

@ -415,8 +415,19 @@ @@ -415,8 +415,19 @@
</p>
{#if rssEvent}
<a
href="/find?event={rssEvent.id}"
href="/write"
class="edit-rss-button"
onclick={(e) => {
if (rssEvent) {
const cloneData = {
kind: rssEvent.kind,
content: rssEvent.content,
tags: rssEvent.tags,
isClone: false
};
sessionStorage.setItem('aitherboard_cloneEvent', JSON.stringify(cloneData));
}
}}
>
Edit RSS Feed Event
</a>

Loading…
Cancel
Save