(null);
+
+ // Check for event ID in URL query parameters
+ $effect(() => {
+ const eventId = $page.url.searchParams.get('event');
+ if (eventId) {
+ initialEventId = eventId;
+ }
+ });
/**
* Decode pubkey from various formats
@@ -111,7 +121,7 @@
@@ -223,9 +233,30 @@
}
.find-button {
+ padding: 0.75rem 1.5rem;
+ background: var(--fog-accent, #64748b);
+ color: var(--fog-text, #f1f5f9);
+ border: none;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
font-family: monospace;
}
+ :global(.dark) .find-button {
+ background: var(--fog-dark-accent, #94a3b8);
+ }
+
+ .find-button:hover:not(:disabled) {
+ opacity: 0.9;
+ }
+
+ .find-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
.error-message {
margin-top: 1rem;
padding: 0.75rem;
diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte
index 7025f19..6cfa907 100644
--- a/src/routes/repos/+page.svelte
+++ b/src/routes/repos/+page.svelte
@@ -6,7 +6,7 @@
import type { NostrEvent } from '../../lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
-
+ import { getRecentCachedEvents } from '../../lib/services/cache/event-cache.js';
import { KIND } from '../../lib/types/kind-lookup.js';
let repos = $state([]);
@@ -15,11 +15,45 @@
onMount(async () => {
await nostrClient.initialize();
+ await loadCachedRepos();
await loadRepos();
});
+ async function loadCachedRepos() {
+ try {
+ // Load cached repos (within 15 minutes)
+ const cachedRepos = await getRecentCachedEvents([KIND.REPO_ANNOUNCEMENT], 15 * 60 * 1000, 100);
+
+ if (cachedRepos.length > 0) {
+ // For parameterized replaceable events, get the newest version of each (by pubkey + d tag)
+ const reposByKey = new Map();
+ for (const event of cachedRepos) {
+ const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
+ const key = `${event.pubkey}:${dTag}`;
+ const existing = reposByKey.get(key);
+ if (!existing || event.created_at > existing.created_at) {
+ reposByKey.set(key, event);
+ }
+ }
+
+ const sortedRepos = Array.from(reposByKey.values()).sort((a, b) => b.created_at - a.created_at);
+
+ if (sortedRepos.length > 0) {
+ repos = sortedRepos;
+ loading = false; // Show cached data immediately
+ }
+ }
+ } catch (error) {
+ console.error('Error loading cached repos:', error);
+ }
+ }
+
async function loadRepos() {
- loading = true;
+ // Only show loading spinner if we don't have cached repos
+ const hasCachedRepos = repos.length > 0;
+ if (!hasCachedRepos) {
+ loading = true;
+ }
try {
const relays = relayManager.getProfileReadRelays();
@@ -32,8 +66,17 @@
{ useCache: true, cacheResults: true }
);
- // For parameterized replaceable events, get the newest version of each (by pubkey + d tag)
+ // Merge with existing cached repos
const reposByKey = new Map();
+
+ // Add existing cached repos first
+ for (const repo of repos) {
+ const dTag = repo.tags.find(t => t[0] === 'd')?.[1] || '';
+ const key = `${repo.pubkey}:${dTag}`;
+ reposByKey.set(key, repo);
+ }
+
+ // Add/update with new repos
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${event.pubkey}:${dTag}`;
diff --git a/src/routes/repos/[naddr]/+page.svelte b/src/routes/repos/[naddr]/+page.svelte
index f912024..fc5754e 100644
--- a/src/routes/repos/[naddr]/+page.svelte
+++ b/src/routes/repos/[naddr]/+page.svelte
@@ -7,6 +7,7 @@
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { fetchGitRepo, extractGitUrls, type GitRepoInfo, type GitFile } from '../../../lib/services/content/git-repo-fetcher.js';
+ import FileExplorer from '../../../lib/components/content/FileExplorer.svelte';
import { marked } from 'marked';
import Asciidoctor from 'asciidoctor';
import { KIND } from '../../../lib/types/kind-lookup.js';
@@ -16,12 +17,15 @@
import MarkdownRenderer from '../../../lib/components/content/MarkdownRenderer.svelte';
import { signAndPublish } from '../../../lib/services/nostr/auth-handler.js';
import { sessionManager } from '../../../lib/services/auth/session-manager.js';
- import { cacheEvent } from '../../../lib/services/cache/event-cache.js';
+ import { cacheEvent, getEventsByKind } from '../../../lib/services/cache/event-cache.js';
+ import EventMenu from '../../../lib/components/EventMenu.svelte';
+ import { fetchProfiles } from '../../../lib/services/user-data.js';
let naddr = $derived($page.params.naddr);
let repoEvent = $state(null);
let gitRepo = $state(null);
let loading = $state(true);
+ let loadingGitRepo = $state(false);
let activeTab = $state<'metadata' | 'about' | 'repository' | 'issues' | 'documentation'>('metadata');
let issues = $state([]);
let issueComments = $state
{:else}
{:else if activeTab === 'repository'}
- {#if gitRepo}
+ {#if loadingGitRepo}
+
+
Loading repository data...
+
+ {:else if gitRepo}
@@ -919,9 +1097,7 @@
File Structure
{#if gitRepo.files.length > 0}
-
-
{renderFileTree(getFileTree(gitRepo.files))}
-
+
{:else}
No files found.
@@ -963,7 +1139,7 @@
{#if filteredIssues.length > 0}
- {#each filteredIssues as issue}
+ {#each paginatedIssues as issue}
{@const currentStatus = getCurrentStatus(issue.id)}
{@const isChanging = changingStatus.get(issue.id) || false}
@@ -1013,6 +1189,31 @@
{/if}
{/each}
+
+
+ {#if totalPages > 1}
+
+ {/if}
{:else}
No issues found with status "{statusFilter}".
@@ -1032,10 +1233,10 @@
{#each Array.from(documentationEvents.entries()) as [docNaddr, docEvent]}
@@ -1084,18 +1285,101 @@
border-bottom-color: var(--fog-dark-border, #374151);
}
+ .repo-title-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 1rem;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ }
+
+ .repo-title-row h1 {
+ flex: 1;
+ min-width: 0;
+ }
+
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
+ flex-wrap: wrap;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: thin;
}
:global(.dark) .tabs {
border-bottom-color: var(--fog-dark-border, #374151);
}
+ .tab-button {
+ padding: 0.75rem 1.25rem;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: transparent;
+ color: var(--fog-text-light, #6b7280);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+ min-height: 2.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ :global(.dark) .tab-button {
+ color: var(--fog-dark-text-light, #9ca3af);
+ }
+
+ .tab-button:hover {
+ color: var(--fog-text, #1f2937);
+ background: var(--fog-highlight, #f3f4f6);
+ }
+
+ :global(.dark) .tab-button:hover {
+ color: var(--fog-dark-text, #f9fafb);
+ background: var(--fog-dark-highlight, #475569);
+ }
+
+ .tab-button.active {
+ color: var(--fog-text, #1f2937);
+ border-bottom-color: var(--fog-accent, #64748b);
+ background: var(--fog-highlight, #f3f4f6);
+ font-weight: 600;
+ }
+
+ :global(.dark) .tab-button.active {
+ color: var(--fog-dark-text, #f9fafb);
+ border-bottom-color: var(--fog-dark-accent, #94a3b8);
+ background: var(--fog-dark-highlight, #475569);
+ }
+
+ .tab-button:focus {
+ outline: 2px solid var(--fog-accent, #64748b);
+ outline-offset: 2px;
+ }
+
+ :global(.dark) .tab-button:focus {
+ outline-color: var(--fog-dark-accent, #94a3b8);
+ }
+
+ @media (max-width: 640px) {
+ .tabs {
+ gap: 0.25rem;
+ padding-bottom: 0.5rem;
+ }
+
+ .tab-button {
+ padding: 0.5rem 0.75rem;
+ font-size: 0.8125rem;
+ min-height: 2.5rem;
+ }
+ }
+
.tab-content {
margin-top: 2rem;
@@ -1278,30 +1562,6 @@
color: var(--fog-dark-text-light, #9ca3af);
}
- .file-tree {
- padding: 1rem;
- border: 1px solid var(--fog-border, #e5e7eb);
- border-radius: 0.5rem;
- background: var(--fog-post, #ffffff);
- overflow-x: auto;
- }
-
- :global(.dark) .file-tree {
- border-color: var(--fog-dark-border, #374151);
- background: var(--fog-dark-post, #1f2937);
- }
-
- .file-tree-content {
- margin: 0;
- font-family: monospace;
- font-size: 0.875rem;
- color: var(--fog-text, #1f2937);
- white-space: pre;
- }
-
- :global(.dark) .file-tree-content {
- color: var(--fog-dark-text, #f9fafb);
- }
.issues-filter {
display: flex;
@@ -1740,7 +2000,7 @@
.doc-header {
display: flex;
- justify-content: space-between;
+ justify-content: flex-end;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
@@ -1751,17 +2011,6 @@
border-bottom-color: var(--fog-dark-border, #374151);
}
- .doc-title {
- font-size: 1.25rem;
- font-weight: 600;
- color: var(--fog-text, #1f2937);
- margin: 0;
- }
-
- :global(.dark) .doc-title {
- color: var(--fog-dark-text, #f9fafb);
- }
-
.doc-meta {
display: flex;
gap: 1rem;
@@ -1769,6 +2018,18 @@
font-size: 0.875rem;
}
+ @media (max-width: 640px) {
+ .doc-header {
+ justify-content: flex-start;
+ }
+
+ .doc-meta {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+ }
+
.doc-kind {
color: var(--fog-text-light, #6b7280);
padding: 0.25rem 0.5rem;
@@ -1797,4 +2058,59 @@
.doc-content {
margin-top: 1rem;
}
+
+ .pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ margin-top: 2rem;
+ padding: 1rem;
+ border-top: 1px solid var(--fog-border, #e5e7eb);
+ }
+
+ :global(.dark) .pagination {
+ border-top-color: var(--fog-dark-border, #374151);
+ }
+
+ .pagination-button {
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--fog-border, #e5e7eb);
+ border-radius: 0.375rem;
+ background: var(--fog-post, #ffffff);
+ color: var(--fog-text, #1f2937);
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+
+ .pagination-button:hover:not(:disabled) {
+ background: var(--fog-highlight, #f3f4f6);
+ border-color: var(--fog-accent, #64748b);
+ }
+
+ .pagination-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ :global(.dark) .pagination-button {
+ border-color: var(--fog-dark-border, #374151);
+ background: var(--fog-dark-post, #1f2937);
+ color: var(--fog-dark-text, #f9fafb);
+ }
+
+ :global(.dark) .pagination-button:hover:not(:disabled) {
+ background: var(--fog-dark-highlight, #475569);
+ border-color: var(--fog-dark-accent, #94a3b8);
+ }
+
+ .pagination-info {
+ font-size: 0.875rem;
+ color: var(--fog-text-light, #6b7280);
+ }
+
+ :global(.dark) .pagination-info {
+ color: var(--fog-dark-text-light, #9ca3af);
+ }
diff --git a/src/routes/rss/+page.svelte b/src/routes/rss/+page.svelte
index c60fcfe..08b81fe 100644
--- a/src/routes/rss/+page.svelte
+++ b/src/routes/rss/+page.svelte
@@ -7,12 +7,26 @@
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js';
+ import MarkdownRenderer from '../../lib/components/content/MarkdownRenderer.svelte';
const RSS_FEED_KIND = 10895;
+ interface RSSItem {
+ title: string;
+ link: string;
+ description: string;
+ pubDate: Date;
+ feedUrl: string;
+ feedTitle?: string;
+ }
+
let currentPubkey = $state(null);
let rssEvent = $state(null);
let loading = $state(true);
+ let loadingFeeds = $state(false);
+ let rssItems = $state([]);
+ let feedErrors = $state>(new Map());
+
let subscribedFeeds = $derived.by(() => {
if (!rssEvent) return [];
return rssEvent.tags
@@ -32,6 +46,12 @@
await checkRssEvent();
});
+ $effect(() => {
+ if (subscribedFeeds.length > 0 && rssEvent) {
+ loadRssFeeds();
+ }
+ });
+
async function checkRssEvent() {
if (!currentPubkey) return;
@@ -54,6 +74,149 @@
}
}
+ async function loadRssFeeds() {
+ if (subscribedFeeds.length === 0 || loadingFeeds) return;
+
+ loadingFeeds = true;
+ feedErrors.clear();
+ const allItems: RSSItem[] = [];
+
+ try {
+ // Fetch all feeds in parallel
+ const feedPromises = subscribedFeeds.map(async (feedUrl) => {
+ try {
+ const items = await fetchRssFeed(feedUrl);
+ allItems.push(...items);
+ } catch (error) {
+ console.error(`Error fetching RSS feed ${feedUrl}:`, error);
+ feedErrors.set(feedUrl, error instanceof Error ? error.message : 'Failed to fetch feed');
+ }
+ });
+
+ await Promise.all(feedPromises);
+
+ // Sort by date (newest first)
+ allItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
+
+ rssItems = allItems;
+ } catch (error) {
+ console.error('Error loading RSS feeds:', error);
+ } finally {
+ loadingFeeds = false;
+ }
+ }
+
+ async function fetchRssFeed(feedUrl: string): Promise {
+ // Use a CORS proxy if needed, or fetch directly
+ // For now, try direct fetch first
+ let response: Response;
+ try {
+ response = await fetch(feedUrl, {
+ mode: 'cors',
+ headers: {
+ 'Accept': 'application/rss+xml, application/xml, text/xml, */*'
+ }
+ });
+ } catch (error) {
+ // If CORS fails, try using a proxy
+ const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(feedUrl)}`;
+ response = await fetch(proxyUrl);
+ }
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const xmlText = await response.text();
+ const parser = new DOMParser();
+ const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
+
+ // Check for parsing errors
+ const parserError = xmlDoc.querySelector('parsererror');
+ if (parserError) {
+ throw new Error('Failed to parse RSS XML');
+ }
+
+ // Get feed title
+ const feedTitle = xmlDoc.querySelector('channel > title')?.textContent?.trim() ||
+ xmlDoc.querySelector('title')?.textContent?.trim();
+
+ // Parse items (handle both RSS 2.0 and Atom formats)
+ const items: RSSItem[] = [];
+
+ // RSS 2.0 format
+ const rssItems = xmlDoc.querySelectorAll('item');
+ rssItems.forEach((item) => {
+ const title = item.querySelector('title')?.textContent?.trim() || 'Untitled';
+ const link = item.querySelector('link')?.textContent?.trim() || '';
+ const description = item.querySelector('description')?.textContent?.trim() || '';
+ const pubDateStr = item.querySelector('pubDate')?.textContent?.trim() || '';
+
+ let pubDate = new Date();
+ if (pubDateStr) {
+ const parsed = new Date(pubDateStr);
+ if (!isNaN(parsed.getTime())) {
+ pubDate = parsed;
+ }
+ }
+
+ items.push({
+ title,
+ link,
+ description,
+ pubDate,
+ feedUrl,
+ feedTitle
+ });
+ });
+
+ // Atom format (if no RSS items found)
+ if (items.length === 0) {
+ const atomEntries = xmlDoc.querySelectorAll('entry');
+ atomEntries.forEach((entry) => {
+ const title = entry.querySelector('title')?.textContent?.trim() || 'Untitled';
+ const linkEl = entry.querySelector('link');
+ const link = linkEl?.getAttribute('href') || linkEl?.textContent?.trim() || '';
+ const description = entry.querySelector('summary')?.textContent?.trim() ||
+ entry.querySelector('content')?.textContent?.trim() || '';
+ const pubDateStr = entry.querySelector('published')?.textContent?.trim() ||
+ entry.querySelector('updated')?.textContent?.trim() || '';
+
+ let pubDate = new Date();
+ if (pubDateStr) {
+ const parsed = new Date(pubDateStr);
+ if (!isNaN(parsed.getTime())) {
+ pubDate = parsed;
+ }
+ }
+
+ items.push({
+ title,
+ link,
+ description,
+ pubDate,
+ feedUrl,
+ feedTitle
+ });
+ });
+ }
+
+ return items;
+ }
+
+ function getRelativeTime(date: Date): string {
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const hours = Math.floor(diff / (1000 * 60 * 60));
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+ const minutes = Math.floor(diff / (1000 * 60));
+
+ if (days > 0) return `${days}d ago`;
+ if (hours > 0) return `${hours}h ago`;
+ if (minutes > 0) return `${minutes}m ago`;
+ return 'just now';
+ }
+
function handleCreateRss() {
goto(`/write?kind=${RSS_FEED_KIND}`);
}
@@ -83,39 +246,94 @@
{:else}
-