Browse Source

more bug-fixes

added profile cache
main
Silberengel 4 weeks ago
parent
commit
a5a5a1f02f
  1. 19
      src/lib/components/UserBadge.svelte
  2. 106
      src/lib/services/git/repo-manager.ts
  3. 73
      src/lib/services/nostr/event-cache.ts
  4. 9
      src/lib/services/nostr/nostr-client.ts
  5. 72
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  6. 49
      src/routes/api/repos/[npub]/[repo]/readme/+server.ts
  7. 69
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  8. 642
      src/routes/repos/[npub]/[repo]/+page.svelte
  9. 35
      src/routes/repos/[npub]/[repo]/+page.ts

19
src/lib/components/UserBadge.svelte

@ -3,6 +3,7 @@
import { NostrClient } from '../services/nostr/nostr-client.js'; import { NostrClient } from '../services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '../config.js'; import { DEFAULT_NOSTR_RELAYS } from '../config.js';
import { KIND } from '../types/nostr.js'; import { KIND } from '../types/nostr.js';
import { eventCache } from '../services/nostr/event-cache.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
interface Props { interface Props {
@ -22,7 +23,23 @@
async function loadUserProfile() { async function loadUserProfile() {
try { 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([ const profileEvents = await nostrClient.fetchEvents([
{ {
kinds: [0], kinds: [0],

106
src/lib/services/git/repo-manager.ts

@ -400,6 +400,112 @@ export class RepoManager {
return existsSync(repoPath); 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<boolean> {
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<void>((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 * Get repository size in bytes
* Returns the total size of the repository directory * Returns the total size of the repository directory

73
src/lib/services/nostr/event-cache.ts

@ -66,6 +66,8 @@ export class EventCache {
private cache: Map<string, CacheEntry> = new Map(); private cache: Map<string, CacheEntry> = new Map();
private defaultTTL: number = 5 * 60 * 1000; // 5 minutes default private defaultTTL: number = 5 * 60 * 1000; // 5 minutes default
private maxCacheSize: number = 10000; // Maximum number of cache entries 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) { constructor(defaultTTL?: number, maxCacheSize?: number) {
if (defaultTTL) { if (defaultTTL) {
@ -115,9 +117,33 @@ export class EventCache {
this.evictOldest(); 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<string, NostrEvent>();
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); const key = generateMultiFilterCacheKey(filters);
this.cache.set(key, { this.cache.set(key, {
events, events: processedEvents,
timestamp: Date.now(), timestamp: Date.now(),
ttl: ttl || this.defaultTTL 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 * Clear all cache entries
*/ */

9
src/lib/services/nostr/nostr-client.ts

@ -222,12 +222,19 @@ export class NostrClient {
const finalEvents = Array.from(uniqueEvents.values()); 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) // Cache the results (use longer TTL for successful fetches)
if (finalEvents.length > 0 || results.some(r => r.status === 'fulfilled')) { if (finalEvents.length > 0 || results.some(r => r.status === 'fulfilled')) {
// Cache successful fetches for 5 minutes, empty results for 1 minute // 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; const ttl = finalEvents.length > 0 ? 5 * 60 * 1000 : 60 * 1000;
eventCache.set(filters, finalEvents, ttl); 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; return finalEvents;

72
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -2,20 +2,84 @@
* API endpoint for getting and creating repository branches * 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 // @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types'; 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 { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.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( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => { 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); const branches = await fileManager.getBranches(context.npub, context.repo);
return json(branches); 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( export const POST: RequestHandler = createRepoPostHandler(

49
src/routes/api/repos/[npub]/[repo]/readme/+server.ts

@ -4,9 +4,17 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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 { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.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 = [ const README_PATTERNS = [
'README.md', 'README.md',
@ -21,6 +29,43 @@ const README_PATTERNS = [
export const GET: RequestHandler = createRepoGetHandler( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => { 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'; const ref = context.ref || 'HEAD';
// Try to find README file // Try to find README file
@ -58,5 +103,5 @@ export const GET: RequestHandler = createRepoGetHandler(
isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown') isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown')
}); });
}, },
{ operation: 'getReadme' } { operation: 'getReadme', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, readme is public
); );

69
src/routes/api/repos/[npub]/[repo]/tree/+server.ts

@ -4,17 +4,82 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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 { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.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( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => { 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 ref = context.ref || 'HEAD';
const path = context.path || ''; const path = context.path || '';
const files = await fileManager.listFiles(context.npub, context.repo, ref, path); const files = await fileManager.listFiles(context.npub, context.repo, ref, path);
return json(files); return json(files);
}, },
{ operation: 'listFiles' } { operation: 'listFiles', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, tree is public
); );

642
src/routes/repos/[npub]/[repo]/+page.svelte

@ -4,14 +4,16 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import CodeEditor from '$lib/components/CodeEditor.svelte'; import CodeEditor from '$lib/components/CodeEditor.svelte';
import PRDetail from '$lib/components/PRDetail.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 { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
// Get page data for OpenGraph metadata // Get page data for OpenGraph metadata - use $derived to make it reactive
const pageData = $page.data as { const pageData = $derived($page.data as {
title?: string; title?: string;
description?: string; description?: string;
image?: string; image?: string;
@ -19,7 +21,14 @@
repoName?: string; repoName?: string;
repoDescription?: string; repoDescription?: string;
repoUrl?: 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 npub = ($page.params as { npub?: string; repo?: string }).npub || '';
const repo = ($page.params as { npub?: string; repo?: string }).repo || ''; const repo = ($page.params as { npub?: string; repo?: string }).repo || '';
@ -40,7 +49,7 @@
let commitMessage = $state(''); let commitMessage = $state('');
let userPubkey = $state<string | null>(null); let userPubkey = $state<string | null>(null);
let showCommitDialog = $state(false); 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 // Navigation stack for directories
let pathStack = $state<string[]>([]); let pathStack = $state<string[]>([]);
@ -96,6 +105,11 @@
let newPRLabels = $state<string[]>(['']); let newPRLabels = $state<string[]>(['']);
let selectedPR = $state<string | null>(null); let selectedPR = $state<string | null>(null);
// Documentation
let documentationContent = $state<string | null>(null);
let documentationHtml = $state<string | null>(null);
let loadingDocs = $state(false);
// README // README
let readmeContent = $state<string | null>(null); let readmeContent = $state<string | null>(null);
let readmePath = $state<string | null>(null); let readmePath = $state<string | null>(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() { async function loadRepoImages() {
try { try {
// Get images from page data (loaded from announcement) // Get images from page data (loaded from announcement)
if (pageData.image) { if (pageData.image) {
repoImage = pageData.image; repoImage = pageData.image;
console.log('[Repo Images] Loaded image from pageData:', repoImage);
} }
if (pageData.banner) { if (pageData.banner) {
repoBanner = pageData.banner; repoBanner = pageData.banner;
console.log('[Repo Images] Loaded banner from pageData:', repoBanner);
} }
// Also fetch from announcement directly as fallback // Also fetch from announcement directly as fallback
@ -397,12 +462,20 @@
if (imageTag?.[1]) { if (imageTag?.[1]) {
repoImage = imageTag[1]; repoImage = imageTag[1];
console.log('[Repo Images] Loaded image from announcement:', repoImage);
} }
if (bannerTag?.[1]) { if (bannerTag?.[1]) {
repoBanner = 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) { } catch (err) {
console.error('Error loading repo images:', err); console.error('Error loading repo images:', err);
@ -1062,6 +1135,8 @@
loadIssues(); loadIssues();
} else if (activeTab === 'prs') { } else if (activeTab === 'prs') {
loadPRs(); loadPRs();
} else if (activeTab === 'docs') {
loadDocumentation();
} }
}); });
@ -1104,35 +1179,96 @@
<header> <header>
{#if repoBanner} {#if repoBanner}
<div class="repo-banner"> <div class="repo-banner">
<img src={repoBanner} alt="" /> <img src={repoBanner} alt="" onerror={(e) => {
console.error('[Repo Images] Failed to load banner:', repoBanner);
const target = e.target as HTMLImageElement;
if (target) target.style.display = 'none';
}} />
</div> </div>
{/if} {/if}
<div class="header-left"> <div class="header-content">
<div class="header-main">
<div class="repo-title-section"> <div class="repo-title-section">
{#if repoImage} {#if repoImage}
<img src={repoImage} alt="" class="repo-image" /> <img src={repoImage} alt="" class="repo-image" onerror={(e) => {
console.error('[Repo Images] Failed to load image:', repoImage);
const target = e.target as HTMLImageElement;
if (target) target.style.display = 'none';
}} />
{/if} {/if}
<div> <div class="repo-title-text">
<h1>{pageData.repoName || repo}</h1> <h1>{pageData.repoName || repo}</h1>
{#if pageData.repoDescription} {#if pageData.repoDescription}
<p class="repo-description-header">{pageData.repoDescription}</p> <p class="repo-description-header">{pageData.repoDescription}</p>
{:else}
<p class="repo-description-header repo-description-placeholder">No description</p>
{/if} {/if}
</div> </div>
</div> </div>
<span class="npub"> <div class="repo-meta-info">
by <a href={`/users/${npub}`}>{npub.slice(0, 16)}...</a> {#if pageData.repoLanguage}
<span class="repo-language">
<img src="/icons/file-text.svg" alt="" class="icon-inline" />
{pageData.repoLanguage}
</span> </span>
<a href="/docs" class="docs-link" target="_blank" title="Documentation">📖</a> {/if}
{#if pageData.repoIsPrivate}
<span class="repo-privacy-badge private">Private</span>
{:else}
<span class="repo-privacy-badge public">Public</span>
{/if}
{#if forkInfo?.isFork && forkInfo.originalRepo} {#if forkInfo?.isFork && forkInfo.originalRepo}
<span class="fork-badge">Forked from <a href={`/repos/${forkInfo.originalRepo.npub}/${forkInfo.originalRepo.repo}`}>{forkInfo.originalRepo.repo}</a></span> <span class="fork-badge">Forked from <a href={`/repos/${forkInfo.originalRepo.npub}/${forkInfo.originalRepo.repo}`}>{forkInfo.originalRepo.repo}</a></span>
{/if} {/if}
</div> </div>
<div class="header-right"> {#if pageData.repoOwnerPubkey || (pageData.repoMaintainers && pageData.repoMaintainers.length > 0)}
<select bind:value={currentBranch} onchange={handleBranchChange} class="branch-select"> <div class="repo-contributors">
{#each branches as branch} <span class="contributors-label">Contributors:</span>
<option value={branch}>{branch}</option> <div class="contributors-list">
{#if pageData.repoOwnerPubkey}
<a href={`/users/${npub}`} class="contributor-item">
<UserBadge pubkey={pageData.repoOwnerPubkey} />
<span class="contributor-badge owner">Owner</span>
</a>
{/if}
{#if pageData.repoMaintainers}
{#each pageData.repoMaintainers.filter(m => m !== pageData.repoOwnerPubkey) as maintainerPubkey}
<a href={`/users/${nip19.npubEncode(maintainerPubkey)}`} class="contributor-item">
<UserBadge pubkey={maintainerPubkey} />
<span class="contributor-badge maintainer">Maintainer</span>
</a>
{/each} {/each}
</select> {/if}
</div>
</div>
{/if}
{#if pageData.repoTopics && pageData.repoTopics.length > 0}
<div class="repo-topics">
{#each pageData.repoTopics as topic}
<span class="topic-tag">{topic}</span>
{/each}
</div>
{/if}
{#if pageData.repoWebsite}
<div class="repo-website">
<a href={pageData.repoWebsite} target="_blank" rel="noopener noreferrer">
<img src="/icons/external-link.svg" alt="" class="icon-inline" />
{pageData.repoWebsite}
</a>
</div>
{/if}
{#if pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0}
<div class="repo-clone-urls">
<span class="clone-label">Clone:</span>
{#each pageData.repoCloneUrls.slice(0, 3) as cloneUrl}
<code class="clone-url">{cloneUrl}</code>
{/each}
{#if pageData.repoCloneUrls.length > 3}
<span class="clone-more">+{pageData.repoCloneUrls.length - 3} more</span>
{/if}
</div>
{/if}
<div class="header-actions-bottom">
{#if userPubkey} {#if userPubkey}
<button onclick={forkRepository} disabled={forking} class="fork-button"> <button onclick={forkRepository} disabled={forking} class="fork-button">
{forking ? 'Forking...' : 'Fork'} {forking ? 'Forking...' : 'Fork'}
@ -1144,30 +1280,37 @@
<button onclick={() => showCreateBranchDialog = true} class="create-branch-button">+ New Branch</button> <button onclick={() => showCreateBranchDialog = true} class="create-branch-button">+ New Branch</button>
{/if} {/if}
<span class="auth-status"> <span class="auth-status">
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" />
{#if isMaintainer} {#if isMaintainer}
Maintainer Maintainer
{:else} {:else}
Authenticated (Contributor) Authenticated (Contributor)
{/if} {/if}
</span> </span>
<button onclick={logout} class="logout-button">Logout</button>
{:else}
<span class="auth-status">Not authenticated</span>
<button onclick={login} class="login-button" disabled={!isNIP07Available()}>
{isNIP07Available() ? 'Login' : 'NIP-07 Not Available'}
</button>
{/if} {/if}
</div>
</div>
<div class="header-actions">
{#if branches.length > 0}
<select bind:value={currentBranch} onchange={handleBranchChange} class="branch-select">
{#each branches as branch}
<option value={branch}>{branch}</option>
{/each}
</select>
{/if}
{#if verificationStatus} {#if verificationStatus}
<span class="verification-status" class:verified={verificationStatus.verified} class:unverified={!verificationStatus.verified}> <span class="verification-status" class:verified={verificationStatus.verified} class:unverified={!verificationStatus.verified}>
{#if verificationStatus.verified} {#if verificationStatus.verified}
✓ Verified <img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" />
Verified
{:else} {:else}
⚠ Unverified <img src="/icons/alert-triangle.svg" alt="Unverified" class="icon-inline" />
Unverified
{/if} {/if}
</span> </span>
{/if} {/if}
</div> </div>
</div>
</header> </header>
<main class="repo-view"> <main class="repo-view">
@ -1214,6 +1357,13 @@
> >
Pull Requests Pull Requests
</button> </button>
<button
class="tab-button"
class:active={activeTab === 'docs'}
onclick={() => activeTab = 'docs'}
>
Documentation
</button>
</div> </div>
<div class="repo-layout"> <div class="repo-layout">
@ -1239,9 +1389,9 @@
<li class="file-item" class:directory={file.type === 'directory'} class:selected={currentFile === file.path}> <li class="file-item" class:directory={file.type === 'directory'} class:selected={currentFile === file.path}>
<button onclick={() => handleFileClick(file)} class="file-button"> <button onclick={() => handleFileClick(file)} class="file-button">
{#if file.type === 'directory'} {#if file.type === 'directory'}
📁 <img src="/icons/package.svg" alt="Directory" class="icon-inline" />
{:else} {:else}
📄 <img src="/icons/file-text.svg" alt="File" class="icon-inline" />
{/if} {/if}
{file.name} {file.name}
{#if file.size !== undefined} {#if file.size !== undefined}
@ -1249,7 +1399,9 @@
{/if} {/if}
</button> </button>
{#if userPubkey && isMaintainer && file.type === 'file'} {#if userPubkey && isMaintainer && file.type === 'file'}
<button onclick={() => deleteFile(file.path)} class="delete-file-button" title="Delete file">🗑</button> <button onclick={() => deleteFile(file.path)} class="delete-file-button" title="Delete file">
<img src="/icons/x.svg" alt="Delete" class="icon-small" />
</button>
{/if} {/if}
</li> </li>
{/each} {/each}
@ -1816,53 +1968,187 @@
header { header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
background: var(--card-bg); 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 { .repo-banner {
width: 100%; width: 100%;
height: 300px; height: 200px;
overflow: hidden; overflow: hidden;
background: var(--bg-secondary); 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 { .repo-banner img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block;
} }
.header-left { .repo-title-section {
display: flex; display: flex;
flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 1rem;
margin-bottom: 0.5rem;
width: 100%;
} }
.repo-title-section { .repo-title-text {
display: flex; flex: 1;
align-items: center; min-width: 0; /* Allow text to shrink */
}
.repo-title-text h1 {
margin: 0;
word-wrap: break-word;
}
.repo-image {
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; gap: 1rem;
margin-bottom: 0.5rem; }
.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 { .repo-image {
width: 64px; width: 64px;
height: 64px; height: 64px;
border-radius: 8px; }
object-fit: cover;
flex-shrink: 0; .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 { .repo-description-header {
margin: 0.25rem 0 0 0; margin: 0.25rem 0 0 0;
color: var(--text-secondary); color: var(--text-primary);
font-size: 0.9rem; 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 { .fork-badge {
@ -1883,81 +2169,216 @@
text-decoration: underline; text-decoration: underline;
} }
.repo-meta-info {
header h1 { display: flex;
margin: 0; flex-wrap: wrap;
font-size: 1.5rem; align-items: center;
color: var(--text-primary); 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); 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; font-size: 0.875rem;
} }
.docs-link { .repo-website a {
color: var(--link-color); color: var(--link-color);
text-decoration: none; text-decoration: none;
font-size: 1.25rem; display: inline-flex;
margin-left: 0.5rem; align-items: center;
transition: color 0.2s ease; gap: 0.25rem;
} }
.docs-link:hover { .repo-website a:hover {
color: var(--link-hover); text-decoration: underline;
} }
.header-right { .repo-clone-urls {
margin-top: 0.5rem;
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 1rem; gap: 0.5rem;
font-size: 0.75rem;
} }
.branch-select { .clone-label {
padding: 0.5rem; color: var(--text-muted);
border: 1px solid var(--input-border); font-weight: 500;
}
.clone-url {
padding: 0.125rem 0.375rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--input-bg); font-family: 'IBM Plex Mono', monospace;
font-size: 0.75rem;
color: var(--text-primary); 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; font-size: 0.875rem;
color: var(--text-muted); color: var(--text-muted);
font-weight: 500;
} }
.login-button, .contributors-list {
.logout-button { display: flex;
padding: 0.5rem 1rem; flex-wrap: wrap;
border: 1px solid var(--input-border); 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; border-radius: 0.25rem;
background: var(--button-primary); font-size: 0.7rem;
color: white; font-weight: 700;
cursor: pointer; text-transform: uppercase;
font-size: 0.875rem; white-space: nowrap;
font-family: 'IBM Plex Serif', serif; letter-spacing: 0.05em;
transition: background 0.2s ease; 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) { .contributor-badge.owner {
background: var(--button-primary-hover); /* High contrast colors that work in both light and dark modes */
background: #4a5568;
color: #ffffff;
border-color: #2d3748;
} }
.login-button:disabled { /* Dark mode adjustments for owner badge */
opacity: 0.5; @media (prefers-color-scheme: dark) {
cursor: not-allowed; .contributor-badge.owner {
background: #718096;
color: #ffffff;
border-color: #a0aec0;
}
} }
.logout-button { .contributor-badge.maintainer {
background: var(--error-text); /* High contrast colors that work in both light and dark modes */
color: white; background: #22543d;
border-color: var(--error-text); color: #ffffff;
margin-left: 0.5rem; border-color: #1a202c;
} }
.logout-button:hover { /* Dark mode adjustments for maintainer badge */
opacity: 0.9; @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 { .repo-view {
@ -2684,4 +3105,49 @@
background: var(--error-bg); background: var(--error-bg);
color: var(--error-text); 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%);
}
</style> </style>

35
src/routes/repos/[npub]/[repo]/+page.ts

@ -54,6 +54,34 @@ export const load: PageLoad = async ({ params, url, parent }) => {
const image = announcement.tags.find(t => t[0] === 'image')?.[1]; const image = announcement.tags.find(t => t[0] === 'image')?.[1];
const banner = announcement.tags.find(t => t[0] === 'banner')?.[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 // Get git domain for constructing URLs
const layoutData = await parent(); const layoutData = await parent();
const gitDomain = (layoutData as { gitDomain?: string }).gitDomain || url.host || 'localhost:6543'; const gitDomain = (layoutData as { gitDomain?: string }).gitDomain || url.host || 'localhost:6543';
@ -68,6 +96,13 @@ export const load: PageLoad = async ({ params, url, parent }) => {
repoName: name, repoName: name,
repoDescription: description, repoDescription: description,
repoUrl, repoUrl,
repoCloneUrls: cloneUrls,
repoMaintainers: maintainers,
repoOwnerPubkey: ownerPubkey,
repoLanguage: language,
repoTopics: topics,
repoWebsite: website,
repoIsPrivate: isPrivate,
ogType: 'website' ogType: 'website'
}; };
} catch (error) { } catch (error) {

Loading…
Cancel
Save