6 changed files with 2805 additions and 3 deletions
@ -0,0 +1,476 @@
@@ -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 @@
@@ -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