12 changed files with 2253 additions and 25 deletions
@ -0,0 +1,110 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for downloading repository as ZIP |
||||||
|
*/ |
||||||
|
|
||||||
|
import { error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { FileManager } from '$lib/services/git/file-manager.js'; |
||||||
|
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { exec } from 'child_process'; |
||||||
|
import { promisify } from 'util'; |
||||||
|
import { existsSync, readFileSync } from 'fs'; |
||||||
|
import { join } from 'path'; |
||||||
|
import { createReadStream } from 'fs'; |
||||||
|
|
||||||
|
const execAsync = promisify(exec); |
||||||
|
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
||||||
|
const fileManager = new FileManager(repoRoot); |
||||||
|
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, url, request }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
const ref = url.searchParams.get('ref') || 'HEAD'; |
||||||
|
const format = url.searchParams.get('format') || 'zip'; // zip or tar.gz
|
||||||
|
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
if (!fileManager.repoExists(npub, repo)) { |
||||||
|
return error(404, 'Repository not found'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check repository privacy
|
||||||
|
let repoOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
repoOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); |
||||||
|
if (!canView) { |
||||||
|
return error(403, 'This repository is private. Only owners and maintainers can view it.'); |
||||||
|
} |
||||||
|
|
||||||
|
const repoPath = join(repoRoot, npub, `${repo}.git`); |
||||||
|
const tempDir = join(repoRoot, '..', 'temp-downloads'); |
||||||
|
const workDir = join(tempDir, `${npub}-${repo}-${Date.now()}`); |
||||||
|
const archiveName = `${repo}-${ref}.${format === 'tar.gz' ? 'tar.gz' : 'zip'}`; |
||||||
|
const archivePath = join(tempDir, archiveName); |
||||||
|
|
||||||
|
try { |
||||||
|
// Create temp directory
|
||||||
|
await execAsync(`mkdir -p "${tempDir}"`); |
||||||
|
await execAsync(`mkdir -p "${workDir}"`); |
||||||
|
|
||||||
|
// Clone repository to temp directory
|
||||||
|
await execAsync(`git clone "${repoPath}" "${workDir}"`); |
||||||
|
|
||||||
|
// Checkout specific ref if not HEAD
|
||||||
|
if (ref !== 'HEAD') { |
||||||
|
await execAsync(`cd "${workDir}" && git checkout "${ref}"`); |
||||||
|
} |
||||||
|
|
||||||
|
// Remove .git directory
|
||||||
|
await execAsync(`rm -rf "${workDir}/.git"`); |
||||||
|
|
||||||
|
// Create archive
|
||||||
|
if (format === 'tar.gz') { |
||||||
|
await execAsync(`cd "${tempDir}" && tar -czf "${archiveName}" -C "${workDir}" .`); |
||||||
|
} else { |
||||||
|
// Use zip command (requires zip utility)
|
||||||
|
await execAsync(`cd "${workDir}" && zip -r "${archivePath}" .`); |
||||||
|
} |
||||||
|
|
||||||
|
// Read archive file
|
||||||
|
const archiveBuffer = readFileSync(archivePath); |
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await execAsync(`rm -rf "${workDir}"`); |
||||||
|
await execAsync(`rm -f "${archivePath}"`); |
||||||
|
|
||||||
|
// Return archive
|
||||||
|
return new Response(archiveBuffer, { |
||||||
|
headers: { |
||||||
|
'Content-Type': format === 'tar.gz' ? 'application/gzip' : 'application/zip', |
||||||
|
'Content-Disposition': `attachment; filename="${archiveName}"`, |
||||||
|
'Content-Length': archiveBuffer.length.toString() |
||||||
|
} |
||||||
|
}); |
||||||
|
} catch (archiveError) { |
||||||
|
// Clean up on error
|
||||||
|
await execAsync(`rm -rf "${workDir}"`).catch(() => {}); |
||||||
|
await execAsync(`rm -f "${archivePath}"`).catch(() => {}); |
||||||
|
throw archiveError; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Error creating repository archive:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to create repository archive'); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,257 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for forking repositories |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { RepoManager } from '$lib/services/git/repo-manager.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js'; |
||||||
|
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
import { KIND } from '$lib/types/nostr.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||||
|
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; |
||||||
|
import { exec } from 'child_process'; |
||||||
|
import { promisify } from 'util'; |
||||||
|
import { existsSync } from 'fs'; |
||||||
|
import { join } from 'path'; |
||||||
|
|
||||||
|
const execAsync = promisify(exec); |
||||||
|
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
||||||
|
const repoManager = new RepoManager(repoRoot); |
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
/** |
||||||
|
* POST - Fork a repository |
||||||
|
* Body: { userPubkey, forkName? } |
||||||
|
*/ |
||||||
|
export const POST: RequestHandler = async ({ params, request }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const body = await request.json(); |
||||||
|
const { userPubkey, forkName } = body; |
||||||
|
|
||||||
|
if (!userPubkey) { |
||||||
|
return error(401, 'Authentication required. Please provide userPubkey.'); |
||||||
|
} |
||||||
|
|
||||||
|
// Decode original repo owner npub
|
||||||
|
let originalOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
originalOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
// Decode user pubkey if needed
|
||||||
|
let userPubkeyHex = userPubkey; |
||||||
|
try { |
||||||
|
const userDecoded = nip19.decode(userPubkey); |
||||||
|
if (userDecoded.type === 'npub') { |
||||||
|
userPubkeyHex = userDecoded.data as string; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Assume it's already hex
|
||||||
|
} |
||||||
|
|
||||||
|
// Check if original repo exists
|
||||||
|
const originalRepoPath = join(repoRoot, npub, `${repo}.git`); |
||||||
|
if (!existsSync(originalRepoPath)) { |
||||||
|
return error(404, 'Original repository not found'); |
||||||
|
} |
||||||
|
|
||||||
|
// Get original repo announcement
|
||||||
|
const originalAnnouncements = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [originalOwnerPubkey], |
||||||
|
'#d': [repo], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (originalAnnouncements.length === 0) { |
||||||
|
return error(404, 'Original repository announcement not found'); |
||||||
|
} |
||||||
|
|
||||||
|
const originalAnnouncement = originalAnnouncements[0]; |
||||||
|
|
||||||
|
// Determine fork name (use original name if not specified)
|
||||||
|
const forkRepoName = forkName || repo; |
||||||
|
const userNpub = nip19.npubEncode(userPubkeyHex); |
||||||
|
|
||||||
|
// Check if fork already exists
|
||||||
|
const forkRepoPath = join(repoRoot, userNpub, `${forkRepoName}.git`); |
||||||
|
if (existsSync(forkRepoPath)) { |
||||||
|
return error(409, 'Fork already exists'); |
||||||
|
} |
||||||
|
|
||||||
|
// Clone the repository
|
||||||
|
await execAsync(`git clone --bare "${originalRepoPath}" "${forkRepoPath}"`); |
||||||
|
|
||||||
|
// Create fork announcement
|
||||||
|
const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543'; |
||||||
|
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; |
||||||
|
const forkGitUrl = `${protocol}://${gitDomain}/${userNpub}/${forkRepoName}.git`; |
||||||
|
|
||||||
|
// Extract original clone URLs and earliest unique commit
|
||||||
|
const originalCloneUrls = originalAnnouncement.tags |
||||||
|
.filter(t => t[0] === 'clone') |
||||||
|
.flatMap(t => t.slice(1)) |
||||||
|
.filter(url => url && typeof url === 'string') as string[]; |
||||||
|
|
||||||
|
const earliestCommitTag = originalAnnouncement.tags.find(t => t[0] === 'r' && t[2] === 'euc'); |
||||||
|
const earliestCommit = earliestCommitTag?.[1]; |
||||||
|
|
||||||
|
// Get original repo name and description
|
||||||
|
const originalName = originalAnnouncement.tags.find(t => t[0] === 'name')?.[1] || repo; |
||||||
|
const originalDescription = originalAnnouncement.tags.find(t => t[0] === 'description')?.[1] || ''; |
||||||
|
|
||||||
|
// Build fork announcement tags
|
||||||
|
const tags: string[][] = [ |
||||||
|
['d', forkRepoName], |
||||||
|
['name', `${originalName} (fork)`], |
||||||
|
['description', `Fork of ${originalName}${originalDescription ? `: ${originalDescription}` : ''}`], |
||||||
|
['clone', forkGitUrl, ...originalCloneUrls.filter(url => !url.includes(gitDomain))], |
||||||
|
['relays', ...DEFAULT_NOSTR_RELAYS], |
||||||
|
['t', 'fork'], // Mark as fork
|
||||||
|
['a', `30617:${originalOwnerPubkey}:${repo}`], // Reference to original repo
|
||||||
|
['p', originalOwnerPubkey], // Original owner
|
||||||
|
]; |
||||||
|
|
||||||
|
// Add earliest unique commit if available
|
||||||
|
if (earliestCommit) { |
||||||
|
tags.push(['r', earliestCommit, 'euc']); |
||||||
|
} |
||||||
|
|
||||||
|
// Create fork announcement event
|
||||||
|
const forkAnnouncementTemplate = { |
||||||
|
kind: KIND.REPO_ANNOUNCEMENT, |
||||||
|
pubkey: userPubkeyHex, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
content: '', |
||||||
|
tags |
||||||
|
}; |
||||||
|
|
||||||
|
// Sign and publish fork announcement
|
||||||
|
const signedForkAnnouncement = await signEventWithNIP07(forkAnnouncementTemplate); |
||||||
|
|
||||||
|
const { outbox } = await getUserRelays(userPubkeyHex, nostrClient); |
||||||
|
const combinedRelays = combineRelays(outbox); |
||||||
|
|
||||||
|
const publishResult = await nostrClient.publishEvent(signedForkAnnouncement, combinedRelays); |
||||||
|
|
||||||
|
if (publishResult.success.length === 0) { |
||||||
|
// Clean up repo if announcement failed
|
||||||
|
await execAsync(`rm -rf "${forkRepoPath}"`).catch(() => {}); |
||||||
|
return error(500, 'Failed to publish fork announcement to relays'); |
||||||
|
} |
||||||
|
|
||||||
|
// Create and publish initial ownership proof (self-transfer event)
|
||||||
|
const ownershipService = new OwnershipTransferService(combinedRelays); |
||||||
|
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName); |
||||||
|
const signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); |
||||||
|
|
||||||
|
await nostrClient.publishEvent(signedOwnershipEvent, combinedRelays).catch(err => { |
||||||
|
console.warn('Failed to publish initial ownership event for fork:', err); |
||||||
|
}); |
||||||
|
|
||||||
|
// Provision the fork repo (this will create verification file and include self-transfer)
|
||||||
|
await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent, false); |
||||||
|
|
||||||
|
return json({ |
||||||
|
success: true, |
||||||
|
fork: { |
||||||
|
npub: userNpub, |
||||||
|
repo: forkRepoName, |
||||||
|
url: forkGitUrl, |
||||||
|
announcementId: signedForkAnnouncement.id |
||||||
|
} |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error forking repository:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to fork repository'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* GET - Get fork information |
||||||
|
* Returns whether this is a fork and what it's forked from |
||||||
|
*/ |
||||||
|
export const GET: RequestHandler = async ({ params }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode repo owner npub
|
||||||
|
let ownerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
ownerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
// Get repo announcement
|
||||||
|
const announcements = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [ownerPubkey], |
||||||
|
'#d': [repo], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (announcements.length === 0) { |
||||||
|
return error(404, 'Repository announcement not found'); |
||||||
|
} |
||||||
|
|
||||||
|
const announcement = announcements[0]; |
||||||
|
const isFork = announcement.tags.some(t => t[0] === 't' && t[1] === 'fork'); |
||||||
|
|
||||||
|
// Get original repo reference
|
||||||
|
const originalRepoTag = announcement.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:')); |
||||||
|
const originalOwnerTag = announcement.tags.find(t => t[0] === 'p' && t[1] !== ownerPubkey); |
||||||
|
|
||||||
|
let originalRepo: { npub: string; repo: string } | null = null; |
||||||
|
if (originalRepoTag && originalRepoTag[1]) { |
||||||
|
const match = originalRepoTag[1].match(/^30617:([a-f0-9]{64}):(.+)$/); |
||||||
|
if (match) { |
||||||
|
const [, originalOwnerPubkey, originalRepoName] = match; |
||||||
|
try { |
||||||
|
const originalNpub = nip19.npubEncode(originalOwnerPubkey); |
||||||
|
originalRepo = { npub: originalNpub, repo: originalRepoName }; |
||||||
|
} catch { |
||||||
|
// Invalid pubkey
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return json({ |
||||||
|
isFork, |
||||||
|
originalRepo, |
||||||
|
forkCount: 0 // TODO: Count forks of this repo
|
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error getting fork information:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to get fork information'); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,88 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for raw file access |
||||||
|
*/ |
||||||
|
|
||||||
|
import { error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { FileManager } from '$lib/services/git/file-manager.js'; |
||||||
|
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
||||||
|
const fileManager = new FileManager(repoRoot); |
||||||
|
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, url, request }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
const filePath = url.searchParams.get('path'); |
||||||
|
const ref = url.searchParams.get('ref') || 'HEAD'; |
||||||
|
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); |
||||||
|
|
||||||
|
if (!npub || !repo || !filePath) { |
||||||
|
return error(400, 'Missing npub, repo, or path parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
if (!fileManager.repoExists(npub, repo)) { |
||||||
|
return error(404, 'Repository not found'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check repository privacy
|
||||||
|
let repoOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
repoOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); |
||||||
|
if (!canView) { |
||||||
|
return error(403, 'This repository is private. Only owners and maintainers can view it.'); |
||||||
|
} |
||||||
|
|
||||||
|
// Get file content
|
||||||
|
const fileData = await fileManager.getFileContent(npub, repo, filePath, ref); |
||||||
|
|
||||||
|
// Determine content type based on file extension
|
||||||
|
const ext = filePath.split('.').pop()?.toLowerCase(); |
||||||
|
const contentTypeMap: Record<string, string> = { |
||||||
|
'js': 'application/javascript', |
||||||
|
'ts': 'application/typescript', |
||||||
|
'json': 'application/json', |
||||||
|
'css': 'text/css', |
||||||
|
'html': 'text/html', |
||||||
|
'xml': 'application/xml', |
||||||
|
'svg': 'image/svg+xml', |
||||||
|
'png': 'image/png', |
||||||
|
'jpg': 'image/jpeg', |
||||||
|
'jpeg': 'image/jpeg', |
||||||
|
'gif': 'image/gif', |
||||||
|
'webp': 'image/webp', |
||||||
|
'pdf': 'application/pdf', |
||||||
|
'txt': 'text/plain', |
||||||
|
'md': 'text/markdown', |
||||||
|
'yml': 'text/yaml', |
||||||
|
'yaml': 'text/yaml', |
||||||
|
}; |
||||||
|
|
||||||
|
const contentType = contentTypeMap[ext || ''] || 'text/plain'; |
||||||
|
|
||||||
|
// Return raw file content
|
||||||
|
return new Response(fileData.content, { |
||||||
|
headers: { |
||||||
|
'Content-Type': contentType, |
||||||
|
'Content-Disposition': `inline; filename="${filePath.split('/').pop()}"`, |
||||||
|
'Cache-Control': 'public, max-age=3600' |
||||||
|
} |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error getting raw file:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to get raw file'); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,97 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for getting README content |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { FileManager } from '$lib/services/git/file-manager.js'; |
||||||
|
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
||||||
|
const fileManager = new FileManager(repoRoot); |
||||||
|
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
const README_PATTERNS = [ |
||||||
|
'README.md', |
||||||
|
'README.markdown', |
||||||
|
'README.txt', |
||||||
|
'readme.md', |
||||||
|
'readme.markdown', |
||||||
|
'readme.txt', |
||||||
|
'README', |
||||||
|
'readme' |
||||||
|
]; |
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, url, request }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
const ref = url.searchParams.get('ref') || 'HEAD'; |
||||||
|
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
if (!fileManager.repoExists(npub, repo)) { |
||||||
|
return error(404, 'Repository not found'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check repository privacy
|
||||||
|
let repoOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
repoOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); |
||||||
|
if (!canView) { |
||||||
|
return error(403, 'This repository is private. Only owners and maintainers can view it.'); |
||||||
|
} |
||||||
|
|
||||||
|
// Try to find README file
|
||||||
|
let readmeContent: string | null = null; |
||||||
|
let readmePath: string | null = null; |
||||||
|
|
||||||
|
for (const pattern of README_PATTERNS) { |
||||||
|
try { |
||||||
|
// Try root directory first
|
||||||
|
const content = await fileManager.getFileContent(npub, repo, pattern, ref); |
||||||
|
readmeContent = content.content; |
||||||
|
readmePath = pattern; |
||||||
|
break; |
||||||
|
} catch { |
||||||
|
// Try in root directory with different paths
|
||||||
|
try { |
||||||
|
const content = await fileManager.getFileContent(npub, repo, `/${pattern}`, ref); |
||||||
|
readmeContent = content.content; |
||||||
|
readmePath = `/${pattern}`; |
||||||
|
break; |
||||||
|
} catch { |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!readmeContent) { |
||||||
|
return json({ found: false }); |
||||||
|
} |
||||||
|
|
||||||
|
return json({ |
||||||
|
found: true, |
||||||
|
content: readmeContent, |
||||||
|
path: readmePath, |
||||||
|
isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown') |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error getting README:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to get README'); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,219 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for repository settings |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } 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'; |
||||||
|
import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||||
|
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; |
||||||
|
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; |
||||||
|
|
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); |
||||||
|
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
/** |
||||||
|
* GET - Get repository settings |
||||||
|
*/ |
||||||
|
export const GET: RequestHandler = async ({ params, url, request }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode npub to get pubkey
|
||||||
|
let repoOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
repoOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if user is owner
|
||||||
|
if (!userPubkey) { |
||||||
|
return error(401, 'Authentication required'); |
||||||
|
} |
||||||
|
|
||||||
|
let userPubkeyHex = userPubkey; |
||||||
|
try { |
||||||
|
const userDecoded = nip19.decode(userPubkey); |
||||||
|
if (userDecoded.type === 'npub') { |
||||||
|
userPubkeyHex = userDecoded.data as string; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Assume it's already hex
|
||||||
|
} |
||||||
|
|
||||||
|
const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); |
||||||
|
if (userPubkeyHex !== currentOwner) { |
||||||
|
return error(403, 'Only the repository owner can access settings'); |
||||||
|
} |
||||||
|
|
||||||
|
// Get repository announcement
|
||||||
|
const events = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [currentOwner], |
||||||
|
'#d': [repo], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (events.length === 0) { |
||||||
|
return error(404, 'Repository announcement not found'); |
||||||
|
} |
||||||
|
|
||||||
|
const announcement = events[0]; |
||||||
|
const name = announcement.tags.find(t => t[0] === 'name')?.[1] || repo; |
||||||
|
const description = announcement.tags.find(t => t[0] === 'description')?.[1] || ''; |
||||||
|
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[]; |
||||||
|
const isPrivate = await maintainerService.isRepoPrivate(currentOwner, repo); |
||||||
|
|
||||||
|
return json({ |
||||||
|
name, |
||||||
|
description, |
||||||
|
cloneUrls, |
||||||
|
maintainers, |
||||||
|
isPrivate, |
||||||
|
owner: currentOwner, |
||||||
|
npub |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error getting repository settings:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to get repository settings'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* POST - Update repository settings |
||||||
|
*/ |
||||||
|
export const POST: RequestHandler = async ({ params, request }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const body = await request.json(); |
||||||
|
const { userPubkey, name, description, cloneUrls, maintainers, isPrivate } = body; |
||||||
|
|
||||||
|
if (!userPubkey) { |
||||||
|
return error(401, 'Authentication required'); |
||||||
|
} |
||||||
|
|
||||||
|
// Decode npub to get pubkey
|
||||||
|
let repoOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
repoOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
let userPubkeyHex = userPubkey; |
||||||
|
try { |
||||||
|
const userDecoded = nip19.decode(userPubkey); |
||||||
|
if (userDecoded.type === 'npub') { |
||||||
|
userPubkeyHex = userDecoded.data as string; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Assume it's already hex
|
||||||
|
} |
||||||
|
|
||||||
|
// Check if user is owner
|
||||||
|
const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); |
||||||
|
if (userPubkeyHex !== currentOwner) { |
||||||
|
return error(403, 'Only the repository owner can update settings'); |
||||||
|
} |
||||||
|
|
||||||
|
// Get existing announcement
|
||||||
|
const events = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [currentOwner], |
||||||
|
'#d': [repo], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (events.length === 0) { |
||||||
|
return error(404, 'Repository announcement not found'); |
||||||
|
} |
||||||
|
|
||||||
|
const existingAnnouncement = events[0]; |
||||||
|
|
||||||
|
// Build updated tags
|
||||||
|
const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543'; |
||||||
|
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; |
||||||
|
const gitUrl = `${protocol}://${gitDomain}/${npub}/${repo}.git`; |
||||||
|
|
||||||
|
const tags: string[][] = [ |
||||||
|
['d', repo], |
||||||
|
['name', name || repo], |
||||||
|
...(description ? [['description', description]] : []), |
||||||
|
['clone', gitUrl, ...(cloneUrls || []).filter((url: string) => url && !url.includes(gitDomain))], |
||||||
|
['relays', ...DEFAULT_NOSTR_RELAYS], |
||||||
|
...(isPrivate ? [['private', 'true']] : []), |
||||||
|
...(maintainers || []).map((m: string) => ['maintainers', m]) |
||||||
|
]; |
||||||
|
|
||||||
|
// Preserve other tags from original announcement
|
||||||
|
const preserveTags = ['r', 'web', 't']; |
||||||
|
for (const tag of existingAnnouncement.tags) { |
||||||
|
if (preserveTags.includes(tag[0]) && !tags.some(t => t[0] === tag[0])) { |
||||||
|
tags.push(tag); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Create updated announcement
|
||||||
|
const updatedAnnouncement = { |
||||||
|
kind: KIND.REPO_ANNOUNCEMENT, |
||||||
|
pubkey: currentOwner, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
content: '', |
||||||
|
tags |
||||||
|
}; |
||||||
|
|
||||||
|
// Sign and publish
|
||||||
|
const signedEvent = await signEventWithNIP07(updatedAnnouncement); |
||||||
|
|
||||||
|
const { outbox } = await getUserRelays(currentOwner, nostrClient); |
||||||
|
const combinedRelays = combineRelays(outbox); |
||||||
|
|
||||||
|
const result = await nostrClient.publishEvent(signedEvent, combinedRelays); |
||||||
|
|
||||||
|
if (result.success.length === 0) { |
||||||
|
return error(500, 'Failed to publish updated announcement to relays'); |
||||||
|
} |
||||||
|
|
||||||
|
return json({ success: true, event: signedEvent }); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error updating repository settings:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to update repository settings'); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,160 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for searching repositories and code |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
import { KIND } from '$lib/types/nostr.js'; |
||||||
|
import { FileManager } from '$lib/services/git/file-manager.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { existsSync } from 'fs'; |
||||||
|
import { join } from 'path'; |
||||||
|
|
||||||
|
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
||||||
|
const fileManager = new FileManager(repoRoot); |
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => { |
||||||
|
const query = url.searchParams.get('q'); |
||||||
|
const type = url.searchParams.get('type') || 'repos'; // repos, code, or all
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') || '20', 10); |
||||||
|
|
||||||
|
if (!query || query.trim().length === 0) { |
||||||
|
return error(400, 'Missing or empty query parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
if (query.length < 2) { |
||||||
|
return error(400, 'Query must be at least 2 characters'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const results: { |
||||||
|
repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>; |
||||||
|
code: Array<{ repo: string; npub: string; file: string; matches: number }>; |
||||||
|
} = { |
||||||
|
repos: [], |
||||||
|
code: [] |
||||||
|
}; |
||||||
|
|
||||||
|
// Search repositories
|
||||||
|
if (type === 'repos' || type === 'all') { |
||||||
|
const events = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
const searchLower = query.toLowerCase(); |
||||||
|
|
||||||
|
for (const event of events) { |
||||||
|
const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; |
||||||
|
const description = event.tags.find(t => t[0] === 'description')?.[1] || ''; |
||||||
|
const repoId = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
||||||
|
|
||||||
|
const nameMatch = name.toLowerCase().includes(searchLower); |
||||||
|
const descMatch = description.toLowerCase().includes(searchLower); |
||||||
|
const repoMatch = repoId.toLowerCase().includes(searchLower); |
||||||
|
|
||||||
|
if (nameMatch || descMatch || repoMatch) { |
||||||
|
try { |
||||||
|
const npub = nip19.npubEncode(event.pubkey); |
||||||
|
results.repos.push({ |
||||||
|
id: event.id, |
||||||
|
name: name || repoId, |
||||||
|
description: description || '', |
||||||
|
owner: event.pubkey, |
||||||
|
npub |
||||||
|
}); |
||||||
|
} catch { |
||||||
|
// Skip if npub encoding fails
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by relevance (name matches first)
|
||||||
|
results.repos.sort((a, b) => { |
||||||
|
const aNameMatch = a.name.toLowerCase().includes(searchLower); |
||||||
|
const bNameMatch = b.name.toLowerCase().includes(searchLower); |
||||||
|
if (aNameMatch && !bNameMatch) return -1; |
||||||
|
if (!aNameMatch && bNameMatch) return 1; |
||||||
|
return 0; |
||||||
|
}); |
||||||
|
|
||||||
|
results.repos = results.repos.slice(0, limit); |
||||||
|
} |
||||||
|
|
||||||
|
// Search code (basic file content search)
|
||||||
|
if (type === 'code' || type === 'all') { |
||||||
|
// Get all repos on this server
|
||||||
|
const allRepos: Array<{ npub: string; repo: string }> = []; |
||||||
|
|
||||||
|
// This is a simplified search - in production, you'd want to index files
|
||||||
|
// For now, we'll search through known repos
|
||||||
|
try { |
||||||
|
const repoDirs = await import('fs/promises').then(fs =>
|
||||||
|
fs.readdir(repoRoot, { withFileTypes: true }) |
||||||
|
); |
||||||
|
|
||||||
|
for (const dir of repoDirs) { |
||||||
|
if (dir.isDirectory()) { |
||||||
|
const npub = dir.name; |
||||||
|
try { |
||||||
|
const repoFiles = await import('fs/promises').then(fs => |
||||||
|
fs.readdir(join(repoRoot, npub), { withFileTypes: true }) |
||||||
|
); |
||||||
|
|
||||||
|
for (const repoFile of repoFiles) { |
||||||
|
if (repoFile.isDirectory() && repoFile.name.endsWith('.git')) { |
||||||
|
const repo = repoFile.name.replace('.git', ''); |
||||||
|
allRepos.push({ npub, repo }); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Skip if can't read directory
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// If we can't list repos, skip code search
|
||||||
|
} |
||||||
|
|
||||||
|
// Search in files (limited to avoid performance issues)
|
||||||
|
const searchLower = query.toLowerCase(); |
||||||
|
let codeResults: Array<{ repo: string; npub: string; file: string; matches: number }> = []; |
||||||
|
|
||||||
|
for (const { npub, repo } of allRepos.slice(0, 10)) { // Limit to 10 repos for performance
|
||||||
|
try { |
||||||
|
const files = await fileManager.listFiles(npub, repo, 'HEAD', ''); |
||||||
|
|
||||||
|
for (const file of files.slice(0, 50)) { // Limit to 50 files per repo
|
||||||
|
if (file.type === 'file' && file.name.toLowerCase().includes(searchLower)) { |
||||||
|
codeResults.push({ |
||||||
|
repo, |
||||||
|
npub, |
||||||
|
file: file.path, |
||||||
|
matches: 1 |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Skip if can't access repo
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
results.code = codeResults.slice(0, limit); |
||||||
|
} |
||||||
|
|
||||||
|
return json({ |
||||||
|
query, |
||||||
|
type, |
||||||
|
results, |
||||||
|
total: results.repos.length + results.code.length |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error searching:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to search'); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,80 @@ |
|||||||
|
/** |
||||||
|
* Page metadata and OpenGraph tags for repository pages |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { PageLoad } from './$types'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
import { KIND } from '$lib/types/nostr.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params, url, parent }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return { |
||||||
|
title: 'Repository Not Found', |
||||||
|
description: 'Repository not found' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode npub to get pubkey
|
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type !== 'npub') { |
||||||
|
return { |
||||||
|
title: 'Invalid Repository', |
||||||
|
description: 'Invalid repository identifier' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
const repoOwnerPubkey = decoded.data as string; |
||||||
|
|
||||||
|
// Fetch repository announcement
|
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
const events = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [repoOwnerPubkey], |
||||||
|
'#d': [repo], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (events.length === 0) { |
||||||
|
return { |
||||||
|
title: `${repo} - Repository Not Found`, |
||||||
|
description: 'Repository announcement not found' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
const announcement = events[0]; |
||||||
|
const name = announcement.tags.find(t => t[0] === 'name')?.[1] || repo; |
||||||
|
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]; |
||||||
|
|
||||||
|
// Get git domain for constructing URLs
|
||||||
|
const layoutData = await parent(); |
||||||
|
const gitDomain = (layoutData as { gitDomain?: string }).gitDomain || url.host || 'localhost:6543'; |
||||||
|
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; |
||||||
|
const repoUrl = `${protocol}://${gitDomain}/repos/${npub}/${repo}`; |
||||||
|
|
||||||
|
return { |
||||||
|
title: `${name} - ${repo}`, |
||||||
|
description: description || `Repository: ${name}`, |
||||||
|
image: image || banner || undefined, |
||||||
|
banner: banner || image || undefined, |
||||||
|
repoName: name, |
||||||
|
repoDescription: description, |
||||||
|
repoUrl, |
||||||
|
ogType: 'website' |
||||||
|
}; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading repository metadata:', error); |
||||||
|
return { |
||||||
|
title: `${repo} - Repository`, |
||||||
|
description: 'Repository' |
||||||
|
}; |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,348 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { page } from '$app/stores'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||||
|
|
||||||
|
const npub = ($page.params as { npub?: string; repo?: string }).npub || ''; |
||||||
|
const repo = ($page.params as { npub?: string; repo?: string }).repo || ''; |
||||||
|
|
||||||
|
let loading = $state(true); |
||||||
|
let saving = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let userPubkey = $state<string | null>(null); |
||||||
|
|
||||||
|
let name = $state(''); |
||||||
|
let description = $state(''); |
||||||
|
let cloneUrls = $state<string[]>(['']); |
||||||
|
let maintainers = $state<string[]>(['']); |
||||||
|
let isPrivate = $state(false); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await checkAuth(); |
||||||
|
await loadSettings(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function checkAuth() { |
||||||
|
try { |
||||||
|
if (typeof window !== 'undefined' && window.nostr) { |
||||||
|
userPubkey = await getPublicKeyWithNIP07(); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Auth check failed:', err); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function loadSettings() { |
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/settings?userPubkey=${userPubkey}`); |
||||||
|
if (response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
name = data.name || ''; |
||||||
|
description = data.description || ''; |
||||||
|
cloneUrls = data.cloneUrls?.length > 0 ? data.cloneUrls : ['']; |
||||||
|
maintainers = data.maintainers?.length > 0 ? data.maintainers : ['']; |
||||||
|
isPrivate = data.isPrivate || false; |
||||||
|
} else { |
||||||
|
const data = await response.json(); |
||||||
|
error = data.error || 'Failed to load settings'; |
||||||
|
if (response.status === 403) { |
||||||
|
setTimeout(() => goto(`/repos/${npub}/${repo}`), 2000); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to load settings'; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function saveSettings() { |
||||||
|
if (!userPubkey) { |
||||||
|
error = 'Please connect your NIP-07 extension'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
saving = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/settings`, { |
||||||
|
method: 'POST', |
||||||
|
headers: { 'Content-Type': 'application/json' }, |
||||||
|
body: JSON.stringify({ |
||||||
|
userPubkey, |
||||||
|
name, |
||||||
|
description, |
||||||
|
cloneUrls: cloneUrls.filter(url => url.trim()), |
||||||
|
maintainers: maintainers.filter(m => m.trim()), |
||||||
|
isPrivate |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
if (response.ok) { |
||||||
|
alert('Settings saved successfully!'); |
||||||
|
goto(`/repos/${npub}/${repo}`); |
||||||
|
} else { |
||||||
|
const data = await response.json(); |
||||||
|
error = data.error || 'Failed to save settings'; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to save settings'; |
||||||
|
} finally { |
||||||
|
saving = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function addCloneUrl() { |
||||||
|
cloneUrls = [...cloneUrls, '']; |
||||||
|
} |
||||||
|
|
||||||
|
function removeCloneUrl(index: number) { |
||||||
|
cloneUrls = cloneUrls.filter((_, i) => i !== index); |
||||||
|
} |
||||||
|
|
||||||
|
function addMaintainer() { |
||||||
|
maintainers = [...maintainers, '']; |
||||||
|
} |
||||||
|
|
||||||
|
function removeMaintainer(index: number) { |
||||||
|
maintainers = maintainers.filter((_, i) => i !== index); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="container"> |
||||||
|
<header> |
||||||
|
<a href={`/repos/${npub}/${repo}`} class="back-link">← Back to Repository</a> |
||||||
|
<h1>Repository Settings</h1> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main> |
||||||
|
{#if loading} |
||||||
|
<div class="loading">Loading settings...</div> |
||||||
|
{:else if error && !userPubkey} |
||||||
|
<div class="error"> |
||||||
|
{error} |
||||||
|
<p>Redirecting to repository...</p> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<form onsubmit={(e) => { e.preventDefault(); saveSettings(); }} class="settings-form"> |
||||||
|
<div class="form-section"> |
||||||
|
<h2>Basic Information</h2> |
||||||
|
|
||||||
|
<label> |
||||||
|
Repository Name |
||||||
|
<input type="text" bind:value={name} required /> |
||||||
|
</label> |
||||||
|
|
||||||
|
<label> |
||||||
|
Description |
||||||
|
<textarea bind:value={description} rows="3"></textarea> |
||||||
|
</label> |
||||||
|
|
||||||
|
<label> |
||||||
|
<input type="checkbox" bind:checked={isPrivate} /> |
||||||
|
Private Repository (only owners and maintainers can view) |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-section"> |
||||||
|
<h2>Clone URLs</h2> |
||||||
|
<p class="help-text">Additional clone URLs (your server URL is automatically included)</p> |
||||||
|
{#each cloneUrls as url, index} |
||||||
|
<div class="array-input"> |
||||||
|
<input type="url" bind:value={cloneUrls[index]} placeholder="https://example.com/repo.git" /> |
||||||
|
{#if cloneUrls.length > 1} |
||||||
|
<button type="button" onclick={() => removeCloneUrl(index)} class="remove-button">Remove</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
<button type="button" onclick={addCloneUrl} class="add-button">+ Add Clone URL</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-section"> |
||||||
|
<h2>Maintainers</h2> |
||||||
|
<p class="help-text">Additional maintainers (npub or hex pubkey)</p> |
||||||
|
{#each maintainers as maintainer, index} |
||||||
|
<div class="array-input"> |
||||||
|
<input type="text" bind:value={maintainers[index]} placeholder="npub1..." /> |
||||||
|
{#if maintainers.length > 1} |
||||||
|
<button type="button" onclick={() => removeMaintainer(index)} class="remove-button">Remove</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
<button type="button" onclick={addMaintainer} class="add-button">+ Add Maintainer</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<div class="error">{error}</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="form-actions"> |
||||||
|
<button type="button" onclick={() => goto(`/repos/${npub}/${repo}`)} class="cancel-button">Cancel</button> |
||||||
|
<button type="submit" disabled={saving} class="save-button"> |
||||||
|
{saving ? 'Saving...' : 'Save Settings'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.container { |
||||||
|
max-width: 800px; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.back-link { |
||||||
|
display: inline-block; |
||||||
|
margin-bottom: 1rem; |
||||||
|
color: #007bff; |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.back-link:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.settings-form { |
||||||
|
background: white; |
||||||
|
border-radius: 8px; |
||||||
|
padding: 2rem; |
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.form-section { |
||||||
|
margin-bottom: 2rem; |
||||||
|
padding-bottom: 2rem; |
||||||
|
border-bottom: 1px solid #e0e0e0; |
||||||
|
} |
||||||
|
|
||||||
|
.form-section:last-of-type { |
||||||
|
border-bottom: none; |
||||||
|
} |
||||||
|
|
||||||
|
.form-section h2 { |
||||||
|
margin-bottom: 1rem; |
||||||
|
color: #333; |
||||||
|
} |
||||||
|
|
||||||
|
label { |
||||||
|
display: block; |
||||||
|
margin-bottom: 1rem; |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
label input[type="text"], |
||||||
|
label input[type="url"], |
||||||
|
label textarea { |
||||||
|
width: 100%; |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 4px; |
||||||
|
font-size: 1rem; |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
label input[type="checkbox"] { |
||||||
|
margin-right: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.help-text { |
||||||
|
color: #666; |
||||||
|
font-size: 0.9rem; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.array-input { |
||||||
|
display: flex; |
||||||
|
gap: 0.5rem; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.array-input input { |
||||||
|
flex: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.add-button, .remove-button { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 4px; |
||||||
|
background: white; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.add-button { |
||||||
|
color: #007bff; |
||||||
|
border-color: #007bff; |
||||||
|
} |
||||||
|
|
||||||
|
.add-button:hover { |
||||||
|
background: #f0f8ff; |
||||||
|
} |
||||||
|
|
||||||
|
.remove-button { |
||||||
|
color: #d32f2f; |
||||||
|
border-color: #d32f2f; |
||||||
|
} |
||||||
|
|
||||||
|
.remove-button:hover { |
||||||
|
background: #ffebee; |
||||||
|
} |
||||||
|
|
||||||
|
.form-actions { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
justify-content: flex-end; |
||||||
|
margin-top: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-button, .save-button { |
||||||
|
padding: 0.75rem 1.5rem; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-button { |
||||||
|
background: #f5f5f5; |
||||||
|
color: #333; |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-button:hover { |
||||||
|
background: #e0e0e0; |
||||||
|
} |
||||||
|
|
||||||
|
.save-button { |
||||||
|
background: #007bff; |
||||||
|
color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.save-button:hover:not(:disabled) { |
||||||
|
background: #0056b3; |
||||||
|
} |
||||||
|
|
||||||
|
.save-button:disabled { |
||||||
|
background: #ccc; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.loading, .error { |
||||||
|
text-align: center; |
||||||
|
padding: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.error { |
||||||
|
color: #d32f2f; |
||||||
|
background: #ffebee; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,255 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { page } from '$app/stores'; |
||||||
|
|
||||||
|
let query = $state(''); |
||||||
|
let searchType = $state<'repos' | 'code' | 'all'>('repos'); |
||||||
|
let loading = $state(false); |
||||||
|
let results = $state<{ |
||||||
|
repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>; |
||||||
|
code: Array<{ repo: string; npub: string; file: string; matches: number }>; |
||||||
|
total: number; |
||||||
|
} | null>(null); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
|
||||||
|
async function performSearch() { |
||||||
|
if (!query.trim() || query.length < 2) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${searchType}`); |
||||||
|
if (response.ok) { |
||||||
|
results = await response.json(); |
||||||
|
} else { |
||||||
|
const data = await response.json(); |
||||||
|
error = data.error || 'Search failed'; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Search failed'; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleSearch(e: Event) { |
||||||
|
e.preventDefault(); |
||||||
|
performSearch(); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="container"> |
||||||
|
<header> |
||||||
|
<a href="/" class="back-link">← Back to Repositories</a> |
||||||
|
<h1>Search</h1> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main> |
||||||
|
<form onsubmit={handleSearch} class="search-form"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
bind:value={query} |
||||||
|
placeholder="Search repositories or code..." |
||||||
|
class="search-input" |
||||||
|
autofocus |
||||||
|
/> |
||||||
|
<select bind:value={searchType} class="search-type-select"> |
||||||
|
<option value="repos">Repositories</option> |
||||||
|
<option value="code">Code</option> |
||||||
|
<option value="all">All</option> |
||||||
|
</select> |
||||||
|
<button type="submit" disabled={loading || !query.trim()} class="search-button"> |
||||||
|
{loading ? 'Searching...' : 'Search'} |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<div class="error">{error}</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if results} |
||||||
|
<div class="results"> |
||||||
|
<div class="results-header"> |
||||||
|
<h2>Results ({results.total})</h2> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if (searchType === 'repos' || searchType === 'all') && results.repos.length > 0} |
||||||
|
<section class="results-section"> |
||||||
|
<h3>Repositories ({results.repos.length})</h3> |
||||||
|
<div class="repo-list"> |
||||||
|
{#each results.repos as repo} |
||||||
|
<div class="repo-item" onclick={() => goto(`/repos/${repo.npub}/${repo.name.toLowerCase().replace(/\s+/g, '-')}`)}> |
||||||
|
<h4>{repo.name}</h4> |
||||||
|
{#if repo.description} |
||||||
|
<p class="repo-description">{repo.description}</p> |
||||||
|
{/if} |
||||||
|
<div class="repo-meta"> |
||||||
|
<a href={`/users/${repo.npub}`} onclick={(e) => e.stopPropagation()}> |
||||||
|
{repo.npub.slice(0, 16)}... |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if (searchType === 'code' || searchType === 'all') && results.code.length > 0} |
||||||
|
<section class="results-section"> |
||||||
|
<h3>Code Files ({results.code.length})</h3> |
||||||
|
<div class="code-list"> |
||||||
|
{#each results.code as file} |
||||||
|
<div class="code-item" onclick={() => goto(`/repos/${file.npub}/${file.repo}?file=${encodeURIComponent(file.file)}`)}> |
||||||
|
<div class="code-file-path">{file.file}</div> |
||||||
|
<div class="code-repo"> |
||||||
|
<a href={`/repos/${file.npub}/${file.repo}`} onclick={(e) => e.stopPropagation()}> |
||||||
|
{file.repo} |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if results.total === 0} |
||||||
|
<div class="no-results">No results found</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.container { |
||||||
|
max-width: 1200px; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.back-link { |
||||||
|
display: inline-block; |
||||||
|
margin-bottom: 1rem; |
||||||
|
color: #007bff; |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.back-link:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.search-form { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
margin-bottom: 2rem; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.search-input { |
||||||
|
flex: 1; |
||||||
|
padding: 0.75rem; |
||||||
|
font-size: 1rem; |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
.search-type-select { |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
.search-button { |
||||||
|
padding: 0.75rem 1.5rem; |
||||||
|
background: #007bff; |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.search-button:hover:not(:disabled) { |
||||||
|
background: #0056b3; |
||||||
|
} |
||||||
|
|
||||||
|
.search-button:disabled { |
||||||
|
background: #ccc; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.results-header { |
||||||
|
margin-bottom: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.results-section { |
||||||
|
margin-bottom: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.results-section h3 { |
||||||
|
margin-bottom: 1rem; |
||||||
|
color: #333; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-list, .code-list { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-item, .code-item { |
||||||
|
border: 1px solid #e0e0e0; |
||||||
|
border-radius: 8px; |
||||||
|
padding: 1rem; |
||||||
|
cursor: pointer; |
||||||
|
transition: box-shadow 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-item:hover, .code-item:hover { |
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.repo-item h4 { |
||||||
|
margin: 0 0 0.5rem 0; |
||||||
|
color: #007bff; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-description { |
||||||
|
color: #666; |
||||||
|
margin: 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-meta { |
||||||
|
margin-top: 0.5rem; |
||||||
|
font-size: 0.9rem; |
||||||
|
color: #999; |
||||||
|
} |
||||||
|
|
||||||
|
.code-file-path { |
||||||
|
font-family: monospace; |
||||||
|
color: #333; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.code-repo { |
||||||
|
font-size: 0.9rem; |
||||||
|
color: #666; |
||||||
|
} |
||||||
|
|
||||||
|
.no-results, .error { |
||||||
|
text-align: center; |
||||||
|
padding: 2rem; |
||||||
|
color: #666; |
||||||
|
} |
||||||
|
|
||||||
|
.error { |
||||||
|
color: #d32f2f; |
||||||
|
background: #ffebee; |
||||||
|
border-radius: 4px; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,280 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { page } from '$app/stores'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
import { KIND } from '$lib/types/nostr.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
|
||||||
|
const npub = ($page.params as { npub?: string }).npub || ''; |
||||||
|
|
||||||
|
let loading = $state(true); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let userPubkey = $state<string | null>(null); |
||||||
|
let repos = $state<NostrEvent[]>([]); |
||||||
|
let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null); |
||||||
|
|
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await loadUserProfile(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadUserProfile() { |
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode npub to get pubkey |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type !== 'npub') { |
||||||
|
error = 'Invalid npub format'; |
||||||
|
return; |
||||||
|
} |
||||||
|
userPubkey = decoded.data as string; |
||||||
|
|
||||||
|
// Fetch user's repositories |
||||||
|
const repoEvents = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [userPubkey], |
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
// Filter for repos that list our domain |
||||||
|
repos = repoEvents.filter(event => { |
||||||
|
const cloneUrls = event.tags |
||||||
|
.filter(t => t[0] === 'clone') |
||||||
|
.flatMap(t => t.slice(1)) |
||||||
|
.filter(url => url && typeof url === 'string'); |
||||||
|
|
||||||
|
return cloneUrls.some(url => url.includes(gitDomain)); |
||||||
|
}); |
||||||
|
|
||||||
|
// Sort by created_at descending |
||||||
|
repos.sort((a, b) => b.created_at - a.created_at); |
||||||
|
|
||||||
|
// Try to fetch user profile (kind 0) |
||||||
|
const profileEvents = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [0], |
||||||
|
authors: [userPubkey], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (profileEvents.length > 0) { |
||||||
|
try { |
||||||
|
const profile = JSON.parse(profileEvents[0].content); |
||||||
|
userProfile = { |
||||||
|
name: profile.name, |
||||||
|
about: profile.about, |
||||||
|
picture: profile.picture |
||||||
|
}; |
||||||
|
} catch { |
||||||
|
// Invalid JSON, ignore |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to load user profile'; |
||||||
|
console.error('Error loading user profile:', err); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getRepoName(event: NostrEvent): string { |
||||||
|
return event.tags.find(t => t[0] === 'name')?.[1] || |
||||||
|
event.tags.find(t => t[0] === 'd')?.[1] || |
||||||
|
'Unnamed'; |
||||||
|
} |
||||||
|
|
||||||
|
function getRepoDescription(event: NostrEvent): string { |
||||||
|
return event.tags.find(t => t[0] === 'description')?.[1] || ''; |
||||||
|
} |
||||||
|
|
||||||
|
function getRepoId(event: NostrEvent): string { |
||||||
|
return event.tags.find(t => t[0] === 'd')?.[1] || ''; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="container"> |
||||||
|
<header> |
||||||
|
<a href="/" class="back-link">← Back to Repositories</a> |
||||||
|
<div class="profile-header"> |
||||||
|
{#if userProfile?.picture} |
||||||
|
<img src={userProfile.picture} alt="Profile" class="profile-picture" /> |
||||||
|
{:else} |
||||||
|
<div class="profile-picture-placeholder"> |
||||||
|
{npub.slice(0, 2).toUpperCase()} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div class="profile-info"> |
||||||
|
<h1>{userProfile?.name || npub.slice(0, 16)}...</h1> |
||||||
|
{#if userProfile?.about} |
||||||
|
<p class="profile-about">{userProfile.about}</p> |
||||||
|
{/if} |
||||||
|
<p class="profile-npub">npub: {npub}</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main> |
||||||
|
{#if error} |
||||||
|
<div class="error">Error: {error}</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<div class="loading">Loading profile...</div> |
||||||
|
{:else} |
||||||
|
<div class="repos-section"> |
||||||
|
<h2>Repositories ({repos.length})</h2> |
||||||
|
{#if repos.length === 0} |
||||||
|
<div class="empty">No repositories found</div> |
||||||
|
{:else} |
||||||
|
<div class="repo-grid"> |
||||||
|
{#each repos as event} |
||||||
|
<div class="repo-card" onclick={() => goto(`/repos/${npub}/${getRepoId(event)}`)}> |
||||||
|
<h3>{getRepoName(event)}</h3> |
||||||
|
{#if getRepoDescription(event)} |
||||||
|
<p class="repo-description">{getRepoDescription(event)}</p> |
||||||
|
{/if} |
||||||
|
<div class="repo-meta"> |
||||||
|
<span class="repo-date"> |
||||||
|
{new Date(event.created_at * 1000).toLocaleDateString()} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.container { |
||||||
|
max-width: 1200px; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.back-link { |
||||||
|
display: inline-block; |
||||||
|
margin-bottom: 1rem; |
||||||
|
color: #007bff; |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.back-link:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.profile-header { |
||||||
|
display: flex; |
||||||
|
gap: 1.5rem; |
||||||
|
align-items: flex-start; |
||||||
|
margin-bottom: 2rem; |
||||||
|
padding-bottom: 2rem; |
||||||
|
border-bottom: 1px solid #e0e0e0; |
||||||
|
} |
||||||
|
|
||||||
|
.profile-picture { |
||||||
|
width: 80px; |
||||||
|
height: 80px; |
||||||
|
border-radius: 50%; |
||||||
|
object-fit: cover; |
||||||
|
} |
||||||
|
|
||||||
|
.profile-picture-placeholder { |
||||||
|
width: 80px; |
||||||
|
height: 80px; |
||||||
|
border-radius: 50%; |
||||||
|
background: #007bff; |
||||||
|
color: white; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
font-size: 2rem; |
||||||
|
font-weight: bold; |
||||||
|
} |
||||||
|
|
||||||
|
.profile-info h1 { |
||||||
|
margin: 0 0 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.profile-about { |
||||||
|
color: #666; |
||||||
|
margin: 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.profile-npub { |
||||||
|
color: #999; |
||||||
|
font-size: 0.9rem; |
||||||
|
margin: 0.5rem 0 0 0; |
||||||
|
font-family: monospace; |
||||||
|
} |
||||||
|
|
||||||
|
.repos-section { |
||||||
|
margin-top: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.repos-section h2 { |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
||||||
|
gap: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-card { |
||||||
|
border: 1px solid #e0e0e0; |
||||||
|
border-radius: 8px; |
||||||
|
padding: 1.5rem; |
||||||
|
cursor: pointer; |
||||||
|
transition: box-shadow 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-card:hover { |
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.repo-card h3 { |
||||||
|
margin: 0 0 0.5rem 0; |
||||||
|
color: #007bff; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-description { |
||||||
|
color: #666; |
||||||
|
margin: 0.5rem 0; |
||||||
|
font-size: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-meta { |
||||||
|
margin-top: 1rem; |
||||||
|
padding-top: 1rem; |
||||||
|
border-top: 1px solid #f0f0f0; |
||||||
|
font-size: 0.85rem; |
||||||
|
color: #999; |
||||||
|
} |
||||||
|
|
||||||
|
.loading, .empty, .error { |
||||||
|
text-align: center; |
||||||
|
padding: 2rem; |
||||||
|
color: #666; |
||||||
|
} |
||||||
|
|
||||||
|
.error { |
||||||
|
color: #d32f2f; |
||||||
|
background: #ffebee; |
||||||
|
border-radius: 4px; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
</style> |
||||||
Loading…
Reference in new issue