Browse Source

bug-fixes

master
Silberengel 4 weeks ago
parent
commit
a91bf4d56c
  1. 4
      src/app.css
  2. 36
      src/lib/components/content/FileExplorer.svelte
  3. 2
      src/lib/components/modals/EventJsonModal.svelte
  4. 67
      src/lib/modules/feed/FeedPost.svelte
  5. 24
      src/lib/services/content/git-repo-fetcher.ts
  6. 96
      src/routes/repos/+page.svelte
  7. 54
      src/routes/repos/[naddr]/+page.svelte
  8. 4
      static/healthz.json
  9. 4
      vite.config.ts

4
src/app.css

@ -1,6 +1,6 @@
/* Custom highlight.js theme for code blocks, JSON previews, and markdown */ /* highlight.js VS2015 theme for code blocks, JSON previews, and markdown */
/* @import must come before all other statements */ /* @import must come before all other statements */
@import './lib/styles/highlight-theme.css'; @import 'highlight.js/styles/vs2015.css';
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@tailwind base; @tailwind base;

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

@ -1,8 +1,10 @@
<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';
import { fetchGitHubApi } from '../../services/github-api.js'; import { fetchGitHubApi } from '../../services/github-api.js';
import { browser } from '$app/environment';
// @ts-ignore - highlight.js default export works at runtime // @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
interface Props { interface Props {
@ -322,23 +324,24 @@
// Apply syntax highlighting when file content changes // Apply syntax highlighting when file content changes
$effect(() => { $effect(() => {
if (!browser || !hljs) return; // Only run in browser and if hljs is available
if (fileContent && selectedFile && isCodeFile(selectedFile) && codeRef) { if (fileContent && selectedFile && isCodeFile(selectedFile) && codeRef) {
const language = getLanguageFromExtension(selectedFile); const language = getLanguageFromExtension(selectedFile);
try { try {
// Check if language is supported, fallback to plaintext if not // Check if language is supported, fallback to plaintext if not
if (hljs.getLanguage(language)) { if (hljs.getLanguage && hljs.getLanguage(language)) {
codeRef.innerHTML = hljs.highlight(fileContent, { language }).value; codeRef.innerHTML = hljs.highlight(fileContent, { language }).value;
codeRef.className = `language-${language}`; codeRef.className = `hljs language-${language}`;
} else { } else {
// Language not supported, use plaintext // Language not supported, use plaintext
codeRef.innerHTML = hljs.highlight(fileContent, { language: 'plaintext' }).value; codeRef.innerHTML = hljs.highlight(fileContent, { language: 'plaintext' }).value;
codeRef.className = 'language-plaintext'; codeRef.className = 'hljs language-plaintext';
} }
} catch (error) { } catch (error) {
// If highlighting fails, just display plain text // If highlighting fails, just display plain text
console.warn(`Failed to highlight code with language '${language}':`, error); console.warn(`Failed to highlight code with language '${language}':`, error);
codeRef.textContent = fileContent; codeRef.textContent = fileContent;
codeRef.className = 'language-plaintext'; codeRef.className = 'hljs language-plaintext';
} }
} }
}); });
@ -568,7 +571,7 @@
</div> </div>
{:else if fileContent !== null} {:else if fileContent !== null}
{#if isCodeFile(selectedFile)} {#if isCodeFile(selectedFile)}
<pre class="file-content-code"><code bind:this={codeRef} class="language-{getLanguageFromExtension(selectedFile)}">{fileContent}</code></pre> <pre class="file-content-code"><code bind:this={codeRef} class="hljs language-{getLanguageFromExtension(selectedFile)}">{fileContent}</code></pre>
{:else} {:else}
<pre class="file-content-code"><code>{fileContent}</code></pre> <pre class="file-content-code"><code>{fileContent}</code></pre>
{/if} {/if}
@ -818,28 +821,7 @@
padding: 1rem; padding: 1rem;
} }
.file-content-code { /* Code block styling is handled by highlight.js vs2015 theme */
margin: 0;
background: #000000 !important; /* Pure black background */
border: 1px solid #333333;
border-radius: 4px;
padding: 1rem;
overflow-x: auto;
white-space: pre;
word-wrap: break-word;
}
:global(.dark) .file-content-code {
background: #000000 !important; /* Pure black background */
border-color: #333333;
}
.file-content-code code {
display: block;
overflow-x: auto;
padding: 0;
/* Theme colors are defined in highlight-theme.css */
}
.file-image-container { .file-image-container {
display: flex; display: flex;

2
src/lib/components/modals/EventJsonModal.svelte

@ -292,7 +292,7 @@
display: block; display: block;
padding: 0; padding: 0;
background: transparent !important; background: transparent !important;
/* Colors are defined in highlight-theme.css */ /* Colors are defined by highlight.js vs2015 theme */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;

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

@ -193,15 +193,46 @@
// Parse NIP-21 links and create segments for rendering // Parse NIP-21 links and create segments for rendering
interface ContentSegment { interface ContentSegment {
type: 'text' | 'profile' | 'event' | 'url' | 'wikilink' | 'hashtag' | 'greentext'; type: 'text' | 'profile' | 'event' | 'url' | 'grasp' | 'wikilink' | 'hashtag' | 'greentext';
content: string; // Display text (without nostr: prefix for links) content: string; // Display text (without nostr: prefix for links)
pubkey?: string; // For profile badges pubkey?: string; // For profile badges
eventId?: string; // For event links (bech32 or hex) eventId?: string; // For event links (bech32 or hex)
url?: string; // For regular HTTP/HTTPS URLs url?: string; // For regular HTTP/HTTPS URLs
graspUrl?: string; // For grasp server URLs
wikilink?: string; // For wikilink d-tag wikilink?: string; // For wikilink d-tag
hashtag?: string; // For hashtag topic name hashtag?: string; // For hashtag topic name
} }
// Detect if a URL is a grasp server link (NIP-34)
// Grasp links match: http://<grasp-path>/<valid-npub>/<string>.git or https://<grasp-path>/<valid-npub>/<string>.git
function isGraspLink(url: string): boolean {
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(p => p);
// Check if it's a git clone URL ending in .git
if (pathParts.length >= 2 && pathParts[pathParts.length - 1].endsWith('.git')) {
// Check if the second-to-last part is a valid npub (bech32 encoded pubkey)
const potentialNpub = pathParts[pathParts.length - 2];
// npub format: npub1 followed by bech32 characters
if (/^npub1[a-z0-9]+$/.test(potentialNpub)) {
return true;
}
}
// Also check for websocket URLs (ws:// or wss://) which might be grasp relays
if (urlObj.protocol === 'ws:' || urlObj.protocol === 'wss:') {
// Could be a grasp relay, but we can't definitively identify it without more context
// For now, we'll only detect HTTP/HTTPS clone URLs
}
} catch {
// Invalid URL
return false;
}
return false;
}
// Process text to detect greentext (lines starting with >) // Process text to detect greentext (lines starting with >)
function processGreentext(text: string): ContentSegment[] { function processGreentext(text: string): ContentSegment[] {
const lines = text.split('\n'); const lines = text.split('\n');
@ -465,6 +496,14 @@
content: `#${hashtagMatch.hashtag}`, content: `#${hashtagMatch.hashtag}`,
hashtag: hashtagMatch.hashtag hashtag: hashtagMatch.hashtag
}); });
} else {
// Check if it's a grasp link
if (isGraspLink(match.url)) {
finalSegments.push({
type: 'grasp',
content: match.url,
graspUrl: match.url
});
} else { } else {
finalSegments.push({ finalSegments.push({
type: 'url', type: 'url',
@ -472,6 +511,7 @@
url: match.url url: match.url
}); });
} }
}
textIndex = match.index + match.length; textIndex = match.index + match.length;
} }
@ -1085,11 +1125,30 @@
{:else if segment.type === 'profile' && segment.pubkey} {:else if segment.type === 'profile' && segment.pubkey}
<ProfileBadge pubkey={segment.pubkey} inline={true} /> <ProfileBadge pubkey={segment.pubkey} inline={true} />
{:else if segment.type === 'event' && segment.eventId} {:else if segment.type === 'event' && segment.eventId}
{@const eventUrl = getEventUrl(segment.eventId)}
<a <a
href={getEventUrl(segment.eventId)} href={eventUrl}
target="_blank"
rel="noopener noreferrer"
class="nostr-event-link text-fog-accent dark:text-fog-dark-accent hover:underline" class="nostr-event-link text-fog-accent dark:text-fog-dark-accent hover:underline"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
goto(eventUrl);
}}
>
{segment.content}
</a>
{:else if segment.type === 'grasp' && segment.graspUrl}
<a
href={segment.graspUrl}
class="grasp-link text-fog-accent dark:text-fog-dark-accent hover:underline"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
// For grasp links, we could navigate to a grasp server page or open the git URL
// For now, open in new tab since it's a git clone URL
window.open(segment.graspUrl, '_blank', 'noopener,noreferrer');
}}
title="Grasp server (NIP-34)"
> >
{segment.content} {segment.content}
</a> </a>

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

@ -533,8 +533,12 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
*/ */
async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise<GitRepoInfo | null> { async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise<GitRepoInfo | null> {
try { try {
// URL-encode owner and repo to handle special characters
const encodedOwner = encodeURIComponent(owner);
const encodedRepo = encodeURIComponent(repo);
// Use proxy endpoint to avoid CORS issues // Use proxy endpoint to avoid CORS issues
const repoResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}?baseUrl=${encodeURIComponent(baseUrl)}`); const repoResponse = await fetch(`/api/gitea-proxy/repos/${encodedOwner}/${encodedRepo}?baseUrl=${encodeURIComponent(baseUrl)}`);
if (!repoResponse.ok) { if (!repoResponse.ok) {
console.warn(`Gitea API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`); console.warn(`Gitea API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`);
return null; return null;
@ -544,8 +548,8 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
const defaultBranch = repoData.default_branch || 'master'; const defaultBranch = repoData.default_branch || 'master';
const [branchesResponse, commitsResponse] = await Promise.all([ const [branchesResponse, commitsResponse] = await Promise.all([
fetch(`/api/gitea-proxy/repos/${owner}/${repo}/branches?baseUrl=${encodeURIComponent(baseUrl)}`).catch(() => null), fetch(`/api/gitea-proxy/repos/${encodedOwner}/${encodedRepo}/branches?baseUrl=${encodeURIComponent(baseUrl)}`).catch(() => null),
fetch(`/api/gitea-proxy/repos/${owner}/${repo}/commits?baseUrl=${encodeURIComponent(baseUrl)}&limit=10`).catch(() => null) fetch(`/api/gitea-proxy/repos/${encodedOwner}/${encodedRepo}/commits?baseUrl=${encodeURIComponent(baseUrl)}&limit=10`).catch(() => null)
]); ]);
let branchesData: any[] = []; let branchesData: any[] = [];
@ -596,9 +600,11 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
// Fetch file tree - Gitea uses /git/trees API endpoint // Fetch file tree - Gitea uses /git/trees API endpoint
let files: GitFile[] = []; let files: GitFile[] = [];
// encodedOwner and encodedRepo are already defined at the top of the function
const encodedBranch = encodeURIComponent(defaultBranch);
try { try {
// Try the git/trees endpoint first (more complete) // Try the git/trees endpoint first (more complete)
const treeResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}/git/trees/${defaultBranch}?baseUrl=${encodeURIComponent(baseUrl)}&recursive=1`).catch(() => null); const treeResponse = await fetch(`/api/gitea-proxy/repos/${encodedOwner}/${encodedRepo}/git/trees/${encodedBranch}?baseUrl=${encodeURIComponent(baseUrl)}&recursive=1`).catch(() => null);
if (treeResponse && treeResponse.ok) { if (treeResponse && treeResponse.ok) {
const treeData = await treeResponse.json(); const treeData = await treeResponse.json();
if (treeData.tree && Array.isArray(treeData.tree)) { if (treeData.tree && Array.isArray(treeData.tree)) {
@ -613,7 +619,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
} }
} else { } else {
// Fallback to contents endpoint (only root directory) // Fallback to contents endpoint (only root directory)
const contentsResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}/contents?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`).catch(() => null); const contentsResponse = await fetch(`/api/gitea-proxy/repos/${encodedOwner}/${encodedRepo}/contents?baseUrl=${encodeURIComponent(baseUrl)}&ref=${encodedBranch}`).catch(() => null);
if (contentsResponse && contentsResponse.ok) { if (contentsResponse && contentsResponse.ok) {
const contentsData = await contentsResponse.json(); const contentsData = await contentsResponse.json();
if (Array.isArray(contentsData)) { if (Array.isArray(contentsData)) {
@ -634,9 +640,11 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
// First try root directory (most common case) // 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'];
// encodedOwner and encodedRepo are already defined at the top of the function
for (const readmeFile of readmeFiles) { for (const readmeFile of readmeFiles) {
try { try {
const fileResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}/contents/${readmeFile}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`); const encodedReadmeFile = encodeURIComponent(readmeFile);
const fileResponse = await fetch(`/api/gitea-proxy/repos/${encodedOwner}/${encodedRepo}/contents/${encodedReadmeFile}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${encodeURIComponent(defaultBranch)}`);
if (!fileResponse.ok) throw new Error('Not found'); if (!fileResponse.ok) throw new Error('Not found');
const fileData = await fileResponse.json(); const fileData = await fileResponse.json();
if (fileData.content) { if (fileData.content) {
@ -675,7 +683,9 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
// If found in tree, fetch it // If found in tree, fetch it
if (readmePath) { if (readmePath) {
try { try {
const fileResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}/contents/${readmePath}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`); // URL-encode the file path segments
const encodedReadmePath = readmePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
const fileResponse = await fetch(`/api/gitea-proxy/repos/${encodedOwner}/${encodedRepo}/contents/${encodedReadmePath}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${encodedBranch}`);
if (!fileResponse.ok) throw new Error('Not found'); if (!fileResponse.ok) throw new Error('Not found');
const fileData = await fileResponse.json(); const fileData = await fileResponse.json();
if (fileData.content) { if (fileData.content) {

96
src/routes/repos/+page.svelte

@ -345,9 +345,44 @@
return null; return null;
} }
// Filter repos based on search query and filters // Search repos from cache when there's a search query
let filteredRepos = $derived.by(() => { let searchRepos = $state<NostrEvent[]>([]);
let filtered = repos; let searchingCache = $state(false);
// When search query changes, search the cache directly
$effect(() => {
const query = searchQuery.trim();
if (!query) {
searchRepos = [];
searchingCache = false;
return;
}
// Search cache directly for matching repos
searchingCache = true;
searchCacheForRepos(query).then(results => {
searchRepos = results;
searchingCache = false;
});
});
async function searchCacheForRepos(query: string): Promise<NostrEvent[]> {
try {
// Get all cached repos (no limit for search)
const cachedRepos = await getRecentCachedEvents([KIND.REPO_ANNOUNCEMENT], 365 * 24 * 60 * 60 * 1000, 10000);
// For parameterized replaceable events, get the newest version of each (by pubkey + d tag)
const reposByKey = new Map<string, NostrEvent>();
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);
}
}
let filtered = Array.from(reposByKey.values());
// Filter by "See my repos" checkbox // Filter by "See my repos" checkbox
if (showMyRepos && currentPubkey) { if (showMyRepos && currentPubkey) {
@ -362,15 +397,10 @@
}); });
} }
// If no search query, return filtered list const queryLower = query.toLowerCase();
if (!searchQuery.trim()) {
return filtered;
}
const query = searchQuery.trim().toLowerCase();
// Try to decode as pubkey (hex, npub, or nprofile) // Try to decode as pubkey (hex, npub, or nprofile)
const decodedPubkey = decodePubkeyToHex(query); const decodedPubkey = decodePubkeyToHex(queryLower);
if (decodedPubkey) { if (decodedPubkey) {
return filtered.filter(repo => { return filtered.filter(repo => {
// Match by owner pubkey // Match by owner pubkey
@ -384,7 +414,7 @@
} }
// Try to decode as event ID (hex, note, nevent, or naddr) // Try to decode as event ID (hex, note, nevent, or naddr)
const decodedEventId = decodeEventIdToHex(query); const decodedEventId = decodeEventIdToHex(queryLower);
if (decodedEventId) { if (decodedEventId) {
return filtered.filter(repo => { return filtered.filter(repo => {
// Match by event ID // Match by event ID
@ -404,34 +434,64 @@
return filtered.filter(repo => { return filtered.filter(repo => {
// Search in name // Search in name
const name = getRepoName(repo).toLowerCase(); const name = getRepoName(repo).toLowerCase();
if (name.includes(query)) return true; if (name.includes(queryLower)) return true;
// Search in description // Search in description
const desc = getRepoDescription(repo).toLowerCase(); const desc = getRepoDescription(repo).toLowerCase();
if (desc.includes(query)) return true; if (desc.includes(queryLower)) return true;
// Search in clone URLs // Search in clone URLs
const cloneUrls = getCloneUrls(repo); const cloneUrls = getCloneUrls(repo);
if (cloneUrls.some(url => url.toLowerCase().includes(query))) return true; if (cloneUrls.some(url => url.toLowerCase().includes(queryLower))) return true;
// Search in web URLs // Search in web URLs
const webUrls = getWebUrls(repo); const webUrls = getWebUrls(repo);
if (webUrls.some(url => url.toLowerCase().includes(query))) return true; if (webUrls.some(url => url.toLowerCase().includes(queryLower))) return true;
// Search in d-tag // Search in d-tag
const dTag = getDTagFromEvent(repo).toLowerCase(); const dTag = getDTagFromEvent(repo).toLowerCase();
if (dTag.includes(query)) return true; if (dTag.includes(queryLower)) return true;
// Search in naddr // Search in naddr
const naddr = getNaddr(repo); const naddr = getNaddr(repo);
if (naddr && naddr.toLowerCase().includes(query)) return true; if (naddr && naddr.toLowerCase().includes(queryLower)) return true;
// Search in maintainer pubkeys (as hex) // Search in maintainer pubkeys (as hex)
const maintainers = getMaintainers(repo); const maintainers = getMaintainers(repo);
if (maintainers.some(m => m.toLowerCase().includes(query))) return true; if (maintainers.some(m => m.toLowerCase().includes(queryLower))) return true;
return false; return false;
}); });
} catch (error) {
console.error('Error searching cache for repos:', error);
return [];
}
}
// Filter repos based on search query and filters
let filteredRepos = $derived.by(() => {
// If there's a search query, use search results from cache
if (searchQuery.trim()) {
return searchRepos;
}
// Otherwise, filter the loaded repos
let filtered = repos;
// Filter by "See my repos" checkbox
if (showMyRepos && currentPubkey) {
filtered = filtered.filter(repo => {
// Check if repo owner matches
if (repo.pubkey.toLowerCase() === currentPubkey.toLowerCase()) {
return true;
}
// Check if user is a maintainer
const maintainers = getMaintainers(repo);
return maintainers.some(m => m.toLowerCase() === currentPubkey.toLowerCase());
});
}
return filtered;
}); });
</script> </script>

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

@ -33,6 +33,8 @@
let issues = $state<NostrEvent[]>([]); let issues = $state<NostrEvent[]>([]);
let issueComments = $state<Map<string, NostrEvent[]>>(new Map()); let issueComments = $state<Map<string, NostrEvent[]>>(new Map());
let issueStatuses = $state<Map<string, NostrEvent>>(new Map()); let issueStatuses = $state<Map<string, NostrEvent>>(new Map());
let loadingIssues = $state(false);
let loadingIssueData = $state(false); // Statuses, comments, profiles
let documentationEvents = $state<Map<string, NostrEvent>>(new Map()); let documentationEvents = $state<Map<string, NostrEvent>>(new Map());
let changingStatus = $state<Map<string, boolean>>(new Map()); // Track which issues are having status changed let changingStatus = $state<Map<string, boolean>>(new Map()); // Track which issues are having status changed
let statusFilter = $state<string | null>(null); // Filter issues by status: null = all, 'open', 'resolved', 'closed', 'draft' let statusFilter = $state<string | null>(null); // Filter issues by status: null = all, 'open', 'resolved', 'closed', 'draft'
@ -115,11 +117,9 @@
break; // Success, stop trying other URLs break; // Success, stop trying other URLs
} }
} catch (error) { } catch (error) {
// Failed to fetch git repo // Failed to fetch git repo - continue to next URL
// Continue to next URL
} }
} }
} else {
} }
} catch (error) { } catch (error) {
// Failed to load git repo // Failed to load git repo
@ -279,6 +279,7 @@
async function loadIssues() { async function loadIssues() {
if (!repoEvent) return; if (!repoEvent) return;
loadingIssues = true;
try { try {
const gitUrls = extractGitUrls(repoEvent); const gitUrls = extractGitUrls(repoEvent);
const relays = relayManager.getProfileReadRelays(); const relays = relayManager.getProfileReadRelays();
@ -301,10 +302,16 @@
filters.push({ '#r': gitUrls, kinds: [KIND.ISSUE], limit: 100 }); filters.push({ '#r': gitUrls, kinds: [KIND.ISSUE], limit: 100 });
} }
// Batch fetch all issues in parallel // Search for issues by the repo author (issues might be created by repo maintainers)
filters.push({ authors: [repoEvent.pubkey], kinds: [KIND.ISSUE], limit: 100 });
// Batch fetch all issues in parallel with cache-first strategy
const issueEventsArrays = await Promise.all( const issueEventsArrays = await Promise.all(
filters.map(filter => filters.map(filter =>
nostrClient.fetchEvents([filter], relays, { useCache: true, cacheResults: true }) nostrClient.fetchEvents([filter], relays, {
useCache: 'cache-first', // Prioritize cache for faster loading
cacheResults: true
})
) )
); );
@ -317,16 +324,24 @@
// Deduplicate and sort // Deduplicate and sort
const uniqueIssues = Array.from(new Map(issueEvents.map(e => [e.id, e])).values()); const uniqueIssues = Array.from(new Map(issueEvents.map(e => [e.id, e])).values());
issues = uniqueIssues.sort((a, b) => b.created_at - a.created_at); issues = uniqueIssues.sort((a, b) => b.created_at - a.created_at);
loadingIssues = false; // Issues are loaded, show them immediately
// Load statuses, comments, and profiles in background (don't wait)
// Batch load statuses, comments, and profiles // This allows the UI to show issues immediately
await Promise.all([ loadingIssueData = true;
Promise.all([
loadIssueStatuses(), loadIssueStatuses(),
loadIssueComments(), loadIssueComments(),
loadAllProfiles() loadAllProfiles()
]); ]).finally(() => {
loadingIssueData = false;
}).catch(() => {
// Background loading errors are non-critical
loadingIssueData = false;
});
} catch (error) { } catch (error) {
// Failed to load issues // Failed to load issues
loadingIssues = false;
} }
} }
@ -346,7 +361,7 @@
limit: 200 limit: 200
}], }],
relays, relays,
{ useCache: true, cacheResults: true } { useCache: 'cache-first', cacheResults: true } // Prioritize cache
); );
@ -551,10 +566,11 @@
const relays = relayManager.getCommentReadRelays(); const relays = relayManager.getCommentReadRelays();
// Batch fetch all comments for all issues // Batch fetch all comments for all issues
// Use cache-first to load comments faster
const comments = await nostrClient.fetchEvents( const comments = await nostrClient.fetchEvents(
[{ '#e': issueIds, kinds: [KIND.COMMENT], limit: 500 }], [{ '#e': issueIds, kinds: [KIND.COMMENT], limit: 500 }],
relays, relays,
{ useCache: true, cacheResults: true } { useCache: 'cache-first', cacheResults: true } // Prioritize cache
); );
// Group comments by issue ID // Group comments by issue ID
@ -1287,7 +1303,11 @@
</div> </div>
{:else if activeTab === 'issues'} {:else if activeTab === 'issues'}
<div class="issues-tab"> <div class="issues-tab">
{#if issues.length > 0} {#if loadingIssues}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading issues...</p>
</div>
{:else if issues.length > 0}
<div class="issues-filter"> <div class="issues-filter">
<label for="status-filter" class="filter-label">Filter by status:</label> <label for="status-filter" class="filter-label">Filter by status:</label>
<select <select
@ -1310,6 +1330,9 @@
{:else} {:else}
{issues.length} {issues.length === 1 ? 'issue' : 'issues'} {issues.length} {issues.length === 1 ? 'issue' : 'issues'}
{/if} {/if}
{#if loadingIssueData}
<span class="loading-indicator"> (loading details...)</span>
{/if}
</span> </span>
</div> </div>
<div class="issues-list"> <div class="issues-list">
@ -1601,17 +1624,22 @@
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-family: monospace; font-family: monospace;
color: var(--fog-text, #1f2937);
} }
:global(.dark) .readme-container :global(code) { :global(.dark) .readme-container :global(code) {
background: var(--fog-dark-highlight, #475569); background: var(--fog-dark-highlight, #475569);
color: var(--fog-dark-text, #f9fafb);
} }
/* Code blocks with highlight.js are styled by vs2015 theme */
/* Pre blocks for non-highlighted code */
.readme-container :global(pre) { .readme-container :global(pre) {
background: var(--fog-highlight, #f3f4f6);
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow-x: auto; overflow-x: auto;
/* Background will be overridden by vs2015 theme for hljs code blocks */
background: var(--fog-highlight, #f3f4f6);
} }
:global(.dark) .readme-container :global(pre) { :global(.dark) .readme-container :global(pre) {

4
static/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.3.2", "version": "0.3.2",
"buildTime": "2026-02-14T08:45:25.090Z", "buildTime": "2026-02-14T09:16:17.577Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1771058725091 "timestamp": 1771060577577
} }

4
vite.config.ts

@ -173,8 +173,8 @@ export default defineConfig({
// Suppress warning about highlight.js default import - it's used in reactive contexts that Vite can't detect // Suppress warning about highlight.js default import - it's used in reactive contexts that Vite can't detect
if (warning.message && if (warning.message &&
typeof warning.message === 'string' && typeof warning.message === 'string' &&
warning.message.includes('highlight.js') && (warning.message.includes('highlight.js') || warning.message.includes('"default" is imported')) &&
warning.message.includes('never used')) { (warning.message.includes('never used') || warning.message.includes('but never used'))) {
return; return;
} }
warn(warning); warn(warning);

Loading…
Cancel
Save