You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1114 lines
36 KiB
1114 lines
36 KiB
<script lang="ts"> |
|
import { onMount } from 'svelte'; |
|
import { goto } from '$app/navigation'; |
|
import { page } from '$app/stores'; |
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
|
import { KIND } from '$lib/types/nostr.js'; |
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; |
|
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; |
|
import { userStore } from '$lib/stores/user-store.js'; |
|
import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; |
|
|
|
// Registered repos (with domain in clone URLs) |
|
// Also includes local-only forks (marked with isLocalOnly flag) |
|
let registeredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string; isLocalOnly?: boolean }>>([]); |
|
let allRegisteredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string; isLocalOnly?: boolean }>>([]); |
|
|
|
// Local clones (repos without domain in clone URLs) |
|
let localRepos = $state<Array<{ npub: string; repoName: string; announcement: NostrEvent | null; lastModified: number }>>([]); |
|
let allLocalRepos = $state<Array<{ npub: string; repoName: string; announcement: NostrEvent | null; lastModified: number }>>([]); |
|
|
|
let loading = $state(true); |
|
let loadingLocal = $state(false); |
|
let error = $state<string | null>(null); |
|
let forkCounts = $state<Map<string, number>>(new Map()); |
|
let userPubkey = $state<string | null>(null); |
|
let userPubkeyHex = $state<string | null>(null); |
|
let contactPubkeys = $state<Set<string>>(new Set()); |
|
let deletingRepo = $state<{ npub: string; repo: string } | null>(null); |
|
|
|
// User's own repositories (where they are owner or maintainer) |
|
// Also includes repos they transferred away (marked as transferred) |
|
let myRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string; transferred?: boolean; currentOwner?: string }>>([]); |
|
let loadingMyRepos = $state(false); |
|
|
|
// Most Favorited Repositories |
|
let mostFavoritedRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string; favoriteCount: number }>>([]); |
|
let loadingMostFavorited = $state(false); |
|
let mostFavoritedPage = $state(0); |
|
let mostFavoritedCache = $state<{ data: typeof mostFavoritedRepos; timestamp: number } | null>(null); |
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes |
|
|
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
|
const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS); |
|
|
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
|
|
onMount(async () => { |
|
await loadRepos(); |
|
await loadUserAndContacts(); |
|
await loadMyRepos(); |
|
await loadMostFavoritedRepos(); |
|
}); |
|
|
|
// Reload repos when page becomes visible (e.g., after returning from another page) |
|
$effect(() => { |
|
if (typeof document !== 'undefined') { |
|
const handleVisibilityChange = () => { |
|
if (document.visibilityState === 'visible') { |
|
// Reload repos when page becomes visible to catch newly published repos |
|
loadRepos().catch(err => console.warn('Failed to reload repos on visibility change:', err)); |
|
loadMyRepos().catch(err => console.warn('Failed to reload my repos on visibility change:', err)); |
|
} |
|
}; |
|
document.addEventListener('visibilitychange', handleVisibilityChange); |
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange); |
|
} |
|
}); |
|
|
|
// Reload my repos when navigating to repos page (e.g., after transfer) |
|
$effect(() => { |
|
if ($page.url.pathname === '/repos' && userPubkeyHex) { |
|
loadMyRepos().catch(err => console.warn('Failed to reload my repos on page navigation:', err)); |
|
} |
|
}); |
|
|
|
// Sync with userStore - if userStore says logged out, clear local state |
|
$effect(() => { |
|
const currentUser = $userStore; |
|
if (!currentUser.userPubkey || !currentUser.userPubkeyHex) { |
|
// User is logged out according to store - clear local state |
|
const wasLoggedIn = userPubkey !== null || userPubkeyHex !== null; |
|
userPubkey = null; |
|
userPubkeyHex = null; |
|
myRepos = []; |
|
contactPubkeys.clear(); |
|
|
|
// If user was logged in before, reload repos to hide private ones |
|
if (wasLoggedIn) { |
|
loadRepos().catch(err => console.warn('Failed to reload repos after logout:', err)); |
|
loadLocalRepos().catch(err => console.warn('Failed to reload local repos after logout:', err)); |
|
} |
|
} else if (currentUser.userPubkey && currentUser.userPubkeyHex) { |
|
// User is logged in according to store - sync local state |
|
// Only update if different to avoid unnecessary reloads |
|
const wasDifferent = userPubkey !== currentUser.userPubkey || userPubkeyHex !== currentUser.userPubkeyHex; |
|
const wasLoggedOut = userPubkey === null && userPubkeyHex === null; |
|
|
|
if (wasDifferent) { |
|
userPubkey = currentUser.userPubkey; |
|
userPubkeyHex = currentUser.userPubkeyHex; |
|
|
|
// Reload everything when user logs in or pubkey changes |
|
loadRepos().catch(err => console.warn('Failed to reload repos after login:', err)); |
|
loadLocalRepos().catch(err => console.warn('Failed to reload local repos after login:', err)); |
|
loadMyRepos().catch(err => console.warn('Failed to load my repos after store sync:', err)); |
|
loadContacts().catch(err => console.warn('Failed to load contacts after store sync:', err)); |
|
} |
|
} |
|
}); |
|
|
|
async function loadUserAndContacts() { |
|
// Check userStore first - if user is logged out, don't try to get pubkey |
|
const currentUser = $userStore; |
|
if (!currentUser.userPubkey || !currentUser.userPubkeyHex) { |
|
userPubkey = null; |
|
userPubkeyHex = null; |
|
contactPubkeys.clear(); |
|
return; |
|
} |
|
|
|
// If userStore has user info, use it |
|
if (currentUser.userPubkey && currentUser.userPubkeyHex) { |
|
userPubkey = currentUser.userPubkey; |
|
userPubkeyHex = currentUser.userPubkeyHex; |
|
contactPubkeys.add(userPubkeyHex); // Include user's own repos |
|
|
|
// Still fetch contacts even if we have store data |
|
await loadContacts(); |
|
return; |
|
} |
|
|
|
// Fallback: try to get from NIP-07 if store doesn't have it |
|
if (!isNIP07Available()) { |
|
return; |
|
} |
|
|
|
try { |
|
const pubkey = await getPublicKeyWithNIP07(); |
|
if (!pubkey) return; |
|
|
|
userPubkey = pubkey; |
|
|
|
// Convert npub to hex for API calls |
|
// NIP-07 may return either npub or hex, so check format first |
|
if (/^[0-9a-f]{64}$/i.test(userPubkey)) { |
|
// Already hex format |
|
userPubkeyHex = userPubkey.toLowerCase(); |
|
contactPubkeys.add(userPubkeyHex); // Include user's own repos |
|
} else { |
|
// Try to decode as npub |
|
try { |
|
const decoded = nip19.decode(userPubkey); |
|
if (decoded.type === 'npub') { |
|
userPubkeyHex = decoded.data as string; |
|
contactPubkeys.add(userPubkeyHex); // Include user's own repos |
|
} |
|
} catch (err) { |
|
// If decode fails, might still be hex or invalid - skip |
|
console.warn('Failed to decode user pubkey:', err); |
|
} |
|
} |
|
|
|
if (userPubkeyHex) { |
|
await loadContacts(); |
|
} |
|
} catch (err) { |
|
console.warn('Failed to load user or contacts:', err); |
|
} |
|
} |
|
|
|
async function loadContacts() { |
|
if (!userPubkeyHex) return; |
|
|
|
try { |
|
// Fetch user's kind 3 contact list |
|
const contactEvents = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.CONTACT_LIST], |
|
authors: [userPubkeyHex], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (contactEvents.length > 0) { |
|
const contactEvent = contactEvents[0]; |
|
// Extract pubkeys from 'p' tags |
|
for (const tag of contactEvent.tags) { |
|
if (tag[0] === 'p' && tag[1]) { |
|
let pubkey = tag[1]; |
|
// Try to decode if it's an npub |
|
try { |
|
const decoded = nip19.decode(pubkey); |
|
if (decoded.type === 'npub') { |
|
pubkey = decoded.data as string; |
|
} |
|
} catch { |
|
// Assume it's already a hex pubkey |
|
} |
|
if (pubkey) { |
|
contactPubkeys.add(pubkey); |
|
} |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
console.warn('Failed to load contacts:', err); |
|
} |
|
} |
|
|
|
async function loadMyRepos() { |
|
if (!userPubkey || !userPubkeyHex) { |
|
myRepos = []; |
|
return; |
|
} |
|
|
|
loadingMyRepos = true; |
|
try { |
|
// Fetch all repos where user is current owner |
|
const ownerRepos = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [userPubkeyHex], |
|
limit: 100 |
|
} |
|
]); |
|
|
|
const repos: Array<{ event: NostrEvent; npub: string; repoName: string; transferred?: boolean; currentOwner?: string }> = []; |
|
|
|
// Add repos where user is current owner |
|
for (const event of ownerRepos) { |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
|
if (!dTag) continue; |
|
|
|
try { |
|
const npub = nip19.npubEncode(event.pubkey); |
|
repos.push({ |
|
event, |
|
npub, |
|
repoName: dTag, |
|
transferred: false |
|
}); |
|
} catch (err) { |
|
console.warn('Failed to encode npub for repo:', err); |
|
} |
|
} |
|
|
|
// Fetch repos that were transferred FROM this user (where they were original owner) |
|
// Search for transfer events where this user is the 'from' pubkey |
|
const { OwnershipTransferService } = await import('$lib/services/nostr/ownership-transfer-service.js'); |
|
const ownershipService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); |
|
|
|
// Get all repos where user was original owner |
|
const originalOwnerRepos = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [userPubkeyHex], |
|
limit: 100 |
|
} |
|
]); |
|
|
|
for (const originalEvent of originalOwnerRepos) { |
|
const dTag = originalEvent.tags.find(t => t[0] === 'd')?.[1]; |
|
if (!dTag) continue; |
|
|
|
// Check current owner |
|
const currentOwner = await ownershipService.getCurrentOwner(userPubkeyHex, dTag); |
|
|
|
// If current owner is different, this repo was transferred |
|
if (currentOwner !== userPubkeyHex) { |
|
// Fetch the current announcement from the new owner |
|
const currentAnnouncements = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [currentOwner], |
|
'#d': [dTag], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (currentAnnouncements.length > 0) { |
|
try { |
|
const npub = nip19.npubEncode(userPubkeyHex); // Original owner npub |
|
repos.push({ |
|
event: currentAnnouncements[0], // Use current announcement |
|
npub, |
|
repoName: dTag, |
|
transferred: true, |
|
currentOwner |
|
}); |
|
} catch (err) { |
|
console.warn('Failed to encode npub for transferred repo:', err); |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Sort by created_at descending (newest first) |
|
repos.sort((a, b) => b.event.created_at - a.event.created_at); |
|
|
|
myRepos = repos; |
|
} catch (err) { |
|
console.warn('Failed to load my repos:', err); |
|
myRepos = []; |
|
} finally { |
|
loadingMyRepos = false; |
|
} |
|
} |
|
|
|
async function loadRepos(triggerPoll = false) { |
|
loading = true; |
|
error = null; |
|
|
|
try { |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const url = `/api/repos/list?domain=${encodeURIComponent(gitDomain)}`; |
|
|
|
const response = await fetch(url, { |
|
headers: userPubkeyHex ? { |
|
'X-User-Pubkey': userPubkeyHex |
|
} : {} |
|
}); |
|
|
|
if (!response.ok) { |
|
throw new Error(`Failed to load repositories: ${response.statusText}`); |
|
} |
|
|
|
const data = await response.json(); |
|
|
|
// API returns { registered, total } |
|
registeredRepos = data.registered || []; |
|
|
|
// Load local repos and merge them into registered repos list |
|
await loadLocalRepos(); |
|
|
|
// Merge local repos into registered repos list with special icon |
|
const mergedRepos = [...registeredRepos]; |
|
for (const localRepo of localRepos) { |
|
// Check if this local repo is already in registered repos |
|
const exists = mergedRepos.some(r => r.npub === localRepo.npub && r.repoName === localRepo.repoName); |
|
if (!exists && localRepo.announcement) { |
|
// Add local repo to registered list with a flag indicating it's local-only |
|
mergedRepos.push({ |
|
event: localRepo.announcement, |
|
npub: localRepo.npub, |
|
repoName: localRepo.repoName, |
|
isLocalOnly: true // Flag to show special icon |
|
}); |
|
} |
|
} |
|
|
|
registeredRepos = mergedRepos; |
|
allRegisteredRepos = [...registeredRepos]; |
|
|
|
// Load fork counts for registered repos (in parallel, but don't block) |
|
loadForkCounts(registeredRepos.map(r => r.event)).catch(err => { |
|
console.warn('[RepoList] Failed to load some fork counts:', err); |
|
}); |
|
|
|
// If triggerPoll is true, trigger a poll and then refresh the list |
|
if (triggerPoll) { |
|
try { |
|
// Trigger poll (non-blocking) |
|
const pollResponse = await fetch('/api/repos/poll', { |
|
method: 'POST', |
|
headers: userPubkeyHex ? { |
|
'X-User-Pubkey': userPubkeyHex |
|
} : {} |
|
}); |
|
|
|
if (pollResponse.ok) { |
|
// Wait a bit for the poll to process (lazy - don't wait for full completion) |
|
// Give it 2-3 seconds to provision repos |
|
await new Promise(resolve => setTimeout(resolve, 2500)); |
|
|
|
// Refresh the list after poll |
|
await loadRepos(false); |
|
} |
|
} catch (pollErr) { |
|
// Don't fail the whole operation if poll fails |
|
console.warn('[RepoList] Failed to trigger poll:', pollErr); |
|
} |
|
} |
|
} catch (e) { |
|
error = String(e); |
|
console.error('[RepoList] Failed to load repos:', e); |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
|
|
async function loadLocalRepos() { |
|
loadingLocal = true; |
|
|
|
try { |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const url = `/api/repos/local?domain=${encodeURIComponent(gitDomain)}`; |
|
|
|
const response = await fetch(url, { |
|
headers: userPubkeyHex ? { |
|
'X-User-Pubkey': userPubkeyHex |
|
} : {} |
|
}); |
|
|
|
if (!response.ok) { |
|
console.warn('Failed to load local repos:', response.statusText); |
|
return; |
|
} |
|
|
|
const data = await response.json(); |
|
// API returns array of { npub, repoName, announcement } |
|
localRepos = data.map((item: { npub: string; repoName: string; announcement: NostrEvent }) => ({ |
|
npub: item.npub, |
|
repoName: item.repoName, |
|
announcement: item.announcement, |
|
lastModified: item.announcement?.created_at ? item.announcement.created_at * 1000 : Date.now() |
|
})); |
|
allLocalRepos = [...localRepos]; |
|
} catch (e) { |
|
console.warn('[RepoList] Failed to load local repos:', e); |
|
} finally { |
|
loadingLocal = false; |
|
} |
|
} |
|
|
|
async function deleteLocalRepo(npub: string, repo: string) { |
|
if (!confirm(`Are you sure you want to delete the local clone of "${repo}"?\n\nThis will permanently remove the repository from this server. The announcement on Nostr will NOT be deleted.\n\nThis action cannot be undone.\n\nClick OK to delete, or Cancel to abort.`)) { |
|
return; |
|
} |
|
|
|
deletingRepo = { npub, repo }; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/delete`, { |
|
method: 'DELETE', |
|
headers: userPubkeyHex ? { |
|
'X-User-Pubkey': userPubkeyHex |
|
} : {} |
|
}); |
|
|
|
if (!response.ok) { |
|
const error = await response.json(); |
|
throw new Error(error.message || 'Failed to delete repository'); |
|
} |
|
|
|
// Remove from local repos list |
|
localRepos = localRepos.filter(r => !(r.npub === npub && r.repoName === repo)); |
|
allLocalRepos = [...localRepos]; |
|
|
|
alert('Repository deleted successfully'); |
|
} catch (e) { |
|
alert(`Failed to delete repository: ${e instanceof Error ? e.message : String(e)}`); |
|
} finally { |
|
deletingRepo = null; |
|
} |
|
} |
|
|
|
function registerRepo(npub: string, repo: string) { |
|
// Navigate to signup page with repo pre-filled |
|
goto(`/signup?npub=${encodeURIComponent(npub)}&repo=${encodeURIComponent(repo)}`); |
|
} |
|
|
|
async function loadMostFavoritedRepos() { |
|
// Check cache first |
|
if (mostFavoritedCache && Date.now() - mostFavoritedCache.timestamp < CACHE_TTL) { |
|
updateMostFavoritedPage(); |
|
return; |
|
} |
|
|
|
loadingMostFavorited = true; |
|
try { |
|
// Fetch up to 1000 bookmark events (kind 10003) |
|
const bookmarkEvents = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.BOOKMARKS], |
|
limit: 1000 |
|
} |
|
]); |
|
|
|
// Count how many times each repo a-tag appears |
|
const repoCounts = new Map<string, { count: number; aTag: string }>(); |
|
|
|
for (const bookmark of bookmarkEvents) { |
|
for (const tag of bookmark.tags) { |
|
if (tag[0] === 'a' && tag[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) { |
|
const aTag = tag[1]; |
|
const current = repoCounts.get(aTag) || { count: 0, aTag }; |
|
current.count++; |
|
repoCounts.set(aTag, current); |
|
} |
|
} |
|
} |
|
|
|
// Convert to array and sort by count |
|
const repoCountsArray = Array.from(repoCounts.entries()) |
|
.map(([aTag, data]) => ({ aTag, count: data.count })) |
|
.sort((a, b) => b.count - a.count); |
|
|
|
// Fetch repo announcements for the top repos |
|
const topRepos: Array<{ event: NostrEvent; npub: string; repoName: string; favoriteCount: number }> = []; |
|
|
|
for (const { aTag, count } of repoCountsArray.slice(0, 100)) { |
|
// Parse a-tag: 30617:pubkey:d-tag |
|
const parts = aTag.split(':'); |
|
if (parts.length >= 3) { |
|
const pubkey = parts[1]; |
|
const dTag = parts[2]; |
|
|
|
// Fetch the repo announcement |
|
const announcements = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [pubkey], |
|
'#d': [dTag], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (announcements.length > 0) { |
|
try { |
|
const npub = nip19.npubEncode(pubkey); |
|
topRepos.push({ |
|
event: announcements[0], |
|
npub, |
|
repoName: dTag, |
|
favoriteCount: count |
|
}); |
|
} catch (err) { |
|
console.warn('Failed to encode npub for favorited repo:', err); |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Cache the results |
|
mostFavoritedCache = { |
|
data: topRepos, |
|
timestamp: Date.now() |
|
}; |
|
|
|
updateMostFavoritedPage(); |
|
} catch (err) { |
|
console.error('Failed to load most favorited repos:', err); |
|
} finally { |
|
loadingMostFavorited = false; |
|
} |
|
} |
|
|
|
function updateMostFavoritedPage() { |
|
if (!mostFavoritedCache) return; |
|
|
|
const start = mostFavoritedPage * 10; |
|
const end = start + 10; |
|
mostFavoritedRepos = mostFavoritedCache.data.slice(start, end); |
|
} |
|
|
|
function nextMostFavoritedPage() { |
|
if (!mostFavoritedCache) return; |
|
const maxPage = Math.ceil(mostFavoritedCache.data.length / 10) - 1; |
|
if (mostFavoritedPage < maxPage) { |
|
mostFavoritedPage++; |
|
updateMostFavoritedPage(); |
|
} |
|
} |
|
|
|
function prevMostFavoritedPage() { |
|
if (mostFavoritedPage > 0) { |
|
mostFavoritedPage--; |
|
updateMostFavoritedPage(); |
|
} |
|
} |
|
|
|
// Background refresh of cache |
|
async function refreshMostFavoritedCache() { |
|
if (!mostFavoritedCache || Date.now() - mostFavoritedCache.timestamp >= CACHE_TTL) { |
|
await loadMostFavoritedRepos(); |
|
} |
|
} |
|
|
|
// Start background refresh |
|
$effect(() => { |
|
if (typeof window !== 'undefined') { |
|
const interval = setInterval(() => { |
|
refreshMostFavoritedCache().catch(err => console.warn('Background refresh failed:', err)); |
|
}, CACHE_TTL); |
|
return () => clearInterval(interval); |
|
} |
|
}); |
|
|
|
async function loadForkCounts(repoEvents: NostrEvent[]) { |
|
const counts = new Map<string, number>(); |
|
|
|
// Extract owner pubkey and repo name for each repo |
|
const forkCountPromises = repoEvents.map(async (event) => { |
|
try { |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
|
if (!dTag) return; |
|
|
|
const repoKey = `${event.pubkey}:${dTag}`; |
|
const count = await forkCountService.getForkCount(event.pubkey, dTag); |
|
counts.set(repoKey, count); |
|
} catch (err) { |
|
// Ignore individual failures |
|
} |
|
}); |
|
|
|
await Promise.all(forkCountPromises); |
|
forkCounts = counts; |
|
} |
|
|
|
function getForkCount(event: NostrEvent): number { |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
|
if (!dTag) return 0; |
|
const repoKey = `${event.pubkey}:${dTag}`; |
|
return forkCounts.get(repoKey) || 0; |
|
} |
|
|
|
function isOwner(npub: string, repoName: string): boolean { |
|
if (!userPubkeyHex) return false; |
|
try { |
|
const decoded = nip19.decode(npub); |
|
if (decoded.type === 'npub') { |
|
return (decoded.data as string) === userPubkeyHex; |
|
} |
|
} catch { |
|
// Invalid npub |
|
} |
|
return false; |
|
} |
|
|
|
function getRepoName(event: NostrEvent): string { |
|
const nameTag = event.tags.find((t: string[]) => t[0] === 'name' && t[1]); |
|
if (nameTag?.[1]) return nameTag[1]; |
|
|
|
const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1]; |
|
if (dTag) return dTag; |
|
|
|
return `Repository ${event.id.slice(0, 8)}`; |
|
} |
|
|
|
function getRepoDescription(event: NostrEvent): string { |
|
const descTag = event.tags.find((t: string[]) => t[0] === 'description' && t[1]); |
|
return descTag?.[1] || ''; |
|
} |
|
|
|
function getRepoImage(event: NostrEvent): string | null { |
|
const imageTag = event.tags.find((t: string[]) => t[0] === 'image' && t[1]); |
|
return imageTag?.[1] || null; |
|
} |
|
|
|
function getRepoBanner(event: NostrEvent): string | null { |
|
const bannerTag = event.tags.find((t: string[]) => t[0] === 'banner' && t[1]); |
|
return bannerTag?.[1] || null; |
|
} |
|
|
|
function getCloneUrls(event: NostrEvent): string[] { |
|
const urls: string[] = []; |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'clone') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const url = tag[i]; |
|
if (url && typeof url === 'string') { |
|
urls.push(url); |
|
} |
|
} |
|
} |
|
} |
|
|
|
return urls; |
|
} |
|
|
|
</script> |
|
|
|
<svelte:head> |
|
<title>Repositories - GitRepublic</title> |
|
<meta name="description" content="Browse repositories on GitRepublic - Decentralized Git Hosting on Nostr" /> |
|
</svelte:head> |
|
|
|
<div class="container"> |
|
<main> |
|
{#if userPubkey && myRepos.length > 0} |
|
<!-- My Repositories Section --> |
|
<div class="repo-section"> |
|
<div class="section-header"> |
|
<h3>My Repositories</h3> |
|
<span class="section-badge">{myRepos.length}</span> |
|
</div> |
|
<div class="repos-list"> |
|
{#each myRepos as item} |
|
{@const repo = item.event} |
|
{@const repoImage = getRepoImage(repo)} |
|
{@const isTransferred = item.transferred || false} |
|
<div class="repo-card repo-card-my" class:transferred={isTransferred}> |
|
<div class="repo-card-content"> |
|
<div class="repo-header"> |
|
<div class="repo-header-text"> |
|
<div class="repo-title-row"> |
|
{#if repoImage} |
|
<img src={repoImage} alt="Repository" class="repo-avatar" /> |
|
{/if} |
|
<h3>{getRepoName(repo)}</h3> |
|
{#if isTransferred} |
|
<span class="transferred-badge" title="Transferred to another owner">↗</span> |
|
{/if} |
|
</div> |
|
{#if getRepoDescription(repo)} |
|
<p class="description">{getRepoDescription(repo)}</p> |
|
{/if} |
|
</div> |
|
<a href="/repos/{item.npub}/{item.repoName}" class="view-button" title="View repository"> |
|
<img src="/icons/arrow-right.svg" alt="View" /> |
|
</a> |
|
</div> |
|
<div class="repo-meta"> |
|
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> |
|
{#if getForkCount(repo) > 0} |
|
{@const forkCount = getForkCount(repo)} |
|
<span class="fork-count"> |
|
<img src="/icons/git-fork.svg" alt="Fork" class="fork-icon" /> |
|
{forkCount} fork{forkCount === 1 ? '' : 's'} |
|
</span> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Most Favorited Repositories --> |
|
<div class="repo-section"> |
|
<div class="section-header"> |
|
<h3>Most Favorited Repositories</h3> |
|
<span class="section-badge">{mostFavoritedRepos.length}</span> |
|
{#if loadingMostFavorited} |
|
<span class="loading-indicator">Loading...</span> |
|
{/if} |
|
</div> |
|
{#if loadingMostFavorited && mostFavoritedRepos.length === 0} |
|
<div class="loading">Loading most favorited repositories...</div> |
|
{:else if mostFavoritedRepos.length === 0} |
|
<div class="empty">No favorited repositories found.</div> |
|
{:else} |
|
<div class="repos-list"> |
|
{#each mostFavoritedRepos as item} |
|
{@const repo = item.event} |
|
{@const repoImage = getRepoImage(repo)} |
|
<div class="repo-card repo-card-favorited"> |
|
<div class="repo-card-content"> |
|
<div class="repo-header"> |
|
<div class="repo-header-text"> |
|
<div class="repo-title-row"> |
|
{#if repoImage} |
|
<img src={repoImage} alt="Repository" class="repo-avatar" /> |
|
{/if} |
|
<h3>{getRepoName(repo)}</h3> |
|
</div> |
|
{#if getRepoDescription(repo)} |
|
<p class="description">{getRepoDescription(repo)}</p> |
|
{/if} |
|
</div> |
|
<a href="/repos/{item.npub}/{item.repoName}" class="view-button" title="View repository"> |
|
<img src="/icons/arrow-right.svg" alt="View" /> |
|
</a> |
|
</div> |
|
<div class="repo-meta"> |
|
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> |
|
<span class="favorite-count">⭐ {item.favoriteCount} {item.favoriteCount === 1 ? 'favorite' : 'favorites'}</span> |
|
{#if getForkCount(repo) > 0} |
|
{@const forkCount = getForkCount(repo)} |
|
<span class="fork-count"> |
|
<img src="/icons/git-fork.svg" alt="Fork" class="fork-icon" /> |
|
{forkCount} fork{forkCount === 1 ? '' : 's'} |
|
</span> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{#if mostFavoritedCache && mostFavoritedCache.data.length > 10} |
|
<div class="pagination"> |
|
<button onclick={prevMostFavoritedPage} disabled={mostFavoritedPage === 0}> |
|
Previous |
|
</button> |
|
<span class="page-info"> |
|
Page {mostFavoritedPage + 1} of {Math.ceil(mostFavoritedCache.data.length / 10)} |
|
</span> |
|
<button |
|
onclick={nextMostFavoritedPage} |
|
disabled={mostFavoritedPage >= Math.ceil(mostFavoritedCache.data.length / 10) - 1} |
|
> |
|
Next |
|
</button> |
|
</div> |
|
{/if} |
|
{/if} |
|
</div> |
|
|
|
<div class="repos-header"> |
|
<h2>Repositories on {$page.data.gitDomain || 'localhost:6543'}</h2> |
|
<button onclick={() => loadRepos(true)} disabled={loading}> |
|
{loading ? 'Loading...' : 'Refresh'} |
|
</button> |
|
</div> |
|
|
|
|
|
{#if error} |
|
<div class="error"> |
|
Error loading repositories: {error} |
|
</div> |
|
{:else if loading} |
|
<div class="loading">Loading repositories...</div> |
|
{:else} |
|
<!-- Registered Repositories Section --> |
|
<div class="repo-section"> |
|
<div class="section-header"> |
|
<h3>Registered Repositories</h3> |
|
<span class="section-badge">{registeredRepos.length}</span> |
|
</div> |
|
{#if registeredRepos.length === 0} |
|
<div class="empty">No registered repositories found.</div> |
|
{:else} |
|
<div class="repos-list"> |
|
{#each registeredRepos as item} |
|
{@const repo = item.event} |
|
{@const repoImage = getRepoImage(repo)} |
|
{@const isLocalOnly = item.isLocalOnly || false} |
|
<div class="repo-card repo-card-registered"> |
|
<div class="repo-card-content"> |
|
<div class="repo-header"> |
|
<div class="repo-header-text"> |
|
<div class="repo-title-row"> |
|
{#if repoImage} |
|
<img src={repoImage} alt="Repository" class="repo-avatar" /> |
|
{/if} |
|
<h3>{getRepoName(repo)}</h3> |
|
{#if isLocalOnly} |
|
<img src="/icons/hard-drive.svg" alt="Local-only fork" class="local-only-icon" title="Local-only fork (not published to Nostr)" /> |
|
{/if} |
|
</div> |
|
{#if getRepoDescription(repo)} |
|
<p class="description">{getRepoDescription(repo)}</p> |
|
{/if} |
|
</div> |
|
<a href="/repos/{item.npub}/{item.repoName}" class="view-button" title="View repository"> |
|
<img src="/icons/arrow-right.svg" alt="View" /> |
|
</a> |
|
</div> |
|
<div class="repo-meta"> |
|
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> |
|
{#if isLocalOnly} |
|
<span class="local-only-badge" title="Local-only fork (not published to Nostr)">Local Fork</span> |
|
{/if} |
|
{#if getForkCount(repo) > 0} |
|
{@const forkCount = getForkCount(repo)} |
|
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
{/if} |
|
</main> |
|
</div> |
|
|
|
<style> |
|
.loading-indicator { |
|
color: var(--text-secondary, #666); |
|
font-size: 0.875rem; |
|
} |
|
|
|
.pagination { |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
gap: 1rem; |
|
margin-top: 1.5rem; |
|
padding-top: 1.5rem; |
|
border-top: 1px solid var(--border-color, #e0e0e0); |
|
} |
|
|
|
.pagination button { |
|
padding: 0.5rem 1rem; |
|
background: var(--accent, #007bff); |
|
color: var(--accent-text, #ffffff); |
|
border: none; |
|
border-radius: 0.25rem; |
|
cursor: pointer; |
|
font-weight: 500; |
|
transition: all 0.2s ease; |
|
} |
|
|
|
.pagination button:hover:not(:disabled) { |
|
background: var(--accent-hover, #0056b3); |
|
} |
|
|
|
.pagination button:disabled { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
.page-info { |
|
color: var(--text-secondary, #666); |
|
font-size: 0.9rem; |
|
} |
|
|
|
.repos-list { |
|
display: grid; |
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
|
gap: 1rem; |
|
margin-top: 1rem; |
|
} |
|
|
|
.repo-card { |
|
background: var(--card-bg, #ffffff); |
|
border: 1px solid var(--border-color, #e0e0e0); |
|
border-radius: 0.5rem; |
|
overflow: hidden; |
|
transition: box-shadow 0.2s ease; |
|
display: flex; |
|
flex-direction: column; |
|
min-height: 160px; |
|
height: 100%; |
|
} |
|
|
|
.repo-card:hover { |
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
|
} |
|
|
|
.repo-card-content { |
|
padding: 1rem; |
|
flex: 1; |
|
display: flex; |
|
flex-direction: column; |
|
min-height: 0; |
|
} |
|
|
|
.repo-header { |
|
display: flex; |
|
align-items: flex-start; |
|
gap: 0.75rem; |
|
margin-bottom: 0.75rem; |
|
} |
|
|
|
.repo-title-row { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
} |
|
|
|
.repo-avatar { |
|
width: 24px; |
|
height: 24px; |
|
border-radius: 50%; |
|
object-fit: cover; |
|
flex-shrink: 0; |
|
border: 1px solid var(--border-color, #e0e0e0); |
|
} |
|
|
|
.local-only-icon { |
|
width: 16px; |
|
height: 16px; |
|
opacity: 0.7; |
|
flex-shrink: 0; |
|
filter: brightness(0) invert(1); |
|
} |
|
|
|
.local-only-badge { |
|
background: var(--bg-tertiary, #f5f5f5); |
|
color: var(--text-secondary, #666); |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 0.25rem; |
|
font-size: 0.75rem; |
|
font-weight: 500; |
|
} |
|
|
|
.repo-header-text { |
|
flex: 1; |
|
min-width: 0; |
|
} |
|
|
|
.repo-header-text h3 { |
|
margin: 0 0 0.25rem 0; |
|
font-size: 1rem; |
|
font-weight: 600; |
|
color: var(--text-primary, #1a1a1a); |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
|
|
.repo-header-text .description { |
|
margin: 0; |
|
font-size: 0.875rem; |
|
color: var(--text-secondary, #666); |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
display: -webkit-box; |
|
-webkit-line-clamp: 2; |
|
line-clamp: 2; |
|
-webkit-box-orient: vertical; |
|
line-height: 1.4; |
|
} |
|
|
|
.view-button { |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
width: 32px; |
|
height: 32px; |
|
padding: 0; |
|
background: var(--accent, #007bff); |
|
color: var(--accent-text, #ffffff); |
|
text-decoration: none; |
|
border-radius: 0.25rem; |
|
transition: all 0.2s ease; |
|
flex-shrink: 0; |
|
} |
|
|
|
.view-button:hover { |
|
background: var(--accent-hover, #0056b3); |
|
} |
|
|
|
.view-button img { |
|
width: 18px; |
|
height: 18px; |
|
filter: brightness(0) invert(1); |
|
} |
|
|
|
.repo-meta { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 0.75rem; |
|
margin-top: auto; |
|
padding-top: 0.75rem; |
|
border-top: 1px solid var(--border-color, #e0e0e0); |
|
font-size: 0.75rem; |
|
color: var(--text-secondary, #666); |
|
} |
|
|
|
.fork-count { |
|
color: var(--text-secondary, #666); |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.25rem; |
|
} |
|
|
|
.fork-icon { |
|
width: 14px; |
|
height: 14px; |
|
opacity: 0.8; |
|
filter: brightness(0) invert(1); |
|
} |
|
|
|
.favorite-count { |
|
color: var(--text-secondary, #666); |
|
font-weight: 500; |
|
} |
|
|
|
.transferred-badge { |
|
font-size: 0.875rem; |
|
color: var(--text-secondary, #666); |
|
margin-left: 0.5rem; |
|
opacity: 0.7; |
|
} |
|
|
|
.repo-card.transferred { |
|
opacity: 0.7; |
|
} |
|
|
|
.repo-card.transferred:hover { |
|
opacity: 0.9; |
|
} |
|
|
|
.repo-section { |
|
margin: 2rem 0; |
|
} |
|
|
|
.section-header { |
|
display: flex; |
|
align-items: center; |
|
gap: 1rem; |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.section-header h3 { |
|
margin: 0; |
|
font-size: 1.25rem; |
|
color: var(--text-primary, #1a1a1a); |
|
} |
|
|
|
.section-badge { |
|
padding: 0.25rem 0.75rem; |
|
background: var(--bg-secondary, #f5f5f5); |
|
border: 1px solid var(--border-color, #e0e0e0); |
|
border-radius: 1rem; |
|
font-size: 0.875rem; |
|
color: var(--text-secondary, #666); |
|
} |
|
|
|
@media (max-width: 768px) { |
|
.repos-list { |
|
grid-template-columns: 1fr; |
|
} |
|
} |
|
</style>
|
|
|