6 changed files with 2805 additions and 3 deletions
@ -0,0 +1,476 @@ |
|||||||
|
/** |
||||||
|
* Service for fetching git repository data from various hosting platforms |
||||||
|
* Supports GitHub, GitLab, Gitea, and other git hosting services |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface GitRepoInfo { |
||||||
|
name: string; |
||||||
|
description?: string; |
||||||
|
url: string; |
||||||
|
defaultBranch: string; |
||||||
|
branches: GitBranch[]; |
||||||
|
commits: GitCommit[]; |
||||||
|
files: GitFile[]; |
||||||
|
readme?: { |
||||||
|
path: string; |
||||||
|
content: string; |
||||||
|
format: 'markdown' | 'asciidoc'; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export interface GitBranch { |
||||||
|
name: string; |
||||||
|
commit: { |
||||||
|
sha: string; |
||||||
|
message: string; |
||||||
|
author: string; |
||||||
|
date: string; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export interface GitCommit { |
||||||
|
sha: string; |
||||||
|
message: string; |
||||||
|
author: string; |
||||||
|
date: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface GitFile { |
||||||
|
name: string; |
||||||
|
path: string; |
||||||
|
type: 'file' | 'dir'; |
||||||
|
size?: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse git URL to extract platform, owner, and repo |
||||||
|
*/ |
||||||
|
function parseGitUrl(url: string): { platform: string; owner: string; repo: string; baseUrl: string } | null { |
||||||
|
// GitHub
|
||||||
|
const githubMatch = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); |
||||||
|
if (githubMatch) { |
||||||
|
return { |
||||||
|
platform: 'github', |
||||||
|
owner: githubMatch[1], |
||||||
|
repo: githubMatch[2].replace(/\.git$/, ''), |
||||||
|
baseUrl: 'https://api.github.com' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// GitLab
|
||||||
|
const gitlabMatch = url.match(/gitlab\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); |
||||||
|
if (gitlabMatch) { |
||||||
|
return { |
||||||
|
platform: 'gitlab', |
||||||
|
owner: gitlabMatch[1], |
||||||
|
repo: gitlabMatch[2].replace(/\.git$/, ''), |
||||||
|
baseUrl: 'https://gitlab.com/api/v4' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Gitea (generic pattern)
|
||||||
|
const giteaMatch = url.match(/(https?:\/\/[^/]+)[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); |
||||||
|
if (giteaMatch) { |
||||||
|
return { |
||||||
|
platform: 'gitea', |
||||||
|
owner: giteaMatch[2], |
||||||
|
repo: giteaMatch[3].replace(/\.git$/, ''), |
||||||
|
baseUrl: `${giteaMatch[1]}/api/v1` |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch repository data from GitHub |
||||||
|
*/ |
||||||
|
async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo | null> { |
||||||
|
try { |
||||||
|
const repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`); |
||||||
|
if (!repoResponse.ok) { |
||||||
|
console.warn(`GitHub API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`); |
||||||
|
return null; |
||||||
|
} |
||||||
|
const repoData = await repoResponse.json(); |
||||||
|
|
||||||
|
const defaultBranch = repoData.default_branch || 'main'; |
||||||
|
const [branchesResponse, commitsResponse, treeResponse] = await Promise.all([ |
||||||
|
fetch(`https://api.github.com/repos/${owner}/${repo}/branches`), |
||||||
|
fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`), |
||||||
|
fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => null) |
||||||
|
]); |
||||||
|
|
||||||
|
// Check if responses are OK and parse JSON
|
||||||
|
let branchesData: any[] = []; |
||||||
|
let commitsData: any[] = []; |
||||||
|
let treeData: any = null; |
||||||
|
|
||||||
|
if (branchesResponse && branchesResponse.ok) { |
||||||
|
branchesData = await branchesResponse.json(); |
||||||
|
if (!Array.isArray(branchesData)) { |
||||||
|
console.warn('GitHub branches response is not an array:', branchesData); |
||||||
|
branchesData = []; |
||||||
|
} |
||||||
|
} else { |
||||||
|
console.warn(`GitHub API error for branches: ${branchesResponse?.status || 'unknown'}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (commitsResponse && commitsResponse.ok) { |
||||||
|
commitsData = await commitsResponse.json(); |
||||||
|
if (!Array.isArray(commitsData)) { |
||||||
|
console.warn('GitHub commits response is not an array:', commitsData); |
||||||
|
commitsData = []; |
||||||
|
} |
||||||
|
} else { |
||||||
|
console.warn(`GitHub API error for commits: ${commitsResponse?.status || 'unknown'}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (treeResponse && treeResponse.ok) { |
||||||
|
treeData = await treeResponse.json(); |
||||||
|
} |
||||||
|
|
||||||
|
// Create a map of commit SHAs to commit details for lookup
|
||||||
|
const commitMap = new Map<string, { message: string; author: string; date: string }>(); |
||||||
|
for (const c of commitsData) { |
||||||
|
if (c.sha) { |
||||||
|
const commitObj = c.commit || {}; |
||||||
|
commitMap.set(c.sha, { |
||||||
|
message: commitObj.message ? commitObj.message.split('\n')[0] : '', |
||||||
|
author: commitObj.author?.name || commitObj.committer?.name || 'Unknown', |
||||||
|
date: commitObj.author?.date || commitObj.committer?.date || new Date().toISOString() |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const branches: GitBranch[] = branchesData.map((b: any) => { |
||||||
|
const commitSha = b.commit?.sha || ''; |
||||||
|
// Try to get commit details from the commit object first, then fall back to our commit map
|
||||||
|
const commitObj = b.commit?.commit || {}; |
||||||
|
let commitMessage = commitObj.message ? commitObj.message.split('\n')[0] : ''; |
||||||
|
let commitAuthor = commitObj.author?.name || commitObj.committer?.name || ''; |
||||||
|
let commitDate = commitObj.author?.date || commitObj.committer?.date || ''; |
||||||
|
|
||||||
|
// If commit details are missing, try to find them in our commit map
|
||||||
|
if (!commitMessage && commitSha) { |
||||||
|
const mappedCommit = commitMap.get(commitSha); |
||||||
|
if (mappedCommit) { |
||||||
|
commitMessage = mappedCommit.message; |
||||||
|
commitAuthor = mappedCommit.author; |
||||||
|
commitDate = mappedCommit.date; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Final fallbacks
|
||||||
|
if (!commitMessage) commitMessage = 'No commit message'; |
||||||
|
if (!commitAuthor) commitAuthor = 'Unknown'; |
||||||
|
if (!commitDate) commitDate = new Date().toISOString(); |
||||||
|
|
||||||
|
return { |
||||||
|
name: b.name || '', |
||||||
|
commit: { |
||||||
|
sha: commitSha, |
||||||
|
message: commitMessage, |
||||||
|
author: commitAuthor, |
||||||
|
date: commitDate |
||||||
|
} |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
const commits: GitCommit[] = commitsData.map((c: any) => { |
||||||
|
const commitObj = c.commit || {}; |
||||||
|
const message = commitObj.message ? commitObj.message.split('\n')[0] : ''; |
||||||
|
const author = commitObj.author?.name || commitObj.committer?.name || 'Unknown'; |
||||||
|
const date = commitObj.author?.date || commitObj.committer?.date || new Date().toISOString(); |
||||||
|
|
||||||
|
return { |
||||||
|
sha: c.sha || '', |
||||||
|
message: message, |
||||||
|
author: author, |
||||||
|
date: date |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
const files: GitFile[] = treeData?.tree?.filter((item: any) => item.type === 'blob' || item.type === 'tree').map((item: any) => ({ |
||||||
|
name: item.path.split('/').pop(), |
||||||
|
path: item.path, |
||||||
|
type: item.type === 'tree' ? 'dir' : 'file', |
||||||
|
size: item.size |
||||||
|
})) || []; |
||||||
|
|
||||||
|
// Try to fetch README (prioritize .adoc over .md)
|
||||||
|
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; |
||||||
|
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; |
||||||
|
for (const readmeFile of readmeFiles) { |
||||||
|
try { |
||||||
|
const readmeData = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}`).then(r => { |
||||||
|
if (!r.ok) throw new Error('Not found'); |
||||||
|
return r.json(); |
||||||
|
}); |
||||||
|
if (readmeData.content) { |
||||||
|
const content = atob(readmeData.content.replace(/\s/g, '')); |
||||||
|
readme = { |
||||||
|
path: readmeFile, |
||||||
|
content, |
||||||
|
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown' |
||||||
|
}; |
||||||
|
break; // Found a README, stop searching
|
||||||
|
} |
||||||
|
} catch { |
||||||
|
continue; // Try next file
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
name: repoData.name, |
||||||
|
description: repoData.description, |
||||||
|
url: repoData.html_url, |
||||||
|
defaultBranch: repoData.default_branch, |
||||||
|
branches, |
||||||
|
commits, |
||||||
|
files, |
||||||
|
readme |
||||||
|
}; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error fetching from GitHub:', error); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch repository data from GitLab |
||||||
|
*/ |
||||||
|
async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Promise<GitRepoInfo | null> { |
||||||
|
try { |
||||||
|
const projectPath = `${owner}/${repo}`; |
||||||
|
const encodedPath = encodeURIComponent(projectPath); |
||||||
|
|
||||||
|
const [repoData, branchesData, commitsData] = await Promise.all([ |
||||||
|
fetch(`${baseUrl}/projects/${encodedPath}`).then(r => r.json()), |
||||||
|
fetch(`${baseUrl}/projects/${encodedPath}/repository/branches`).then(r => r.json()), |
||||||
|
fetch(`${baseUrl}/projects/${encodedPath}/repository/commits?per_page=10`).then(r => r.json()) |
||||||
|
]); |
||||||
|
|
||||||
|
const branches: GitBranch[] = branchesData.map((b: any) => ({ |
||||||
|
name: b.name, |
||||||
|
commit: { |
||||||
|
sha: b.commit.id, |
||||||
|
message: b.commit.message.split('\n')[0], |
||||||
|
author: b.commit.author_name, |
||||||
|
date: b.commit.committed_date |
||||||
|
} |
||||||
|
})); |
||||||
|
|
||||||
|
const commits: GitCommit[] = commitsData.map((c: any) => ({ |
||||||
|
sha: c.id, |
||||||
|
message: c.message.split('\n')[0], |
||||||
|
author: c.author_name, |
||||||
|
date: c.committed_date |
||||||
|
})); |
||||||
|
|
||||||
|
// Fetch file tree
|
||||||
|
let files: GitFile[] = []; |
||||||
|
try { |
||||||
|
const treeData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/tree?recursive=true&per_page=100`).then(r => r.json()); |
||||||
|
files = treeData.map((item: any) => ({ |
||||||
|
name: item.name, |
||||||
|
path: item.path, |
||||||
|
type: item.type === 'tree' ? 'dir' : 'file', |
||||||
|
size: item.size |
||||||
|
})); |
||||||
|
} catch { |
||||||
|
// Tree fetch failed
|
||||||
|
} |
||||||
|
|
||||||
|
// Try to fetch README (prioritize .adoc over .md)
|
||||||
|
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; |
||||||
|
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; |
||||||
|
for (const readmeFile of readmeFiles) { |
||||||
|
try { |
||||||
|
const fileData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/files/${encodeURIComponent(readmeFile)}/raw?ref=${repoData.default_branch}`).then(r => { |
||||||
|
if (!r.ok) throw new Error('Not found'); |
||||||
|
return r.text(); |
||||||
|
}); |
||||||
|
readme = { |
||||||
|
path: readmeFile, |
||||||
|
content: fileData, |
||||||
|
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown' |
||||||
|
}; |
||||||
|
break; // Found a README, stop searching
|
||||||
|
} catch { |
||||||
|
continue; // Try next file
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
name: repoData.name, |
||||||
|
description: repoData.description, |
||||||
|
url: repoData.web_url, |
||||||
|
defaultBranch: repoData.default_branch, |
||||||
|
branches, |
||||||
|
commits, |
||||||
|
files, |
||||||
|
readme |
||||||
|
}; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error fetching from GitLab:', error); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch repository data from Gitea |
||||||
|
*/ |
||||||
|
async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise<GitRepoInfo | null> { |
||||||
|
try { |
||||||
|
const [repoData, branchesData, commitsData] = await Promise.all([ |
||||||
|
fetch(`${baseUrl}/repos/${owner}/${repo}`).then(r => r.json()), |
||||||
|
fetch(`${baseUrl}/repos/${owner}/${repo}/branches`).then(r => r.json()), |
||||||
|
fetch(`${baseUrl}/repos/${owner}/${repo}/commits?limit=10`).then(r => r.json()) |
||||||
|
]); |
||||||
|
|
||||||
|
const branches: GitBranch[] = branchesData.map((b: any) => ({ |
||||||
|
name: b.name, |
||||||
|
commit: { |
||||||
|
sha: b.commit.id, |
||||||
|
message: b.commit.message.split('\n')[0], |
||||||
|
author: b.commit.author.name, |
||||||
|
date: b.commit.timestamp |
||||||
|
} |
||||||
|
})); |
||||||
|
|
||||||
|
const commits: GitCommit[] = commitsData.map((c: any) => ({ |
||||||
|
sha: c.sha, |
||||||
|
message: c.commit.message.split('\n')[0], |
||||||
|
author: c.commit.author.name, |
||||||
|
date: c.commit.timestamp |
||||||
|
})); |
||||||
|
|
||||||
|
// Fetch file tree
|
||||||
|
let files: GitFile[] = []; |
||||||
|
try { |
||||||
|
const treeData = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents?ref=${repoData.default_branch}`).then(r => r.json()); |
||||||
|
files = treeData.map((item: any) => ({ |
||||||
|
name: item.name, |
||||||
|
path: item.path, |
||||||
|
type: item.type === 'dir' ? 'dir' : 'file', |
||||||
|
size: item.size |
||||||
|
})); |
||||||
|
} catch { |
||||||
|
// Tree fetch failed
|
||||||
|
} |
||||||
|
|
||||||
|
// Try to fetch README (prioritize .adoc over .md)
|
||||||
|
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; |
||||||
|
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; |
||||||
|
for (const readmeFile of readmeFiles) { |
||||||
|
try { |
||||||
|
const fileData = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents/${readmeFile}?ref=${repoData.default_branch}`).then(r => { |
||||||
|
if (!r.ok) throw new Error('Not found'); |
||||||
|
return r.json(); |
||||||
|
}); |
||||||
|
if (fileData.content) { |
||||||
|
const content = atob(fileData.content.replace(/\s/g, '')); |
||||||
|
readme = { |
||||||
|
path: readmeFile, |
||||||
|
content, |
||||||
|
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown' |
||||||
|
}; |
||||||
|
break; // Found a README, stop searching
|
||||||
|
} |
||||||
|
} catch { |
||||||
|
continue; // Try next file
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
name: repoData.name, |
||||||
|
description: repoData.description, |
||||||
|
url: repoData.html_url, |
||||||
|
defaultBranch: repoData.default_branch, |
||||||
|
branches, |
||||||
|
commits, |
||||||
|
files, |
||||||
|
readme |
||||||
|
}; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error fetching from Gitea:', error); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch repository data from a git URL |
||||||
|
*/ |
||||||
|
export async function fetchGitRepo(url: string): Promise<GitRepoInfo | null> { |
||||||
|
const parsed = parseGitUrl(url); |
||||||
|
if (!parsed) { |
||||||
|
console.error('Unable to parse git URL:', url); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const { platform, owner, repo, baseUrl } = parsed; |
||||||
|
|
||||||
|
switch (platform) { |
||||||
|
case 'github': |
||||||
|
return fetchFromGitHub(owner, repo); |
||||||
|
case 'gitlab': |
||||||
|
return fetchFromGitLab(owner, repo, baseUrl); |
||||||
|
case 'gitea': |
||||||
|
return fetchFromGitea(owner, repo, baseUrl); |
||||||
|
default: |
||||||
|
console.error('Unsupported platform:', platform); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convert SSH git URL to HTTPS format |
||||||
|
*/ |
||||||
|
function convertSshToHttps(url: string): string | null { |
||||||
|
// Handle git@host:user/repo.git format
|
||||||
|
const sshMatch = url.match(/git@([^:]+):(.+?)(?:\.git)?$/); |
||||||
|
if (sshMatch) { |
||||||
|
const [, host, path] = sshMatch; |
||||||
|
return `https://${host}/${path}${path.endsWith('.git') ? '' : '.git'}`; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract git URLs from a Nostr event |
||||||
|
*/ |
||||||
|
export function extractGitUrls(event: { tags: string[][]; content: string }): string[] { |
||||||
|
const urls: string[] = []; |
||||||
|
|
||||||
|
// Check tags for git URLs (including 'clone' tag which is used in NIP-34)
|
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'r' || tag[0] === 'url' || tag[0] === 'git' || tag[0] === 'clone') { |
||||||
|
const url = tag[1]; |
||||||
|
if (!url) continue; |
||||||
|
|
||||||
|
// Convert SSH URLs to HTTPS
|
||||||
|
if (url.startsWith('git@')) { |
||||||
|
const httpsUrl = convertSshToHttps(url); |
||||||
|
if (httpsUrl) { |
||||||
|
urls.push(httpsUrl); |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Check if it's a git URL
|
||||||
|
if (url.includes('github.com') || url.includes('gitlab.com') || url.includes('gitea') || url.includes('.git') || url.startsWith('http')) { |
||||||
|
urls.push(url); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Check content for git URLs
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+\.git|https?:\/\/(?:github|gitlab|gitea)[^\s]+)/gi; |
||||||
|
const matches = event.content.match(urlRegex); |
||||||
|
if (matches) { |
||||||
|
urls.push(...matches); |
||||||
|
} |
||||||
|
|
||||||
|
return [...new Set(urls)]; // Deduplicate
|
||||||
|
} |
||||||
@ -0,0 +1,357 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Header from '../../lib/components/layout/Header.svelte'; |
||||||
|
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../lib/services/nostr/relay-manager.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '../../lib/types/nostr.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
|
||||||
|
import { KIND } from '../../lib/types/kind-lookup.js'; |
||||||
|
|
||||||
|
let repos = $state<NostrEvent[]>([]); |
||||||
|
let loading = $state(true); |
||||||
|
let searchQuery = $state(''); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
await loadRepos(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadRepos() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const relays = relayManager.getProfileReadRelays(); |
||||||
|
|
||||||
|
// Fetch repo announcement events |
||||||
|
const allRepos: NostrEvent[] = []; |
||||||
|
|
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 }], |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
// For parameterized replaceable events, get the newest version of each (by pubkey + d tag) |
||||||
|
const reposByKey = new Map<string, NostrEvent>(); |
||||||
|
for (const event of events) { |
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
||||||
|
const key = `${event.pubkey}:${dTag}`; |
||||||
|
const existing = reposByKey.get(key); |
||||||
|
if (!existing || event.created_at > existing.created_at) { |
||||||
|
reposByKey.set(key, event); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
allRepos.push(...Array.from(reposByKey.values())); |
||||||
|
|
||||||
|
// Sort by created_at descending |
||||||
|
repos = allRepos.sort((a, b) => b.created_at - a.created_at); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading repos:', error); |
||||||
|
repos = []; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getRepoName(event: NostrEvent): string { |
||||||
|
// Try to get name from tags first |
||||||
|
const nameTag = event.tags.find(t => t[0] === 'name' || t[0] === 'title'); |
||||||
|
if (nameTag?.[1]) return nameTag[1]; |
||||||
|
|
||||||
|
// Try to get from content (might be JSON) |
||||||
|
try { |
||||||
|
const content = JSON.parse(event.content); |
||||||
|
if (content.name) return content.name; |
||||||
|
if (content.title) return content.title; |
||||||
|
} catch { |
||||||
|
// Not JSON, try to extract from content |
||||||
|
if (event.content) { |
||||||
|
const lines = event.content.split('\n'); |
||||||
|
for (const line of lines) { |
||||||
|
if (line.toLowerCase().includes('name:') || line.toLowerCase().includes('title:')) { |
||||||
|
const match = line.match(/:\s*(.+)/); |
||||||
|
if (match) return match[1].trim(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback to d tag or identifier |
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
||||||
|
if (dTag) return dTag; |
||||||
|
|
||||||
|
return `Repository ${event.id.slice(0, 8)}`; |
||||||
|
} |
||||||
|
|
||||||
|
function getRepoDescription(event: NostrEvent): string { |
||||||
|
// Try to get description from tags |
||||||
|
const descTag = event.tags.find(t => t[0] === 'description' || t[0] === 'summary'); |
||||||
|
if (descTag?.[1]) return descTag[1]; |
||||||
|
|
||||||
|
// Try to get from content |
||||||
|
try { |
||||||
|
const content = JSON.parse(event.content); |
||||||
|
if (content.description) return content.description; |
||||||
|
if (content.summary) return content.summary; |
||||||
|
} catch { |
||||||
|
// Not JSON, use content as-is if it's not too long |
||||||
|
if (event.content && event.content.length < 200) { |
||||||
|
return event.content; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
function getNaddr(event: NostrEvent): string { |
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
||||||
|
try { |
||||||
|
return nip19.naddrEncode({ |
||||||
|
kind: event.kind, |
||||||
|
pubkey: event.pubkey, |
||||||
|
identifier: dTag, |
||||||
|
relays: [] |
||||||
|
}); |
||||||
|
} catch { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function openRepo(event: NostrEvent) { |
||||||
|
const naddr = getNaddr(event); |
||||||
|
if (naddr) { |
||||||
|
goto(`/repos/${naddr}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Filter repos based on search query |
||||||
|
let filteredRepos = $derived.by(() => { |
||||||
|
if (!searchQuery.trim()) return repos; |
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase(); |
||||||
|
return repos.filter(repo => { |
||||||
|
const name = getRepoName(repo).toLowerCase(); |
||||||
|
const desc = getRepoDescription(repo).toLowerCase(); |
||||||
|
return name.includes(query) || desc.includes(query); |
||||||
|
}); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<Header /> |
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8"> |
||||||
|
<div class="repos-header mb-6"> |
||||||
|
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text font-mono">/Repos</h1> |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2 mb-4"> |
||||||
|
Discover and explore repositories announced on Nostr |
||||||
|
</p> |
||||||
|
|
||||||
|
<div class="search-container mb-4"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
bind:value={searchQuery} |
||||||
|
placeholder="Search repositories..." |
||||||
|
class="search-input" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<div class="loading-state"> |
||||||
|
<p class="text-fog-text dark:text-fog-dark-text">Loading repositories...</p> |
||||||
|
</div> |
||||||
|
{:else if filteredRepos.length === 0} |
||||||
|
<div class="empty-state"> |
||||||
|
<p class="text-fog-text dark:text-fog-dark-text"> |
||||||
|
{searchQuery ? 'No repositories found matching your search.' : 'No repositories found.'} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="repos-list"> |
||||||
|
{#each filteredRepos as repo (repo.id)} |
||||||
|
<div |
||||||
|
class="repo-item" |
||||||
|
onclick={() => openRepo(repo)} |
||||||
|
onkeydown={(e) => { |
||||||
|
if (e.key === 'Enter' || e.key === ' ') { |
||||||
|
e.preventDefault(); |
||||||
|
openRepo(repo); |
||||||
|
} |
||||||
|
}} |
||||||
|
role="button" |
||||||
|
tabindex="0" |
||||||
|
> |
||||||
|
<div class="repo-header"> |
||||||
|
<h3 class="repo-name">{getRepoName(repo)}</h3> |
||||||
|
<span class="repo-kind">Kind {repo.kind}</span> |
||||||
|
</div> |
||||||
|
{#if getRepoDescription(repo)} |
||||||
|
<p class="repo-description">{getRepoDescription(repo)}</p> |
||||||
|
{/if} |
||||||
|
<div class="repo-meta"> |
||||||
|
<span class="repo-date"> |
||||||
|
{new Date(repo.created_at * 1000).toLocaleDateString()} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
|
||||||
|
<style> |
||||||
|
main { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
} |
||||||
|
|
||||||
|
.repos-header { |
||||||
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
padding-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .repos-header { |
||||||
|
border-bottom-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.search-container { |
||||||
|
max-width: 500px; |
||||||
|
} |
||||||
|
|
||||||
|
.search-input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.375rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-input { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.search-input:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-input:focus { |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.loading-state, |
||||||
|
.empty-state { |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
.repos-list { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-item { |
||||||
|
padding: 1.5rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.5rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
overflow-wrap: break-word; |
||||||
|
word-wrap: break-word; |
||||||
|
min-width: 0; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .repo-item { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
.repo-item:hover { |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||||
|
transform: translateY(-2px); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .repo-item:hover { |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); |
||||||
|
} |
||||||
|
|
||||||
|
.repo-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: flex-start; |
||||||
|
gap: 1rem; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
min-width: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-name { |
||||||
|
font-size: 1.25rem; |
||||||
|
font-weight: 600; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
margin: 0; |
||||||
|
flex: 1; |
||||||
|
min-width: 0; |
||||||
|
word-break: break-word; |
||||||
|
overflow-wrap: break-word; |
||||||
|
word-wrap: break-word; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .repo-name { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.repo-kind { |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-text-light, #6b7280); |
||||||
|
padding: 0.25rem 0.5rem; |
||||||
|
border-radius: 0.25rem; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
flex-shrink: 0; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .repo-kind { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.repo-description { |
||||||
|
color: var(--fog-text-light, #6b7280); |
||||||
|
margin: 0.5rem 0; |
||||||
|
line-height: 1.5; |
||||||
|
word-break: break-word; |
||||||
|
overflow-wrap: break-word; |
||||||
|
word-wrap: break-word; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .repo-description { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
.repo-meta { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
margin-top: 0.75rem; |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-text-light, #6b7280); |
||||||
|
word-break: break-word; |
||||||
|
overflow-wrap: break-word; |
||||||
|
word-wrap: break-word; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .repo-meta { |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
} |
||||||
|
</style> |
||||||
Loading…
Reference in new issue