diff --git a/src/lib/components/UserBadge.svelte b/src/lib/components/UserBadge.svelte index 3c79207..3b934d2 100644 --- a/src/lib/components/UserBadge.svelte +++ b/src/lib/components/UserBadge.svelte @@ -3,6 +3,7 @@ import { NostrClient } from '../services/nostr/nostr-client.js'; import { DEFAULT_NOSTR_RELAYS } from '../config.js'; import { KIND } from '../types/nostr.js'; + import { eventCache } from '../services/nostr/event-cache.js'; import { nip19 } from 'nostr-tools'; interface Props { @@ -22,7 +23,23 @@ async function loadUserProfile() { try { - // Fetch user profile (kind 0 - metadata) + // Check cache first for faster lookups + const cachedProfile = eventCache.getProfile(pubkey); + if (cachedProfile) { + try { + const profile = JSON.parse(cachedProfile.content); + userProfile = { + name: profile.name, + picture: profile.picture + }; + loading = false; + return; + } catch { + // Invalid JSON in cache, continue to fetch fresh + } + } + + // Fetch user profile (kind 0 - metadata) if not in cache const profileEvents = await nostrClient.fetchEvents([ { kinds: [0], diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index b41bbcd..db82d12 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -400,6 +400,112 @@ export class RepoManager { return existsSync(repoPath); } + /** + * Fetch repository on-demand from remote clone URLs + * This allows displaying repositories that haven't been provisioned yet + * + * @param npub - Repository owner npub + * @param repoName - Repository name + * @param announcementEvent - The Nostr repo announcement event (optional, will fetch if not provided) + * @returns true if repository was successfully fetched, false otherwise + */ + async fetchRepoOnDemand( + npub: string, + repoName: string, + announcementEvent?: NostrEvent + ): Promise { + const repoPath = join(this.repoRoot, npub, `${repoName}.git`); + + // If repo already exists, no need to fetch + if (existsSync(repoPath)) { + return true; + } + + // If no announcement provided, we can't fetch (caller should provide it) + if (!announcementEvent) { + return false; + } + + try { + // Extract clone URLs from announcement + const cloneUrls = this.extractCloneUrls(announcementEvent); + + // Filter out localhost URLs and our own domain (we want external sources) + const externalUrls = cloneUrls.filter(url => { + const lowerUrl = url.toLowerCase(); + return !lowerUrl.includes('localhost') && + !lowerUrl.includes('127.0.0.1') && + !url.includes(this.domain); + }); + + // If no external URLs, try any URL that's not our domain + const remoteUrls = externalUrls.length > 0 ? externalUrls : + cloneUrls.filter(url => !url.includes(this.domain)); + + if (remoteUrls.length === 0) { + logger.warn({ npub, repoName }, 'No remote clone URLs found for on-demand fetch'); + return false; + } + + // Create directory structure + const repoDir = join(this.repoRoot, npub); + if (!existsSync(repoDir)) { + mkdirSync(repoDir, { recursive: true }); + } + + // Try to clone from the first available remote URL + // Use simple-git for safer cloning + const git = simpleGit(); + const gitEnv = this.getGitEnvForUrl(remoteUrls[0]); + + logger.info({ npub, repoName, sourceUrl: remoteUrls[0] }, 'Fetching repository on-demand from remote'); + + // Clone as bare repository + // Use gitEnv which already contains necessary whitelisted environment variables + await new Promise((resolve, reject) => { + const cloneProcess = spawn('git', ['clone', '--bare', remoteUrls[0], repoPath], { + env: gitEnv, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stderr = ''; + cloneProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + cloneProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Git clone failed with code ${code}: ${stderr}`)); + } + }); + + cloneProcess.on('error', reject); + }); + + // Verify the repository was actually created + if (!existsSync(repoPath)) { + throw new Error('Repository clone completed but repository path does not exist'); + } + + // Create verification file with the announcement (non-blocking - repo is usable without it) + try { + await this.createVerificationFile(repoPath, announcementEvent); + } catch (verifyError) { + // Verification file creation is optional - log but don't fail + logger.warn({ error: verifyError, npub, repoName }, 'Failed to create verification file, but repository is usable'); + } + + logger.info({ npub, repoName }, 'Successfully fetched repository on-demand'); + return true; + } catch (error) { + const sanitizedError = sanitizeError(error); + logger.error({ error: sanitizedError, npub, repoName }, 'Failed to fetch repository on-demand'); + return false; + } + } + /** * Get repository size in bytes * Returns the total size of the repository directory diff --git a/src/lib/services/nostr/event-cache.ts b/src/lib/services/nostr/event-cache.ts index 11f7df0..9f2f472 100644 --- a/src/lib/services/nostr/event-cache.ts +++ b/src/lib/services/nostr/event-cache.ts @@ -66,6 +66,8 @@ export class EventCache { private cache: Map = new Map(); private defaultTTL: number = 5 * 60 * 1000; // 5 minutes default private maxCacheSize: number = 10000; // Maximum number of cache entries + // Special TTL for kind 0 (profile) events - longer since profiles don't change often + private profileEventTTL: number = 30 * 60 * 1000; // 30 minutes for profile events constructor(defaultTTL?: number, maxCacheSize?: number) { if (defaultTTL) { @@ -115,9 +117,33 @@ export class EventCache { this.evictOldest(); } + // Check if this is a kind 0 (profile) event query + const isProfileQuery = filters.some(f => + f.kinds && f.kinds.includes(0) && f.authors && f.authors.length > 0 + ); + + // For kind 0 events, use longer TTL and ensure we only cache the latest per pubkey + let processedEvents = events; + if (isProfileQuery) { + // For replaceable events (kind 0), only keep the latest event per pubkey + const latestByPubkey = new Map(); + for (const event of events) { + const existing = latestByPubkey.get(event.pubkey); + if (!existing || event.created_at > existing.created_at) { + latestByPubkey.set(event.pubkey, event); + } + } + processedEvents = Array.from(latestByPubkey.values()); + + // Use longer TTL for profile events + if (!ttl) { + ttl = this.profileEventTTL; + } + } + const key = generateMultiFilterCacheKey(filters); this.cache.set(key, { - events, + events: processedEvents, timestamp: Date.now(), ttl: ttl || this.defaultTTL }); @@ -158,6 +184,51 @@ export class EventCache { } } + /** + * Get the latest kind 0 (profile) event for a specific pubkey + * This is optimized for profile lookups + */ + getProfile(pubkey: string): NostrEvent | null { + const filters: NostrFilter[] = [ + { + kinds: [0], + authors: [pubkey], + limit: 1 + } + ]; + + const cached = this.get(filters); + if (cached && cached.length > 0) { + // Return the most recent profile event + return cached.sort((a, b) => b.created_at - a.created_at)[0]; + } + + return null; + } + + /** + * Cache a profile event (kind 0) for a specific pubkey + */ + setProfile(pubkey: string, event: NostrEvent): void { + const filters: NostrFilter[] = [ + { + kinds: [0], + authors: [pubkey], + limit: 1 + } + ]; + + // Check if we already have a cached profile for this pubkey + const existing = this.getProfile(pubkey); + if (existing && existing.created_at >= event.created_at) { + // Existing profile is newer or same, don't overwrite + return; + } + + // Cache the new profile event + this.set(filters, [event], this.profileEventTTL); + } + /** * Clear all cache entries */ diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 60e0407..7ecfb8c 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -222,12 +222,19 @@ export class NostrClient { const finalEvents = Array.from(uniqueEvents.values()); + // For kind 0 (profile) events, also cache individually by pubkey for faster lookups + const profileEvents = finalEvents.filter(e => e.kind === 0); + for (const profileEvent of profileEvents) { + eventCache.setProfile(profileEvent.pubkey, profileEvent); + } + // Cache the results (use longer TTL for successful fetches) if (finalEvents.length > 0 || results.some(r => r.status === 'fulfilled')) { // Cache successful fetches for 5 minutes, empty results for 1 minute + // Profile events get longer TTL (handled in eventCache.set) const ttl = finalEvents.length > 0 ? 5 * 60 * 1000 : 60 * 1000; eventCache.set(filters, finalEvents, ttl); - logger.debug({ filters, eventCount: finalEvents.length, ttl }, 'Cached events'); + logger.debug({ filters, eventCount: finalEvents.length, ttl, profileEvents: profileEvents.length }, 'Cached events'); } return finalEvents; diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index d17dd9b..da71739 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -2,20 +2,84 @@ * API endpoint for getting and creating repository branches */ -import { json } from '@sveltejs/kit'; +import { json, error } from '@sveltejs/kit'; // @ts-ignore - SvelteKit generates this type import type { RequestHandler } from './$types'; -import { fileManager } from '$lib/services/service-registry.js'; +import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js'; import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; -import { handleValidationError } from '$lib/utils/error-handler.js'; +import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js'; +import { KIND } from '$lib/types/nostr.js'; +import { join } from 'path'; +import { existsSync } from 'fs'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + + // If repo doesn't exist, try to fetch it on-demand + if (!existsSync(repoPath)) { + try { + // Fetch repository announcement from Nostr + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [context.repoOwnerPubkey], + '#d': [context.repo], + limit: 1 + } + ]); + + if (events.length > 0) { + // Try to fetch the repository from remote clone URLs + const fetched = await repoManager.fetchRepoOnDemand( + context.npub, + context.repo, + events[0] + ); + + if (!fetched) { + // Check if repo exists now (might have been created by another request) + if (existsSync(repoPath)) { + // Repo was created, continue + } else { + throw handleApiError( + new Error('Repository not found and could not be fetched from remote. The repository may not have any accessible clone URLs.'), + { operation: 'getBranches', npub: context.npub, repo: context.repo }, + 'Repository not found and could not be fetched from remote' + ); + } + } + } else { + throw handleApiError( + new Error('Repository announcement not found in Nostr'), + { operation: 'getBranches', npub: context.npub, repo: context.repo }, + 'Repository announcement not found' + ); + } + } catch (err) { + // Check if repo was created by another concurrent request + if (existsSync(repoPath)) { + // Repo exists now, continue with normal flow + } else { + // If fetching fails, return 404 + throw handleApiError( + err instanceof Error ? err : new Error('Failed to fetch repository'), + { operation: 'getBranches', npub: context.npub, repo: context.repo }, + 'Repository not found' + ); + } + } + } + const branches = await fileManager.getBranches(context.npub, context.repo); return json(branches); }, - { operation: 'getBranches', requireRepoAccess: false } // Branches are public info + { operation: 'getBranches', requireRepoExists: false, requireRepoAccess: false } // Branches are public info, handle on-demand fetching ); export const POST: RequestHandler = createRepoPostHandler( diff --git a/src/routes/api/repos/[npub]/[repo]/readme/+server.ts b/src/routes/api/repos/[npub]/[repo]/readme/+server.ts index dd07f6b..cef9713 100644 --- a/src/routes/api/repos/[npub]/[repo]/readme/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/readme/+server.ts @@ -4,9 +4,17 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { fileManager } from '$lib/services/service-registry.js'; +import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext } from '$lib/utils/api-context.js'; +import { handleApiError } from '$lib/utils/error-handler.js'; +import { KIND } from '$lib/types/nostr.js'; +import { join } from 'path'; +import { existsSync } from 'fs'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; const README_PATTERNS = [ 'README.md', @@ -21,6 +29,43 @@ const README_PATTERNS = [ export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + + // If repo doesn't exist, try to fetch it on-demand + if (!existsSync(repoPath)) { + try { + // Fetch repository announcement from Nostr + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [context.repoOwnerPubkey], + '#d': [context.repo], + limit: 1 + } + ]); + + if (events.length > 0) { + // Try to fetch the repository from remote clone URLs + const fetched = await repoManager.fetchRepoOnDemand( + context.npub, + context.repo, + events[0] + ); + + if (!fetched) { + // If fetch fails, return not found (readme endpoint is non-critical) + return json({ found: false }); + } + } else { + // No announcement found, return not found + return json({ found: false }); + } + } catch (err) { + // If fetching fails, return not found (readme is optional) + return json({ found: false }); + } + } + const ref = context.ref || 'HEAD'; // Try to find README file @@ -58,5 +103,5 @@ export const GET: RequestHandler = createRepoGetHandler( isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown') }); }, - { operation: 'getReadme' } + { operation: 'getReadme', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, readme is public ); diff --git a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts index 91ac96e..2596037 100644 --- a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts @@ -4,17 +4,82 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { fileManager } from '$lib/services/service-registry.js'; +import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; +import { handleApiError } from '$lib/utils/error-handler.js'; +import { KIND } from '$lib/types/nostr.js'; +import { join } from 'path'; +import { existsSync } from 'fs'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + + // If repo doesn't exist, try to fetch it on-demand + if (!existsSync(repoPath)) { + try { + // Fetch repository announcement from Nostr + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [context.repoOwnerPubkey], + '#d': [context.repo], + limit: 1 + } + ]); + + if (events.length > 0) { + // Try to fetch the repository from remote clone URLs + const fetched = await repoManager.fetchRepoOnDemand( + context.npub, + context.repo, + events[0] + ); + + if (!fetched) { + // Check if repo exists now (might have been created by another request) + if (existsSync(repoPath)) { + // Repo was created, continue + } else { + throw handleApiError( + new Error('Repository not found and could not be fetched from remote. The repository may not have any accessible clone URLs.'), + { operation: 'listFiles', npub: context.npub, repo: context.repo }, + 'Repository not found and could not be fetched from remote' + ); + } + } + } else { + throw handleApiError( + new Error('Repository announcement not found in Nostr'), + { operation: 'listFiles', npub: context.npub, repo: context.repo }, + 'Repository announcement not found' + ); + } + } catch (err) { + // Check if repo was created by another concurrent request + if (existsSync(repoPath)) { + // Repo exists now, continue with normal flow + } else { + // If fetching fails, return 404 + throw handleApiError( + err instanceof Error ? err : new Error('Failed to fetch repository'), + { operation: 'listFiles', npub: context.npub, repo: context.repo }, + 'Repository not found' + ); + } + } + } + const ref = context.ref || 'HEAD'; const path = context.path || ''; const files = await fileManager.listFiles(context.npub, context.repo, ref, path); return json(files); }, - { operation: 'listFiles' } + { operation: 'listFiles', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, tree is public ); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 4e4ad06..01485b8 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -4,14 +4,16 @@ import { goto } from '$app/navigation'; import CodeEditor from '$lib/components/CodeEditor.svelte'; import PRDetail from '$lib/components/PRDetail.svelte'; + import UserBadge from '$lib/components/UserBadge.svelte'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; + import { KIND } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; - // Get page data for OpenGraph metadata - const pageData = $page.data as { + // Get page data for OpenGraph metadata - use $derived to make it reactive + const pageData = $derived($page.data as { title?: string; description?: string; image?: string; @@ -19,7 +21,14 @@ repoName?: string; repoDescription?: string; repoUrl?: string; - }; + repoCloneUrls?: string[]; + repoMaintainers?: string[]; + repoOwnerPubkey?: string; + repoLanguage?: string; + repoTopics?: string[]; + repoWebsite?: string; + repoIsPrivate?: boolean; + }); const npub = ($page.params as { npub?: string; repo?: string }).npub || ''; const repo = ($page.params as { npub?: string; repo?: string }).repo || ''; @@ -40,7 +49,7 @@ let commitMessage = $state(''); let userPubkey = $state(null); let showCommitDialog = $state(false); - let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs'>('files'); + let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs'>('files'); // Navigation stack for directories let pathStack = $state([]); @@ -96,6 +105,11 @@ let newPRLabels = $state(['']); let selectedPR = $state(null); + // Documentation + let documentationContent = $state(null); + let documentationHtml = $state(null); + let loadingDocs = $state(false); + // README let readmeContent = $state(null); let readmePath = $state(null); @@ -365,14 +379,65 @@ } } + async function loadDocumentation() { + if (loadingDocs || documentationContent !== null) return; + + loadingDocs = true; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + const repoOwnerPubkey = decoded.data as string; + const client = new NostrClient(DEFAULT_NOSTR_RELAYS); + const events = await client.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length > 0) { + documentationContent = events[0].content || null; + + // Render as markdown if content exists + if (documentationContent) { + const MarkdownIt = (await import('markdown-it')).default; + const hljsModule = await import('highlight.js'); + const hljs = hljsModule.default || hljsModule; + + const md = new MarkdownIt({ + highlight: function (str: string, lang: string): string { + if (lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(str, { language: lang }).value; + } catch (__) {} + } + return ''; + } + }); + + documentationHtml = md.render(documentationContent); + } + } + } + } catch (err) { + console.error('Error loading documentation:', err); + } finally { + loadingDocs = false; + } + } + async function loadRepoImages() { try { // Get images from page data (loaded from announcement) if (pageData.image) { repoImage = pageData.image; + console.log('[Repo Images] Loaded image from pageData:', repoImage); } if (pageData.banner) { repoBanner = pageData.banner; + console.log('[Repo Images] Loaded banner from pageData:', repoBanner); } // Also fetch from announcement directly as fallback @@ -397,13 +462,21 @@ if (imageTag?.[1]) { repoImage = imageTag[1]; + console.log('[Repo Images] Loaded image from announcement:', repoImage); } if (bannerTag?.[1]) { repoBanner = bannerTag[1]; + console.log('[Repo Images] Loaded banner from announcement:', repoBanner); } + } else { + console.log('[Repo Images] No announcement found'); } } } + + if (!repoImage && !repoBanner) { + console.log('[Repo Images] No images found in announcement'); + } } catch (err) { console.error('Error loading repo images:', err); } @@ -1062,6 +1135,8 @@ loadIssues(); } else if (activeTab === 'prs') { loadPRs(); + } else if (activeTab === 'docs') { + loadDocumentation(); } }); @@ -1104,69 +1179,137 @@
{#if repoBanner}
- + { + console.error('[Repo Images] Failed to load banner:', repoBanner); + const target = e.target as HTMLImageElement; + if (target) target.style.display = 'none'; + }} />
{/if} -
-
- {#if repoImage} - - {/if} -
-

{pageData.repoName || repo}

- {#if pageData.repoDescription} -

{pageData.repoDescription}

+
+
+
+ {#if repoImage} + { + console.error('[Repo Images] Failed to load image:', repoImage); + const target = e.target as HTMLImageElement; + if (target) target.style.display = 'none'; + }} /> {/if} +
+

{pageData.repoName || repo}

+ {#if pageData.repoDescription} +

{pageData.repoDescription}

+ {:else} +

No description

+ {/if} +
-
- - by {npub.slice(0, 16)}... - - 📖 - {#if forkInfo?.isFork && forkInfo.originalRepo} - Forked from {forkInfo.originalRepo.repo} - {/if} -
-
- - {#if userPubkey} - - {#if isMaintainer} - Settings +
+ {#if pageData.repoLanguage} + + + {pageData.repoLanguage} + + {/if} + {#if pageData.repoIsPrivate} + Private + {:else} + Public {/if} - {#if isMaintainer} - + {#if forkInfo?.isFork && forkInfo.originalRepo} + Forked from {forkInfo.originalRepo.repo} {/if} - +
+ {#if pageData.repoOwnerPubkey || (pageData.repoMaintainers && pageData.repoMaintainers.length > 0)} +
+ Contributors: +
+ {#if pageData.repoOwnerPubkey} + + + Owner + + {/if} + {#if pageData.repoMaintainers} + {#each pageData.repoMaintainers.filter(m => m !== pageData.repoOwnerPubkey) as maintainerPubkey} + + + Maintainer + + {/each} + {/if} +
+
+ {/if} + {#if pageData.repoTopics && pageData.repoTopics.length > 0} +
+ {#each pageData.repoTopics as topic} + {topic} + {/each} +
+ {/if} + {#if pageData.repoWebsite} + + {/if} + {#if pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0} +
+ Clone: + {#each pageData.repoCloneUrls.slice(0, 3) as cloneUrl} + {cloneUrl} + {/each} + {#if pageData.repoCloneUrls.length > 3} + +{pageData.repoCloneUrls.length - 3} more + {/if} +
+ {/if} +
+ {#if userPubkey} + {#if isMaintainer} - ✓ Maintainer - {:else} - ✓ Authenticated (Contributor) + Settings {/if} - - - {:else} - Not authenticated - + {#if isMaintainer} + + {/if} + + Verified + {#if isMaintainer} + Maintainer + {:else} + Authenticated (Contributor) + {/if} + + {/if} +
+
+
+ {#if branches.length > 0} + {/if} - {#if verificationStatus} {#if verificationStatus.verified} - ✓ Verified + Verified + Verified {:else} - ⚠ Unverified + Unverified + Unverified {/if} {/if} +
@@ -1214,6 +1357,13 @@ > Pull Requests +
@@ -1239,9 +1389,9 @@
  • {#if userPubkey && isMaintainer && file.type === 'file'} - + {/if}
  • {/each} @@ -1816,53 +1968,187 @@ header { display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 2rem; + flex-direction: column; border-bottom: 1px solid var(--border-color); background: var(--card-bg); } + + .header-content { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + padding: 2rem 2rem 1.5rem 2rem; + gap: 2rem; + margin-top: 1rem; + } + + .header-main { + display: flex; + flex-direction: column; + flex: 1; + gap: 1rem; + min-width: 0; + position: relative; + } + + .header-actions-bottom { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: auto; + align-self: flex-end; + } .repo-banner { width: 100%; - height: 300px; + height: 200px; overflow: hidden; background: var(--bg-secondary); - margin-bottom: 1rem; + margin-bottom: 0; + position: relative; + } + + .repo-banner::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + to bottom, + transparent 0%, + transparent 60%, + var(--card-bg) 100% + ); + z-index: 1; + pointer-events: none; + } + + .repo-banner::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + to right, + transparent 0%, + transparent 85%, + var(--card-bg) 100% + ); + z-index: 1; + pointer-events: none; } .repo-banner img { width: 100%; height: 100%; object-fit: cover; - } - - .header-left { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; + display: block; } .repo-title-section { display: flex; - align-items: center; + align-items: flex-start; gap: 1rem; margin-bottom: 0.5rem; + width: 100%; + } + + .repo-title-text { + flex: 1; + min-width: 0; /* Allow text to shrink */ + } + + .repo-title-text h1 { + margin: 0; + word-wrap: break-word; } .repo-image { - width: 64px; - height: 64px; - border-radius: 8px; + width: 80px; + height: 80px; + border-radius: 50%; object-fit: cover; flex-shrink: 0; + display: block; + background: var(--bg-secondary); + border: 3px solid var(--card-bg); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + /* Position repo image over banner if banner exists */ + header:has(.repo-banner) .header-content { + margin-top: -30px; /* Overlap banner slightly */ + position: relative; + z-index: 2; + padding-left: 2.5rem; /* Extra padding on left to create space from banner edge */ + } + + header:has(.repo-banner) .repo-image { + border-color: var(--card-bg); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); + } + + /* Responsive design for smaller screens */ + @media (max-width: 768px) { + .header-content { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .header-actions { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } + + .repo-banner { + height: 150px; + } + + header:has(.repo-banner) .header-content { + margin-top: -30px; + } + + .repo-image { + width: 64px; + height: 64px; + } + + .repo-title-text h1 { + font-size: 1.5rem; + } + } + + .repo-image[src=""], + .repo-image:not([src]) { + display: none; + } + + .repo-banner img[src=""], + .repo-banner img:not([src]) { + display: none; } .repo-description-header { margin: 0.25rem 0 0 0; - color: var(--text-secondary); + color: var(--text-primary); font-size: 0.9rem; + line-height: 1.4; + max-width: 100%; + word-wrap: break-word; + } + + .repo-description-placeholder { + color: var(--text-muted); + font-style: italic; } .fork-badge { @@ -1883,81 +2169,216 @@ text-decoration: underline; } - - header h1 { - margin: 0; - font-size: 1.5rem; - color: var(--text-primary); + .repo-meta-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-top: 0.5rem; } - .npub { + .repo-language { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; color: var(--text-muted); + } + + .repo-privacy-badge { + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + } + + .repo-privacy-badge.private { + background: var(--error-bg); + color: var(--error-text); + } + + .repo-privacy-badge.public { + background: var(--success-bg); + color: var(--success-text); + } + + .repo-topics { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; + } + + .topic-tag { + padding: 0.25rem 0.5rem; + background: var(--accent-light); + color: var(--accent); + border-radius: 0.25rem; + font-size: 0.75rem; + } + + .repo-website { + margin-top: 0.5rem; font-size: 0.875rem; } - .docs-link { + .repo-website a { color: var(--link-color); text-decoration: none; - font-size: 1.25rem; - margin-left: 0.5rem; - transition: color 0.2s ease; + display: inline-flex; + align-items: center; + gap: 0.25rem; } - .docs-link:hover { - color: var(--link-hover); + .repo-website a:hover { + text-decoration: underline; } - .header-right { + .repo-clone-urls { + margin-top: 0.5rem; display: flex; + flex-wrap: wrap; align-items: center; - gap: 1rem; + gap: 0.5rem; + font-size: 0.75rem; } - .branch-select { - padding: 0.5rem; - border: 1px solid var(--input-border); + .clone-label { + color: var(--text-muted); + font-weight: 500; + } + + .clone-url { + padding: 0.125rem 0.375rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); border-radius: 0.25rem; - background: var(--input-bg); + font-family: 'IBM Plex Mono', monospace; + font-size: 0.75rem; color: var(--text-primary); - font-family: 'IBM Plex Serif', serif; } - .auth-status { + .clone-more { + color: var(--text-muted); + font-size: 0.75rem; + } + + .repo-contributors { + margin-top: 0.75rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + } + + .contributors-label { font-size: 0.875rem; color: var(--text-muted); + font-weight: 500; } - .login-button, - .logout-button { - padding: 0.5rem 1rem; - border: 1px solid var(--input-border); + .contributors-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + } + + .contributor-item { + display: inline-flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + padding: 0.25rem 0.5rem; + border-radius: 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + transition: all 0.2s ease; + } + + .contributor-item:hover { + border-color: var(--accent); + background: var(--card-bg); + } + + .contributor-badge { + padding: 0.25rem 0.5rem; border-radius: 0.25rem; - background: var(--button-primary); - color: white; - cursor: pointer; - font-size: 0.875rem; - font-family: 'IBM Plex Serif', serif; - transition: background 0.2s ease; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; + letter-spacing: 0.05em; + border: 1px solid transparent; + /* Ensure minimum size for touch targets */ + min-height: 1.5rem; + display: inline-flex; + align-items: center; + justify-content: center; } - .login-button:hover:not(:disabled) { - background: var(--button-primary-hover); + .contributor-badge.owner { + /* High contrast colors that work in both light and dark modes */ + background: #4a5568; + color: #ffffff; + border-color: #2d3748; } - .login-button:disabled { - opacity: 0.5; - cursor: not-allowed; + /* Dark mode adjustments for owner badge */ + @media (prefers-color-scheme: dark) { + .contributor-badge.owner { + background: #718096; + color: #ffffff; + border-color: #a0aec0; + } } - .logout-button { - background: var(--error-text); - color: white; - border-color: var(--error-text); - margin-left: 0.5rem; + .contributor-badge.maintainer { + /* High contrast colors that work in both light and dark modes */ + background: #22543d; + color: #ffffff; + border-color: #1a202c; } - .logout-button:hover { - opacity: 0.9; + /* Dark mode adjustments for maintainer badge */ + @media (prefers-color-scheme: dark) { + .contributor-badge.maintainer { + background: #48bb78; + color: #1a202c; + border-color: #68d391; + } + } + + header h1 { + margin: 0; + font-size: 1.5rem; + color: var(--text-primary); + } + .header-actions { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; + flex-wrap: wrap; + } + + .branch-select { + padding: 0.5rem; + border: 1px solid var(--input-border); + border-radius: 0.25rem; + background: var(--input-bg); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + } + + .auth-status { + font-size: 0.875rem; + color: var(--text-primary); + display: inline-flex; + align-items: center; + gap: 0.25rem; } .repo-view { @@ -2684,4 +3105,49 @@ background: var(--error-bg); color: var(--error-text); } + + .icon-inline { + width: 1em; + height: 1em; + vertical-align: middle; + display: inline-block; + margin-right: 0.25rem; + /* Make icons visible on dark backgrounds by inverting to light */ + filter: brightness(0) saturate(100%) invert(1); + } + + .icon-small { + width: 16px; + height: 16px; + vertical-align: middle; + /* Make icons visible on dark backgrounds by inverting to light */ + filter: brightness(0) saturate(100%) invert(1); + } + + /* Theme-aware icon colors */ + .auth-status .icon-inline { + filter: brightness(0) saturate(100%) invert(1); + opacity: 0.8; + } + + .verification-status.verified .icon-inline { + /* Green checkmark for verified */ + filter: brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%); + } + + .verification-status.unverified .icon-inline { + /* Orange/yellow warning for unverified */ + filter: brightness(0) saturate(100%) invert(67%) sepia(93%) saturate(1352%) hue-rotate(358deg) brightness(102%) contrast(106%); + } + + .file-button .icon-inline { + filter: brightness(0) saturate(100%) invert(1); + opacity: 0.7; + } + + .delete-file-button .icon-small { + /* Red for delete button */ + filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(346deg) brightness(104%) contrast(97%); + } + diff --git a/src/routes/repos/[npub]/[repo]/+page.ts b/src/routes/repos/[npub]/[repo]/+page.ts index 568b456..e62d0d9 100644 --- a/src/routes/repos/[npub]/[repo]/+page.ts +++ b/src/routes/repos/[npub]/[repo]/+page.ts @@ -53,6 +53,34 @@ export const load: PageLoad = async ({ params, url, parent }) => { const description = announcement.tags.find(t => t[0] === 'description')?.[1] || ''; const image = announcement.tags.find(t => t[0] === 'image')?.[1]; const banner = announcement.tags.find(t => t[0] === 'banner')?.[1]; + + // Debug: log image and banner tags if found + if (image) console.log('[Page Load] Found image tag:', image); + if (banner) console.log('[Page Load] Found banner tag:', banner); + if (!image && !banner) { + console.log('[Page Load] No image or banner tags found. Available tags:', + announcement.tags.filter(t => t[0] === 'image' || t[0] === 'banner').map(t => t[0])); + } + const cloneUrls = announcement.tags + .filter(t => t[0] === 'clone') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string') as string[]; + const maintainers = announcement.tags + .filter(t => t[0] === 'maintainers') + .flatMap(t => t.slice(1)) + .filter(m => m && typeof m === 'string') as string[]; + // Owner is the author of the announcement event + const ownerPubkey = announcement.pubkey; + const language = announcement.tags.find(t => t[0] === 'language')?.[1]; + const topics = announcement.tags + .filter(t => t[0] === 't' && t[1] !== 'private') + .map(t => t[1]) + .filter(t => t && typeof t === 'string') as string[]; + const website = announcement.tags.find(t => t[0] === 'website')?.[1]; + const isPrivate = announcement.tags.some(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); // Get git domain for constructing URLs const layoutData = await parent(); @@ -68,6 +96,13 @@ export const load: PageLoad = async ({ params, url, parent }) => { repoName: name, repoDescription: description, repoUrl, + repoCloneUrls: cloneUrls, + repoMaintainers: maintainers, + repoOwnerPubkey: ownerPubkey, + repoLanguage: language, + repoTopics: topics, + repoWebsite: website, + repoIsPrivate: isPrivate, ogType: 'website' }; } catch (error) {