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. 263
      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. 47
      src/routes/find/+page.svelte
  13. 21
      src/routes/highlights/+page.svelte
  14. 62
      src/routes/relay/+page.svelte
  15. 384
      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 {
text-transform: uppercase !important; 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 { [data-design-theme="terminal"] body::before {
background: background:
repeating-linear-gradient( repeating-linear-gradient(
@ -1945,3 +2571,169 @@ audio {
background-color: #1a8cd8 !important; /* Darker blue on hover */ background-color: #1a8cd8 !important; /* Darker blue on hover */
opacity: 1 !important; 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;
}

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

@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { GitFile, GitRepoInfo } from '../../services/content/git-repo-fetcher.js'; 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 { interface Props {
files: GitFile[]; files: GitFile[];
@ -16,6 +19,8 @@
let fileContent = $state<string | null>(null); let fileContent = $state<string | null>(null);
let loadingContent = $state(false); let loadingContent = $state(false);
let contentError = $state<string | null>(null); 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 // Build tree structure
function buildTree(files: GitFile[]): any { function buildTree(files: GitFile[]): any {
@ -59,7 +64,7 @@
} }
async function fetchFileContent(file: GitFile) { async function fetchFileContent(file: GitFile) {
if (selectedFile?.path === file.path && fileContent) { if (selectedFile?.path === file.path && (fileContent || fileUrl)) {
return; // Already loaded return; // Already loaded
} }
@ -67,6 +72,14 @@
loadingContent = true; loadingContent = true;
contentError = null; contentError = null;
fileContent = 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 { try {
// Parse the repo URL to determine platform // Parse the repo URL to determine platform
@ -146,6 +159,104 @@
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 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> </script>
<div class="file-explorer"> <div class="file-explorer">
@ -346,13 +457,37 @@
<div class="file-content-error"> <div class="file-content-error">
<p>Error: {contentError}</p> <p>Error: {contentError}</p>
</div> </div>
{:else if selectedFile && fileContent !== null} {:else if selectedFile && (fileContent !== null || fileUrl)}
<div class="file-content-header"> <div class="file-content-header">
<h3 class="file-content-title">{selectedFile.path}</h3> <h3 class="file-content-title">{selectedFile.path}</h3>
<span class="file-content-size">{formatFileSize(selectedFile.size)}</span> <span class="file-content-size">{formatFileSize(selectedFile.size)}</span>
</div> </div>
<div class="file-content-body"> <div class="file-content-body">
{#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> <pre class="file-content-code"><code>{fileContent}</code></pre>
{/if}
{/if}
</div> </div>
{:else} {:else}
<div class="file-content-empty"> <div class="file-content-empty">
@ -599,16 +734,128 @@
.file-content-code { .file-content-code {
margin: 0; margin: 0;
font-family: 'Courier New', Courier, monospace; background: #1e1e1e !important; /* VS Code dark background, same as JSON preview */
font-size: 0.875rem; border: 1px solid #3e3e3e;
line-height: 1.5; border-radius: 4px;
color: var(--fog-text, #1f2937); padding: 1rem;
white-space: pre-wrap; overflow-x: auto;
white-space: pre;
word-wrap: break-word; word-wrap: break-word;
} }
:global(.dark) .file-content-code { :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, .file-content-loading,

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

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

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

@ -98,6 +98,7 @@
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let showJsonModal = $state(false); let showJsonModal = $state(false);
let showPreviewModal = $state(false); let showPreviewModal = $state(false);
let exampleJsonPreviewRef: HTMLElement | null = $state(null);
let previewContent = $state<string>(''); let previewContent = $state<string>('');
let previewEvent = $state<NostrEvent | null>(null); let previewEvent = $state<NostrEvent | null>(null);
let showExampleModal = $state(false); let showExampleModal = $state(false);
@ -201,6 +202,21 @@
const exampleJSON = $derived(getExampleJSON()); 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 isKind30040 = $derived(selectedKind === KIND.PUBLICATION_INDEX);
const isKind10895 = $derived(selectedKind === KIND.RSS_FEED); const isKind10895 = $derived(selectedKind === KIND.RSS_FEED);
@ -888,7 +904,7 @@
</button> </button>
</div> </div>
<div class="modal-body"> <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>
<div class="modal-footer"> <div class="modal-footer">
<button onclick={() => { <button onclick={() => {
@ -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; margin: 0;
font-size: 0.75rem; overflow-x: auto;
font-family: 'Courier New', Courier, monospace; max-height: 60vh;
color: var(--fog-text, #475569);
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.4;
} }
:global(.dark) .example-json { .example-modal .json-preview code {
color: var(--fog-dark-text, #cbd5e1); 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 { .suggested-tags {

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

@ -6,6 +6,9 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte'; 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 { interface Props {
initialEventId?: string | null; initialEventId?: string | null;
@ -18,6 +21,23 @@
let foundEvent = $state<NostrEvent | null>(null); let foundEvent = $state<NostrEvent | null>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let showEdit = $state(false); 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 // Auto-load event if initialEventId is provided
$effect(() => { $effect(() => {
@ -143,7 +163,7 @@
</div> </div>
<div class="event-json"> <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> </div>
<button class="edit-button" onclick={startEdit}> <button class="edit-button" onclick={startEdit}>
@ -296,26 +316,46 @@
.event-json { .event-json {
margin-bottom: 1rem; 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; padding: 1rem;
background: var(--fog-highlight, #f3f4f6); margin: 0;
border-radius: 0.25rem;
overflow-x: auto; overflow-x: auto;
} }
:global(.dark) .event-json { .event-json .json-preview code {
background: var(--fog-dark-highlight, #374151); 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 { @media (max-width: 768px) {
margin: 0; .event-json .json-preview {
font-size: 0.75rem; padding: 0.75rem;
color: var(--fog-text, #475569);
white-space: pre-wrap;
word-wrap: break-word;
} }
:global(.dark) .event-json pre { .event-json .json-preview code {
color: var(--fog-dark-text, #cbd5e1); font-size: 0.8125rem;
}
}
@media (max-width: 640px) {
.event-json .json-preview {
padding: 0.5rem;
}
.event-json .json-preview code {
font-size: 0.75rem;
}
} }
.edit-button { .edit-button {

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

@ -32,27 +32,41 @@
let publicationModalOpen = $state(false); let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
// Count upvotes and downvotes // Count upvotes and downvotes - only count the most recent vote from each pubkey
let upvotes = $derived.by(() => { // Compute both counts in a single pass for efficiency
let count = 0; 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()) { for (const reaction of allReactionsMap.values()) {
const content = reaction.content.trim(); const content = reaction.content.trim();
if (content === '+' || content === '⬆' || content === '↑') { const normalizedContent = content === '⬆' || content === '↑' ? '+' :
count++; 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(() => { // Count upvotes and downvotes in a single pass
let count = 0; let upCount = 0;
for (const reaction of allReactionsMap.values()) { let downCount = 0;
for (const reaction of votesByPubkey.values()) {
const content = reaction.content.trim(); const content = reaction.content.trim();
if (content === '-' || content === '⬇' || content === '↓') { if (content === '+' || content === '⬆' || content === '↑') {
count++; upCount++;
} else if (content === '-' || content === '⬇' || content === '↓') {
downCount++;
} }
} }
return count;
return { upvotes: upCount, downvotes: downCount };
}); });
// Only show votes as calculated after initial load completes // Only show votes as calculated after initial load completes
@ -422,7 +436,16 @@
content: '' 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); const results = await signAndPublish(deletionEvent, relays);
publicationResults = results; publicationResults = results;
publicationModalOpen = true; publicationModalOpen = true;
@ -459,7 +482,16 @@
content: '' 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); const results = await signAndPublish(deletionEvent, relays);
publicationResults = results; publicationResults = results;
publicationModalOpen = true; publicationModalOpen = true;
@ -496,8 +528,25 @@
// Sign the event first to get the ID // Sign the event first to get the ID
const signedEvent = await sessionManager.signEvent(reactionEvent); const signedEvent = await sessionManager.signEvent(reactionEvent);
// Publish the signed event using reaction publish relays (filters read-only relays) // Extract relay hints from the event being reacted to (r tags)
const relays = relayManager.getReactionPublishRelays(); 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 }); const results = await nostrClient.publish(signedEvent, { relays });
publicationResults = results; publicationResults = results;
publicationModalOpen = true; publicationModalOpen = true;
@ -506,9 +555,20 @@
userReaction = content; userReaction = content;
userReactionEventId = signedEvent.id; 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 // Reassign map to trigger reactivity in Svelte 5
const newMap = new Map(allReactionsMap); 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); newMap.set(signedEvent.id, signedEvent);
allReactionsMap = newMap; allReactionsMap = newMap;
} catch (error) { } catch (error) {

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

@ -17,6 +17,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte'; import IconButton from '../../components/ui/IconButton.svelte';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import CommentForm from '../comments/CommentForm.svelte';
interface Props { interface Props {
highlight: NostrEvent; // The highlight event (kind 9802) highlight: NostrEvent; // The highlight event (kind 9802)
@ -27,6 +28,7 @@
let sourceEvent = $state<NostrEvent | null>(null); let sourceEvent = $state<NostrEvent | null>(null);
let loadingSource = $state(false); let loadingSource = $state(false);
let showReplyForm = $state(false);
// 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
@ -366,10 +368,10 @@
icon="message-square" icon="message-square"
label="Reply" label="Reply"
size={16} size={16}
onclick={() => {}} onclick={() => showReplyForm = !showReplyForm}
/> />
{/if} {/if}
<EventMenu event={highlight} showContentActions={true} onReply={() => {}} /> <EventMenu event={highlight} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
{/snippet} {/snippet}
</CardHeader> </CardHeader>
@ -416,6 +418,21 @@
<FeedReactionButtons event={highlight} /> <FeedReactionButtons event={highlight} />
</div> </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"> <div class="kind-badge">
<span class="kind-number">{getKindInfo(highlight.kind).number}</span> <span class="kind-number">{getKindInfo(highlight.kind).number}</span>
<span class="kind-description">{getKindInfo(highlight.kind).description}</span> <span class="kind-description">{getKindInfo(highlight.kind).description}</span>

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

@ -411,7 +411,16 @@
content: '' 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); const results = await signAndPublish(deletionEvent, relays);
publicationResults = results; publicationResults = results;
publicationModalOpen = true; publicationModalOpen = true;
@ -475,8 +484,17 @@
// Sign the event first to get the ID // Sign the event first to get the ID
const signedEvent = await sessionManager.signEvent(reactionEvent); const signedEvent = await sessionManager.signEvent(reactionEvent);
// Publish the signed event using reaction publish relays (filters read-only relays) // Extract relay hints from the event being reacted to (r tags)
const relays = relayManager.getReactionPublishRelays(); 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 }); const results = await nostrClient.publish(signedEvent, { relays });
publicationResults = results; publicationResults = results;
publicationModalOpen = true; publicationModalOpen = true;

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

@ -199,11 +199,12 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo
})) || []; })) || [];
// Try to fetch README (prioritize .adoc over .md) // 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; let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) { for (const readmeFile of readmeFiles) {
try { 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'); if (!r.ok) throw new Error('Not found');
return r.json(); return r.json();
}); });
@ -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 { return {
name: repoData.name, name: repoData.name,
description: repoData.description, description: repoData.description,
@ -283,6 +323,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
} }
// Try to fetch README (prioritize .adoc over .md) // 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; let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) { for (const readmeFile of readmeFiles) {
@ -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 { return {
name: repoData.name, name: repoData.name,
description: repoData.description, description: repoData.description,
@ -361,6 +438,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
} }
// Try to fetch README (prioritize .adoc over .md) // 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; let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) { for (const readmeFile of readmeFiles) {
@ -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 { return {
name: repoData.name, name: repoData.name,
description: repoData.description, description: repoData.description,

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

@ -326,9 +326,48 @@ class RelayManager {
/** /**
* Get relays for publishing reactions (kind 7) * 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[] { getReactionPublishRelays(reactionRelayHints?: string[]): string[] {
return this.getPublishRelays(config.defaultRelays); // 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 {
updateBlockedRelays(blocked: Set<string>): void { updateBlockedRelays(blocked: Set<string>): void {
this.blockedRelays = blocked; 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(); export const relayManager = new RelayManager();

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

@ -12,19 +12,36 @@
function decodeRelayUrl(encoded: string): string | null { function decodeRelayUrl(encoded: string): string | null {
try { try {
// The relay parameter is just the domain (e.g., "nostr.wine" or "theforest.nostr1.com") // The relay parameter might be just the domain or might include protocol and port
// Construct the full wss:// URL let relayUrl = encoded.trim();
const domain = encoded.trim();
// Validate it looks like a domain // If it already has a protocol, use it as-is
if (!domain || domain.includes('/') || domain.includes(':')) { 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; return null;
} }
// Construct wss:// URL (assume wss:// unless it's localhost) // Check if port is included (format: hostname:port)
const relayUrl = domain.startsWith('localhost') || domain.startsWith('127.0.0.1') const hasPort = relayUrl.includes(':') && !relayUrl.startsWith('localhost') && !relayUrl.startsWith('127.0.0.1') && !relayUrl.startsWith('192.168.') && !relayUrl.startsWith('10.') && !relayUrl.startsWith('172.');
? `ws://${domain}` const portMatch = relayUrl.match(/^([^:]+):(\d+)$/);
: `wss://${domain}`;
// 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; return relayUrl;
} catch (e) { } catch (e) {

47
src/routes/find/+page.svelte

@ -147,25 +147,6 @@
{/if} {/if}
</div> </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} {#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0 || searchResults.events.length > 0 || searchResults.profiles.length > 0}
<section class="results-section"> <section class="results-section">
<h2>Search Results</h2> <h2>Search Results</h2>
@ -250,6 +231,25 @@
{/if} {/if}
</section> </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>
</div> </div>
</main> </main>
@ -325,6 +325,10 @@
gap: 1.5rem; gap: 1.5rem;
} }
.find-sections.collapsed {
display: none;
}
@media (min-width: 640px) { @media (min-width: 640px) {
.find-sections { .find-sections {
gap: 2rem; gap: 2rem;
@ -364,7 +368,8 @@
.results-section { .results-section {
margin-top: 1.5rem; margin-top: 0;
margin-bottom: 1.5rem;
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1rem; padding: 1rem;
@ -374,7 +379,7 @@
@media (min-width: 640px) { @media (min-width: 640px) {
.results-section { .results-section {
padding: 1.5rem; padding: 1.5rem;
margin-top: 2rem; margin-bottom: 2rem;
} }
} }

21
src/routes/highlights/+page.svelte

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

62
src/routes/relay/+page.svelte

@ -63,13 +63,34 @@
// Add gif relays // Add gif relays
config.gifRelays.forEach(r => allRelays.add(r)); 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 // Get connection status from nostrClient
const connectedRelays = nostrClient.getConnectedRelays(); const connectedRelays = nostrClient.getConnectedRelays();
const relayList: RelayInfo[] = Array.from(allRelays).map(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, url,
categories: categorizeRelay(url), categories,
connected: connectedRelays.includes(url) connected: connectedRelays.includes(url)
})); };
});
// Sort by first category, then by URL // Sort by first category, then by URL
relayList.sort((a, b) => { relayList.sort((a, b) => {
@ -100,10 +121,14 @@
let url = tag[1].trim(); let url = tag[1].trim();
// Remove trailing slash // Remove trailing slash
url = url.replace(/\/$/, ''); 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://')) { if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
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}`; url = `wss://${url}`;
} }
}
favoriteRelayUrls.add(url); favoriteRelayUrls.add(url);
} }
} }
@ -187,18 +212,26 @@
} }
function handleRelayClick(url: string) { function handleRelayClick(url: string) {
// Extract hostname from relay URL (e.g., wss://thecitadel.nostr1.com -> thecitadel.nostr1.com) // Extract hostname and port from relay URL for navigation
// The route expects just the domain without protocol or port // The route expects domain with optional port, preserving protocol info for ws://
let relayPath = url; let relayPath = url;
try { try {
const urlObj = new URL(url); 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 { } catch {
// If URL parsing fails, try to extract hostname manually // If URL parsing fails, try to extract manually
// Remove protocol (wss:// or ws://) and trailing slash const protocol = url.startsWith('ws://') ? 'ws://' : 'wss://';
relayPath = url.replace(/^wss?:\/\//, '').replace(/\/$/, ''); relayPath = url.replace(/^wss?:\/\//, '').replace(/\/$/, '');
// Remove port if present (route doesn't support ports in the parameter) // Keep port if present (don't split on colon if it's part of hostname:port)
relayPath = relayPath.split(':')[0]; // 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 // Navigate to feed page with relay filter
goto(`/feed/relay/${relayPath}`); goto(`/feed/relay/${relayPath}`);
@ -213,8 +246,13 @@
// Try to parse as URL, add protocol if missing // Try to parse as URL, add protocol if missing
let fullUrl = url; let fullUrl = url;
if (!url.startsWith('ws://') && !url.startsWith('wss://')) { 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}`; fullUrl = `wss://${url}`;
} }
}
new URL(fullUrl); // Validate URL format new URL(fullUrl); // Validate URL format
// Navigate to the relay // Navigate to the relay
@ -308,7 +346,7 @@
</section> </section>
{/if} {/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))} {@const categoryRelays = relays.filter(r => r.categories.includes(category))}
{#if categoryRelays.length > 0} {#if categoryRelays.length > 0}
<section class="relay-category"> <section class="relay-category">

384
src/routes/repos/+page.svelte

@ -11,12 +11,17 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getRecentCachedEvents } from '../../lib/services/cache/event-cache.js'; import { getRecentCachedEvents } from '../../lib/services/cache/event-cache.js';
import { KIND } from '../../lib/types/kind-lookup.js'; import { KIND } from '../../lib/types/kind-lookup.js';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
let repos = $state<NostrEvent[]>([]); let repos = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null }); 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 searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null); 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 }) { function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result; filterResult = result;
@ -68,36 +73,38 @@
} }
async function loadRepos() { 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; const hasCachedRepos = repos.length > 0;
// Only show loading spinner if we don't have cached repos
if (!hasCachedRepos) { if (!hasCachedRepos) {
loading = true; loading = true;
} }
try { try {
const relays = relayManager.getProfileReadRelays(); const relays = relayManager.getProfileReadRelays();
// Fetch repo announcement events // Fetch repo announcement events with cache-first strategy
const allRepos: NostrEvent[] = []; // This will check cache before making network requests
// Stream fresh data from relays (progressive enhancement)
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 }], [{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 }],
relays, relays,
{ {
useCache: 'cache-first', // Already shown cache above, now stream updates useCache: 'cache-first', // Check cache first, then fetch from relays if needed
cacheResults: true, cacheResults: true, // Cache any new results
onUpdate: (newRepos) => { 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>(); const reposByKey = new Map<string, NostrEvent>();
// Add existing repos first // Add existing repos first (from cache)
for (const repo of repos) { for (const repo of repos) {
const dTag = repo.tags.find(t => t[0] === 'd')?.[1] || ''; const dTag = repo.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${repo.pubkey}:${dTag}`; const key = `${repo.pubkey}:${dTag}`;
reposByKey.set(key, repo); reposByKey.set(key, repo);
} }
// Add/update with new repos // Add/update with new repos from network
for (const event of newRepos) { for (const event of newRepos) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${event.pubkey}:${dTag}`; const key = `${event.pubkey}:${dTag}`;
@ -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>(); const reposByKey = new Map<string, NostrEvent>();
// Add existing cached repos first // Add existing cached repos first
@ -122,7 +129,7 @@
reposByKey.set(key, repo); reposByKey.set(key, repo);
} }
// Add/update with new repos // Add/update with new repos from network
for (const event of events) { for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${event.pubkey}:${dTag}`; const key = `${event.pubkey}:${dTag}`;
@ -135,8 +142,11 @@
// Sort by created_at descending // Sort by created_at descending
repos = Array.from(reposByKey.values()).sort((a, b) => b.created_at - a.created_at); repos = Array.from(reposByKey.values()).sort((a, b) => b.created_at - a.created_at);
} catch (error) { } catch (error) {
// Failed to load 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 = []; repos = [];
}
} finally { } finally {
loading = false; loading = false;
} }
@ -213,29 +223,193 @@
} }
} }
// Filter repos based on search query and pubkey filter // Helper functions to extract repo data
let filteredRepos = $derived.by(() => { function getCloneUrls(event: NostrEvent): string[] {
let filtered = repos; 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 // Decode bech32 pubkey (npub or nprofile) to hex
if (filterResult.value && filterResult.type === 'pubkey') { function decodePubkeyToHex(input: string): string | null {
const normalizedPubkey = filterResult.value.toLowerCase(); if (!input || input.trim() === '') return null;
if (/^[a-f0-9]{64}$/i.test(normalizedPubkey)) {
filtered = filtered.filter(repo => repo.pubkey.toLowerCase() === normalizedPubkey); 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 return null;
if (filterResult.value && filterResult.type === 'text') { }
const query = filterResult.value.toLowerCase();
// 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 => { filtered = filtered.filter(repo => {
const name = getRepoName(repo).toLowerCase(); // Check if repo owner matches
const desc = getRepoDescription(repo).toLowerCase(); if (repo.pubkey.toLowerCase() === currentPubkey.toLowerCase()) {
return name.includes(query) || desc.includes(query); 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; 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;
});
}
// 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> </script>
@ -244,20 +418,31 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="repos-content"> <div class="repos-content">
<div class="repos-header mb-6"> <div class="repos-header mb-6">
<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> <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"> <p class="text-fog-text-light dark:text-fog-dark-text-light mt-2 mb-4">
Discover and explore repositories announced on Nostr Discover and explore repositories announced on Nostr
</p> </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"> <div class="search-container mb-4">
<UnifiedSearch <input
mode="search" type="text"
bind:this={unifiedSearchComponent} bind:value={searchQuery}
allowedKinds={[KIND.REPO_ANNOUNCEMENT]} placeholder="Search by name, clone URL, description, webpage, maintainers, d-tag, naddr, note, nevent, hex event ID, pubkey, npub/nprofile..."
hideDropdownResults={true} class="repo-search-input"
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange}
placeholder="Search kind 30040 repositories by pubkey, p, q tags, or content..."
/> />
</div> </div>
</div> </div>
@ -334,6 +519,20 @@
<h3 class="repo-name">{getRepoName(repo)}</h3> <h3 class="repo-name">{getRepoName(repo)}</h3>
<span class="repo-kind">Kind {repo.kind}</span> <span class="repo-kind">Kind {repo.kind}</span>
</div> </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)} {#if getRepoDescription(repo)}
<p class="repo-description">{getRepoDescription(repo)}</p> <p class="repo-description">{getRepoDescription(repo)}</p>
{/if} {/if}
@ -365,8 +564,79 @@
border-bottom-color: var(--fog-dark-border, #374151); 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 { .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, .loading-state,
@ -449,6 +719,48 @@
background: var(--fog-dark-highlight, #374151); 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 { .repo-description {
color: var(--fog-text-light, #52667a); color: var(--fog-text-light, #52667a);
margin: 0.5rem 0; margin: 0.5rem 0;

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

@ -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(() => { $effect(() => {
if (activeTab === 'repository' && repoEvent && !gitRepo && !loadingGitRepo && !gitRepoFetchAttempted) { if ((activeTab === 'repository' || activeTab === 'about') && repoEvent && !gitRepo && !loadingGitRepo && !gitRepoFetchAttempted) {
loadGitRepo(); loadGitRepo();
} }
}); });

13
src/routes/rss/+page.svelte

@ -415,8 +415,19 @@
</p> </p>
{#if rssEvent} {#if rssEvent}
<a <a
href="/find?event={rssEvent.id}" href="/write"
class="edit-rss-button" 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 Edit RSS Feed Event
</a> </a>

Loading…
Cancel
Save