Browse Source

lower-priority features

main
Silberengel 4 weeks ago
parent
commit
32ac7b88aa
  1. 95
      src/routes/+page.svelte
  2. 110
      src/routes/api/repos/[npub]/[repo]/download/+server.ts
  3. 257
      src/routes/api/repos/[npub]/[repo]/fork/+server.ts
  4. 88
      src/routes/api/repos/[npub]/[repo]/raw/+server.ts
  5. 97
      src/routes/api/repos/[npub]/[repo]/readme/+server.ts
  6. 219
      src/routes/api/repos/[npub]/[repo]/settings/+server.ts
  7. 160
      src/routes/api/search/+server.ts
  8. 289
      src/routes/repos/[npub]/[repo]/+page.svelte
  9. 80
      src/routes/repos/[npub]/[repo]/+page.ts
  10. 348
      src/routes/repos/[npub]/[repo]/settings/+page.svelte
  11. 255
      src/routes/search/+page.svelte
  12. 280
      src/routes/users/[npub]/+page.svelte

95
src/routes/+page.svelte

@ -60,6 +60,10 @@ @@ -60,6 +60,10 @@
}
}
function goToSearch() {
goto('/search');
}
function getRepoName(event: NostrEvent): string {
const nameTag = event.tags.find(t => t[0] === 'name' && t[1]);
if (nameTag?.[1]) return nameTag[1];
@ -75,6 +79,16 @@ @@ -75,6 +79,16 @@
return descTag?.[1] || '';
}
function getRepoImage(event: NostrEvent): string | null {
const imageTag = event.tags.find(t => t[0] === 'image' && t[1]);
return imageTag?.[1] || null;
}
function getRepoBanner(event: NostrEvent): string | null {
const bannerTag = event.tags.find(t => t[0] === 'banner' && t[1]);
return bannerTag?.[1] || null;
}
function getCloneUrls(event: NostrEvent): string[] {
const urls: string[] = [];
@ -143,6 +157,7 @@ @@ -143,6 +157,7 @@
<h1>gitrepublic</h1>
<nav>
<a href="/">Repositories</a>
<a href="/search">Search</a>
<a href="/signup">Sign Up</a>
<a href="/docs/nip34">NIP-34 Docs</a>
</nav>
@ -167,24 +182,38 @@ @@ -167,24 +182,38 @@
{:else}
<div class="repos-list">
{#each repos as repo}
{@const repoImage = getRepoImage(repo)}
{@const repoBanner = getRepoBanner(repo)}
<div class="repo-card">
<div class="repo-header">
<h3>{getRepoName(repo)}</h3>
<a href="/repos/{getNpubFromEvent(repo)}/{getRepoNameFromUrl(repo)}" class="view-button">
View & Edit →
</a>
</div>
{#if getRepoDescription(repo)}
<p class="description">{getRepoDescription(repo)}</p>
{#if repoBanner}
<div class="repo-card-banner">
<img src={repoBanner} alt="Banner" />
</div>
{/if}
<div class="clone-urls">
<strong>Clone URLs:</strong>
{#each getCloneUrls(repo) as url}
<code>{url}</code>
{/each}
</div>
<div class="repo-meta">
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span>
<div class="repo-card-content">
<div class="repo-header">
{#if repoImage}
<img src={repoImage} alt="Repository" class="repo-card-image" />
{/if}
<div class="repo-header-text">
<h3>{getRepoName(repo)}</h3>
{#if getRepoDescription(repo)}
<p class="description">{getRepoDescription(repo)}</p>
{/if}
</div>
<a href="/repos/{getNpubFromEvent(repo)}/{getRepoNameFromUrl(repo)}" class="view-button">
View & Edit →
</a>
</div>
<div class="clone-urls">
<strong>Clone URLs:</strong>
{#each getCloneUrls(repo) as url}
<code>{url}</code>
{/each}
</div>
<div class="repo-meta">
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span>
</div>
</div>
</div>
{/each}
@ -234,15 +263,45 @@ @@ -234,15 +263,45 @@
.repo-card {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1.5rem;
background: white;
overflow: hidden;
}
.repo-card-banner {
width: 100%;
height: 200px;
overflow: hidden;
background: #f3f4f6;
}
.repo-card-banner img {
width: 100%;
height: 100%;
object-fit: cover;
}
.repo-card-content {
padding: 1.5rem;
}
.repo-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
margin-bottom: 0.5rem;
gap: 1rem;
}
.repo-header-text {
flex: 1;
}
.repo-card-image {
width: 64px;
height: 64px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.repo-card h3 {

110
src/routes/api/repos/[npub]/[repo]/download/+server.ts

@ -0,0 +1,110 @@ @@ -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');
}
};

257
src/routes/api/repos/[npub]/[repo]/fork/+server.ts

@ -0,0 +1,257 @@ @@ -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');
}
};

88
src/routes/api/repos/[npub]/[repo]/raw/+server.ts

@ -0,0 +1,88 @@ @@ -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');
}
};

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

@ -0,0 +1,97 @@ @@ -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');
}
};

219
src/routes/api/repos/[npub]/[repo]/settings/+server.ts

@ -0,0 +1,219 @@ @@ -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');
}
};

160
src/routes/api/search/+server.ts

@ -0,0 +1,160 @@ @@ -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');
}
};

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

@ -10,6 +10,17 @@ @@ -10,6 +10,17 @@
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { nip19 } from 'nostr-tools';
// Get page data for OpenGraph metadata
const pageData = $page.data as {
title?: string;
description?: string;
image?: string;
banner?: string;
repoName?: string;
repoDescription?: string;
repoUrl?: string;
};
const npub = ($page.params as { npub?: string; repo?: string }).npub || '';
const repo = ($page.params as { npub?: string; repo?: string }).repo || '';
@ -84,6 +95,132 @@ @@ -84,6 +95,132 @@
let newPRLabels = $state<string[]>(['']);
let selectedPR = $state<string | null>(null);
// README
let readmeContent = $state<string | null>(null);
let readmePath = $state<string | null>(null);
let readmeIsMarkdown = $state(false);
let loadingReadme = $state(false);
let readmeHtml = $state<string>('');
// Fork
let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null);
let forking = $state(false);
// Repository images
let repoImage = $state<string | null>(null);
let repoBanner = $state<string | null>(null);
async function loadReadme() {
loadingReadme = true;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch}`);
if (response.ok) {
const data = await response.json();
if (data.found) {
readmeContent = data.content;
readmePath = data.path;
readmeIsMarkdown = data.isMarkdown;
// Render markdown if needed
if (readmeIsMarkdown && readmeContent) {
const { marked } = await import('marked');
readmeHtml = marked.parse(readmeContent) as string;
}
}
}
} catch (err) {
console.error('Error loading README:', err);
} finally {
loadingReadme = false;
}
}
async function loadForkInfo() {
try {
const response = await fetch(`/api/repos/${npub}/${repo}/fork`);
if (response.ok) {
forkInfo = await response.json();
}
} catch (err) {
console.error('Error loading fork info:', err);
}
}
async function forkRepository() {
if (!userPubkey) {
alert('Please connect your NIP-07 extension');
return;
}
forking = true;
error = null;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userPubkey })
});
if (response.ok) {
const data = await response.json();
alert(`Repository forked successfully! Visit /repos/${data.fork.npub}/${data.fork.repo}`);
goto(`/repos/${data.fork.npub}/${data.fork.repo}`);
} else {
const data = await response.json();
error = data.error || 'Failed to fork repository';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to fork repository';
} finally {
forking = false;
}
}
async function loadRepoImages() {
try {
// Get images from page data (loaded from announcement)
if (pageData.image) {
repoImage = pageData.image;
}
if (pageData.banner) {
repoBanner = pageData.banner;
}
// Also fetch from announcement directly as fallback
if (!repoImage && !repoBanner) {
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: [30617], // REPO_ANNOUNCEMENT
authors: [repoOwnerPubkey],
'#d': [repo],
limit: 1
}
]);
if (events.length > 0) {
const announcement = events[0];
const imageTag = announcement.tags.find((t: string[]) => t[0] === 'image');
const bannerTag = announcement.tags.find((t: string[]) => t[0] === 'banner');
if (imageTag?.[1]) {
repoImage = imageTag[1];
}
if (bannerTag?.[1]) {
repoBanner = bannerTag[1];
}
}
}
}
} catch (err) {
console.error('Error loading repo images:', err);
}
}
onMount(async () => {
await loadBranches();
await loadFiles();
@ -91,6 +228,9 @@ @@ -91,6 +228,9 @@
await loadTags();
await checkMaintainerStatus();
await checkVerification();
await loadReadme();
await loadForkInfo();
await loadRepoImages();
});
async function checkAuth() {
@ -685,26 +825,86 @@ @@ -685,26 +825,86 @@
loadPRs();
}
});
$effect(() => {
if (currentBranch) {
loadReadme();
}
});
</script>
<svelte:head>
<title>{pageData.title || `${repo} - Repository`}</title>
<meta name="description" content={pageData.description || `Repository: ${repo}`} />
<!-- OpenGraph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content={pageData.title || `${pageData.repoName || repo} - Repository`} />
<meta property="og:description" content={pageData.description || pageData.repoDescription || `Repository: ${pageData.repoName || repo}`} />
<meta property="og:url" content={pageData.repoUrl || `https://${$page.url.host}${$page.url.pathname}`} />
{#if pageData.image || repoImage}
<meta property="og:image" content={pageData.image || repoImage} />
{/if}
{#if pageData.banner || repoBanner}
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
{/if}
<!-- Twitter Card -->
<meta name="twitter:card" content={repoBanner || repoImage ? "summary_large_image" : "summary"} />
<meta name="twitter:title" content={pageData.title || `${pageData.repoName || repo} - Repository`} />
<meta name="twitter:description" content={pageData.description || pageData.repoDescription || `Repository: ${pageData.repoName || repo}`} />
{#if pageData.banner || repoBanner}
<meta name="twitter:image" content={pageData.banner || repoBanner} />
{:else if pageData.image || repoImage}
<meta name="twitter:image" content={pageData.image || repoImage} />
{/if}
</svelte:head>
<div class="container">
<header>
{#if repoBanner}
<div class="repo-banner">
<img src={repoBanner} alt="" />
</div>
{/if}
<div class="header-left">
<a href="/" class="back-link">← Back to Repositories</a>
<h1>{repo}</h1>
<span class="npub">by {npub.slice(0, 16)}...</span>
<div class="repo-title-section">
{#if repoImage}
<img src={repoImage} alt="Repository image" class="repo-image" />
{/if}
<div>
<h1>{pageData.repoName || repo}</h1>
{#if pageData.repoDescription}
<p class="repo-description-header">{pageData.repoDescription}</p>
{/if}
</div>
</div>
<span class="npub">
by <a href={`/users/${npub}`}>{npub.slice(0, 16)}...</a>
</span>
<a href="/docs/nip34" class="docs-link" target="_blank" title="NIP-34 Documentation">📖</a>
{#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>
{/if}
</div>
<div class="header-right">
<div class="header-right">
<select bind:value={currentBranch} onchange={handleBranchChange} class="branch-select">
{#each branches as branch}
<option value={branch}>{branch}</option>
{/each}
</select>
{#if userPubkey && isMaintainer}
<button onclick={() => showCreateBranchDialog = true} class="create-branch-button">+ New Branch</button>
{/if}
{#if userPubkey}
<button onclick={forkRepository} disabled={forking} class="fork-button">
{forking ? 'Forking...' : 'Fork'}
</button>
{#if isMaintainer}
<a href={`/repos/${npub}/${repo}/settings`} class="settings-button">Settings</a>
{/if}
{#if isMaintainer}
<button onclick={() => showCreateBranchDialog = true} class="create-branch-button">+ New Branch</button>
{/if}
<span class="auth-status">
{#if isMaintainer}
✓ Maintainer
@ -944,8 +1144,29 @@ @@ -944,8 +1144,29 @@
</aside>
{/if}
<!-- Editor Area / Diff View -->
<!-- Editor Area / Diff View / README -->
<div class="editor-area">
{#if activeTab === 'files' && readmeContent && !currentFile}
<div class="readme-section">
<div class="readme-header">
<h3>README</h3>
<div class="readme-actions">
<a href={`/api/repos/${npub}/${repo}/raw?path=${readmePath}`} target="_blank" class="raw-link">View Raw</a>
<a href={`/api/repos/${npub}/${repo}/download?format=zip`} class="download-link">Download ZIP</a>
</div>
</div>
{#if loadingReadme}
<div class="loading">Loading README...</div>
{:else if readmeIsMarkdown && readmeHtml}
<div class="readme-content markdown">
{@html readmeHtml}
</div>
{:else if readmeContent}
<pre class="readme-content"><code>{readmeContent}</code></pre>
{/if}
</div>
{/if}
{#if activeTab === 'files' && currentFile}
<div class="editor-header">
<span class="file-path">{currentFile}</span>
@ -1343,10 +1564,64 @@ @@ -1343,10 +1564,64 @@
background: white;
}
.repo-banner {
width: 100%;
height: 300px;
overflow: hidden;
background: #f3f4f6;
margin-bottom: 1rem;
}
.repo-banner img {
width: 100%;
height: 100%;
object-fit: cover;
}
.header-left {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.repo-title-section {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.repo-image {
width: 64px;
height: 64px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.repo-description-header {
margin: 0.25rem 0 0 0;
color: #666;
font-size: 0.9rem;
}
.fork-badge {
padding: 0.25rem 0.5rem;
background: #e0e7ff;
color: #3730a3;
border-radius: 4px;
font-size: 0.85rem;
margin-left: 0.5rem;
}
.fork-badge a {
color: #3730a3;
text-decoration: none;
}
.fork-badge a:hover {
text-decoration: underline;
}
.back-link {

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

@ -0,0 +1,80 @@ @@ -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'
};
}
};

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

@ -0,0 +1,348 @@ @@ -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>

255
src/routes/search/+page.svelte

@ -0,0 +1,255 @@ @@ -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>

280
src/routes/users/[npub]/+page.svelte

@ -0,0 +1,280 @@ @@ -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…
Cancel
Save