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.
2448 lines
74 KiB
2448 lines
74 KiB
<script lang="ts"> |
|
import Header from '../../../lib/components/layout/Header.svelte'; |
|
import PageHeader from '../../../lib/components/layout/PageHeader.svelte'; |
|
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../../lib/services/nostr/relay-manager.js'; |
|
import { onMount } from 'svelte'; |
|
import { page } from '$app/stores'; |
|
import { goto } from '$app/navigation'; |
|
import type { NostrEvent } from '../../../lib/types/nostr.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
import { fetchGitRepo, extractGitUrls, isGraspUrl, convertSshToHttps, type GitRepoInfo, type GitFile } from '../../../lib/services/content/git-repo-fetcher.js'; |
|
import FileExplorer from '../../../lib/components/content/FileExplorer.svelte'; |
|
import { marked } from 'marked'; |
|
import Asciidoctor from 'asciidoctor'; |
|
import { KIND } from '../../../lib/types/kind-lookup.js'; |
|
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte'; |
|
import { sanitizeHtml } from '../../../lib/services/security/sanitizer.js'; |
|
import ProfileBadge from '../../../lib/components/layout/ProfileBadge.svelte'; |
|
import MarkdownRenderer from '../../../lib/components/content/MarkdownRenderer.svelte'; |
|
import { signAndPublish } from '../../../lib/services/nostr/auth-handler.js'; |
|
import { sessionManager } from '../../../lib/services/auth/session-manager.js'; |
|
import { cacheEvent, getEventsByKind } from '../../../lib/services/cache/event-cache.js'; |
|
import EventMenu from '../../../lib/components/EventMenu.svelte'; |
|
import { fetchProfiles } from '../../../lib/services/user-data.js'; |
|
import Icon from '../../../lib/components/ui/Icon.svelte'; |
|
|
|
let naddr = $derived($page.params.naddr); |
|
let repoEvent = $state<NostrEvent | null>(null); |
|
let gitRepo = $state<GitRepoInfo | null>(null); |
|
let loading = $state(true); |
|
let loadingGitRepo = $state(false); |
|
let gitRepoFetchAttempted = $state(false); // Track if we've already attempted to fetch (even if failed) |
|
let activeTab = $state<'metadata' | 'about' | 'repository' | 'issues' | 'documentation'>('metadata'); |
|
let issues = $state<NostrEvent[]>([]); |
|
let issueComments = $state<Map<string, NostrEvent[]>>(new Map()); |
|
let issueStatuses = $state<Map<string, NostrEvent>>(new Map()); |
|
let loadingIssues = $state(false); |
|
let loadingIssueData = $state(false); // Statuses, comments, profiles |
|
let documentationEvents = $state<Map<string, NostrEvent>>(new Map()); |
|
let changingStatus = $state<Map<string, boolean>>(new Map()); // Track which issues are having status changed |
|
let statusFilter = $state<string | null>(null); // Filter issues by status: null = all, 'open', 'resolved', 'closed', 'draft' |
|
let issuesPage = $state(1); // Current page for pagination |
|
const ISSUES_PER_PAGE = 20; // Number of issues to show per page |
|
|
|
const asciidoctor = Asciidoctor(); |
|
|
|
let loadingRepo = $state(false); // Guard to prevent concurrent loads |
|
|
|
// Initialize activeTab from URL parameter |
|
function getTabFromUrl(): 'metadata' | 'about' | 'repository' | 'issues' | 'documentation' { |
|
const tabParam = $page.url.searchParams.get('tab'); |
|
const validTabs: Array<'metadata' | 'about' | 'repository' | 'issues' | 'documentation'> = ['metadata', 'about', 'repository', 'issues', 'documentation']; |
|
if (tabParam && validTabs.includes(tabParam as any)) { |
|
return tabParam as 'metadata' | 'about' | 'repository' | 'issues' | 'documentation'; |
|
} |
|
return 'metadata'; // Default |
|
} |
|
|
|
// Update activeTab when URL changes |
|
$effect(() => { |
|
const urlTab = getTabFromUrl(); |
|
if (urlTab !== activeTab) { |
|
activeTab = urlTab; |
|
} |
|
}); |
|
|
|
// Function to change tab and update URL |
|
function setActiveTab(tab: 'metadata' | 'about' | 'repository' | 'issues' | 'documentation') { |
|
activeTab = tab; |
|
const url = new URL($page.url); |
|
url.searchParams.set('tab', tab); |
|
goto(url.pathname + url.search, { replaceState: true, noScroll: true }); |
|
} |
|
|
|
onMount(async () => { |
|
await nostrClient.initialize(); |
|
// Initialize tab from URL |
|
activeTab = getTabFromUrl(); |
|
// Don't call loadRepo here - let $effect handle it |
|
}); |
|
|
|
// Track the last naddr we loaded to prevent duplicate loads |
|
let lastLoadedNaddr = $state<string | null>(null); |
|
|
|
$effect(() => { |
|
if (naddr && !loadingRepo && naddr !== lastLoadedNaddr) { |
|
// Reset git repo state when naddr changes |
|
gitRepo = null; |
|
gitRepoFetchAttempted = false; |
|
lastLoadedNaddr = naddr; |
|
loadCachedRepo(); |
|
loadRepo(); |
|
} |
|
}); |
|
|
|
// Load git repo when repository or about tab is clicked (about tab needs README) |
|
$effect(() => { |
|
if ((activeTab === 'repository' || activeTab === 'about') && repoEvent && !gitRepo && !loadingGitRepo && !gitRepoFetchAttempted) { |
|
loadGitRepo(); |
|
} |
|
}); |
|
|
|
async function loadGitRepo() { |
|
if (!repoEvent || loadingGitRepo || gitRepo || gitRepoFetchAttempted) return; |
|
|
|
loadingGitRepo = true; |
|
gitRepoFetchAttempted = true; // Mark as attempted immediately to prevent re-triggering |
|
try { |
|
const gitUrls = extractGitUrls(repoEvent); |
|
|
|
// Prioritize GRASP clones if multiple URLs exist |
|
let prioritizedUrls = gitUrls; |
|
if (gitUrls.length > 1) { |
|
const graspUrls = gitUrls.filter(url => isGraspUrl(url)); |
|
const nonGraspUrls = gitUrls.filter(url => !isGraspUrl(url)); |
|
// Put GRASP URLs first |
|
prioritizedUrls = [...graspUrls, ...nonGraspUrls]; |
|
} |
|
|
|
if (prioritizedUrls.length > 0) { |
|
// Try each URL until one works |
|
for (const url of prioritizedUrls) { |
|
try { |
|
const repo = await fetchGitRepo(url); |
|
if (repo) { |
|
gitRepo = repo; |
|
break; // Success, stop trying other URLs |
|
} |
|
} catch (error) { |
|
// Failed to fetch git repo - continue to next URL |
|
console.warn(`Failed to fetch repo from ${url}:`, error); |
|
} |
|
} |
|
} |
|
} catch (error) { |
|
// Failed to load git repo |
|
} finally { |
|
loadingGitRepo = false; |
|
} |
|
} |
|
|
|
async function loadCachedRepo() { |
|
if (!naddr) return; |
|
|
|
try { |
|
// Decode naddr |
|
let decoded; |
|
try { |
|
decoded = nip19.decode(naddr); |
|
} catch (decodeError) { |
|
return; // Can't decode, skip cache check |
|
} |
|
|
|
if (decoded.type !== 'naddr') { |
|
return; |
|
} |
|
|
|
const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] }; |
|
const kind = naddrData.kind; |
|
const pubkey = naddrData.pubkey; |
|
const dTag = naddrData.identifier || ''; |
|
|
|
// Check cache for repo events of this kind |
|
const cachedEvents = await getEventsByKind(kind, 1000); |
|
|
|
// Find the matching repo event (by pubkey and d-tag) |
|
const matchingEvent = cachedEvents.find(event => { |
|
if (event.pubkey !== pubkey) return false; |
|
const eventDTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
|
return eventDTag === dTag; |
|
}); |
|
|
|
if (matchingEvent) { |
|
repoEvent = matchingEvent; |
|
loading = false; // Show cached data immediately |
|
|
|
// Load maintainer profiles immediately from cache |
|
loadMaintainerProfiles(); |
|
|
|
// Load issues and documentation in background (but not git repo - wait for tab click) |
|
Promise.all([ |
|
loadIssues(), |
|
loadDocumentation() |
|
]).catch(err => { |
|
// Cache error (non-critical) |
|
}); |
|
} |
|
} catch (error) { |
|
// Cache error (non-critical) |
|
} |
|
} |
|
|
|
async function loadRepo() { |
|
if (!naddr || loadingRepo) { |
|
if (!naddr) { |
|
// Missing naddr parameter |
|
} |
|
if (!repoEvent) { |
|
loading = false; |
|
} |
|
return; |
|
} |
|
|
|
// Only show loading spinner if we don't have cached data |
|
const hasCachedData = repoEvent !== null; |
|
if (!hasCachedData) { |
|
loading = true; |
|
} |
|
|
|
loadingRepo = true; |
|
try { |
|
// Decode naddr |
|
let decoded; |
|
try { |
|
decoded = nip19.decode(naddr); |
|
} catch (decodeError) { |
|
// Invalid naddr format |
|
if (!hasCachedData) { |
|
loading = false; |
|
} |
|
return; |
|
} |
|
|
|
if (decoded.type !== 'naddr') { |
|
// Invalid naddr type |
|
if (!hasCachedData) { |
|
loading = false; |
|
} |
|
return; |
|
} |
|
|
|
const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] }; |
|
const kind = naddrData.kind; |
|
const pubkey = naddrData.pubkey; |
|
const dTag = naddrData.identifier || ''; |
|
|
|
|
|
// Fetch the repo announcement event |
|
// Merge naddr relays with standard profile relays (naddr relays are additional hints, not replacements) |
|
const standardRelays = relayManager.getProfileReadRelays(); |
|
const naddrRelays = naddrData.relays || []; |
|
const relays = [...new Set([...standardRelays, ...naddrRelays])]; // Deduplicate |
|
|
|
|
|
// Step 1: Fetch the repo event by ID (using kind, author, and d-tag) |
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], |
|
relays, |
|
{ useCache: 'cache-first', cacheResults: true } |
|
); |
|
|
|
|
|
if (events.length > 0) { |
|
const newRepoEvent = events[0]; |
|
// Only update if it's actually different (prevents unnecessary re-renders) |
|
if (!repoEvent || repoEvent.id !== newRepoEvent.id) { |
|
repoEvent = newRepoEvent; |
|
} |
|
|
|
// Don't fetch git repo here - wait until user clicks on repository tab |
|
// This prevents rate limiting from GitHub/GitLab/Gitea |
|
|
|
// Load maintainer profiles immediately from cache |
|
loadMaintainerProfiles(); |
|
|
|
// Step 2: Batch load all related data in parallel (only if not already loaded from cache) |
|
if (issues.length === 0 && documentationEvents.size === 0) { |
|
await Promise.all([ |
|
loadIssues(), // Batch fetch issues, statuses, comments, and profiles |
|
loadDocumentation() // Load documentation events |
|
]); |
|
} |
|
} else { |
|
// Repo event not found |
|
if (!hasCachedData) { |
|
loading = false; |
|
} |
|
} |
|
} catch (error) { |
|
// Failed to load repo |
|
if (!hasCachedData) { |
|
loading = false; |
|
} |
|
} finally { |
|
loading = false; |
|
loadingRepo = false; |
|
} |
|
} |
|
|
|
async function loadIssues() { |
|
if (!repoEvent) return; |
|
|
|
loadingIssues = true; |
|
try { |
|
const gitUrls = extractGitUrls(repoEvent); |
|
const relays = relayManager.getProfileReadRelays(); |
|
|
|
// Batch fetch all issues that reference this repo |
|
const filters: any[] = []; |
|
|
|
// Search for issues that reference the repo event ID |
|
filters.push({ '#e': [repoEvent.id], kinds: [KIND.ISSUE], limit: 100 }); |
|
|
|
// Search for issues that reference the repo using 'a' tag (NIP-34 format: kind:pubkey:d-tag) |
|
const dTag = repoEvent.tags.find(t => Array.isArray(t) && t[0] === 'd')?.[1] || ''; |
|
if (dTag) { |
|
const aTagValue = `${repoEvent.kind}:${repoEvent.pubkey}:${dTag}`; |
|
filters.push({ '#a': [aTagValue], kinds: [KIND.ISSUE], limit: 100 }); |
|
} |
|
|
|
// Also search for issues with git URLs in tags (batch all URLs in one filter) |
|
if (gitUrls.length > 0) { |
|
filters.push({ '#r': gitUrls, kinds: [KIND.ISSUE], limit: 100 }); |
|
} |
|
|
|
// Search for issues by the repo author (issues might be created by repo maintainers) |
|
filters.push({ authors: [repoEvent.pubkey], kinds: [KIND.ISSUE], limit: 100 }); |
|
|
|
// Batch fetch all issues in parallel with cache-first strategy |
|
const issueEventsArrays = await Promise.all( |
|
filters.map(filter => |
|
nostrClient.fetchEvents([filter], relays, { |
|
useCache: 'cache-first', // Prioritize cache for faster loading |
|
cacheResults: true |
|
}) |
|
) |
|
); |
|
|
|
// Flatten and deduplicate |
|
const issueEvents: NostrEvent[] = []; |
|
for (const events of issueEventsArrays) { |
|
issueEvents.push(...events); |
|
} |
|
|
|
// Deduplicate |
|
const uniqueIssues = Array.from(new Map(issueEvents.map(e => [e.id, e])).values()); |
|
|
|
// Filter to only include issues that actually match this repo |
|
// Check if issue has 'a' tag matching this repo, or 'e' tag matching repo event ID, or 'r' tag matching repo URLs |
|
const repoATag = dTag ? `${repoEvent.kind}:${repoEvent.pubkey}:${dTag}` : null; |
|
const repoEventId = repoEvent.id; |
|
const matchingIssues = uniqueIssues.filter(issue => { |
|
// Check if issue references this repo via 'a' tag |
|
if (repoATag) { |
|
const aTags = issue.tags.filter(t => t[0] === 'a').map(t => t[1]); |
|
if (aTags.includes(repoATag)) { |
|
return true; |
|
} |
|
} |
|
|
|
// Check if issue references this repo via 'e' tag |
|
const eTags = issue.tags.filter(t => t[0] === 'e').map(t => t[1]); |
|
if (eTags.includes(repoEventId)) { |
|
return true; |
|
} |
|
|
|
// Check if issue references this repo via 'r' tag (git URLs) |
|
const rTags = issue.tags.filter(t => t[0] === 'r').map(t => t[1]); |
|
for (const gitUrl of gitUrls) { |
|
if (rTags.includes(gitUrl)) { |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
}); |
|
|
|
issues = matchingIssues.sort((a, b) => b.created_at - a.created_at); |
|
loadingIssues = false; // Issues are loaded, show them immediately |
|
|
|
// Load statuses, comments, and profiles in background (don't wait) |
|
// This allows the UI to show issues immediately |
|
loadingIssueData = true; |
|
Promise.all([ |
|
loadIssueStatuses(), |
|
loadIssueComments(), |
|
loadAllProfiles() |
|
]).finally(() => { |
|
loadingIssueData = false; |
|
}).catch(() => { |
|
// Background loading errors are non-critical |
|
loadingIssueData = false; |
|
}); |
|
} catch (error) { |
|
// Failed to load issues |
|
loadingIssues = false; |
|
} |
|
} |
|
|
|
async function loadIssueStatuses() { |
|
if (issues.length === 0) return; |
|
|
|
try { |
|
const issueIds = issues.map(i => i.id); |
|
const relays = relayManager.getProfileReadRelays(); |
|
|
|
// Status events are different kinds: 1630 (Open), 1631 (Applied/Merged/Resolved), 1632 (Closed), 1633 (Draft) |
|
// They have "e" tags pointing to issues with marker "root" |
|
const statuses = await nostrClient.fetchEvents( |
|
[{ |
|
'#e': issueIds, |
|
kinds: [KIND.STATUS_OPEN, KIND.STATUS_APPLIED, KIND.STATUS_CLOSED, KIND.STATUS_DRAFT], |
|
limit: 200 |
|
}], |
|
relays, |
|
{ useCache: 'cache-first', cacheResults: true } // Prioritize cache |
|
); |
|
|
|
|
|
// Get the latest status for each issue (statuses are replaceable per pubkey) |
|
// For each issue, get the latest status from each pubkey, then take the most recent overall |
|
const statusMap = new Map<string, NostrEvent>(); |
|
const statusesByIssue = new Map<string, Map<string, NostrEvent>>(); // issueId -> pubkey -> status |
|
|
|
for (const status of statuses) { |
|
// Find the "e" tag with marker "root" (or just the first "e" tag if no marker) |
|
const eTag = status.tags.find(t => t[0] === 'e' && (t.length < 3 || t[2] === 'root')); |
|
const fallbackETag = status.tags.find(t => t[0] === 'e'); |
|
const issueId = (eTag && eTag[1] && issueIds.includes(eTag[1])) |
|
? eTag[1] |
|
: (fallbackETag && fallbackETag[1] && issueIds.includes(fallbackETag[1])) |
|
? fallbackETag[1] |
|
: null; |
|
|
|
if (issueId) { |
|
// Group by issue and pubkey (replaceable events) |
|
if (!statusesByIssue.has(issueId)) { |
|
statusesByIssue.set(issueId, new Map()); |
|
} |
|
const pubkeyMap = statusesByIssue.get(issueId)!; |
|
const existing = pubkeyMap.get(status.pubkey); |
|
if (!existing || status.created_at > existing.created_at) { |
|
pubkeyMap.set(status.pubkey, status); |
|
} |
|
} |
|
} |
|
|
|
// For each issue, get the most recent status from any pubkey |
|
for (const [issueId, pubkeyMap] of statusesByIssue.entries()) { |
|
let latestStatus: NostrEvent | null = null; |
|
for (const status of pubkeyMap.values()) { |
|
if (!latestStatus || status.created_at > latestStatus.created_at) { |
|
latestStatus = status; |
|
} |
|
} |
|
if (latestStatus) { |
|
statusMap.set(issueId, latestStatus); |
|
} |
|
} |
|
|
|
issueStatuses = statusMap; |
|
} catch (error) { |
|
// Failed to load issue statuses |
|
} |
|
} |
|
|
|
async function changeIssueStatus(issueId: string, newStatus: string) { |
|
const session = sessionManager.getSession(); |
|
if (!session) { |
|
alert('Please log in to change issue status'); |
|
return; |
|
} |
|
|
|
changingStatus.set(issueId, true); |
|
|
|
try { |
|
// Get the issue event to extract repository info |
|
const issue = issues.find(i => i.id === issueId); |
|
if (!issue) { |
|
alert('Issue not found'); |
|
return; |
|
} |
|
|
|
// Get the repo event to extract owner and repo ID |
|
const repoOwner = repoEvent?.pubkey || ''; |
|
const repoDTag = repoEvent?.tags.find(t => t[0] === 'd')?.[1] || ''; |
|
|
|
// Build tags according to NIP-34 spec |
|
const tags: string[][] = [ |
|
['e', issueId, '', 'root'], // Reference to the issue with "root" marker |
|
['p', repoOwner], // Repository owner |
|
['p', issue.pubkey], // Root event author |
|
]; |
|
|
|
// Add optional tags for improved subscription filter efficiency |
|
if (repoEvent && repoDTag) { |
|
tags.push(['a', `${KIND.REPO_ANNOUNCEMENT}:${repoOwner}:${repoDTag}`]); |
|
} |
|
|
|
// Get git repo URLs for 'r' tags (optional - requires commit IDs) |
|
// For now, we'll skip 'r' tags as they require the earliest unique commit ID |
|
// which would need to be fetched from the git repository |
|
|
|
const statusKind = getKindFromStatus(newStatus); |
|
|
|
const event: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: statusKind, // Status is determined by the kind |
|
pubkey: session.pubkey, |
|
created_at: Math.floor(Date.now() / 1000), |
|
tags: tags, |
|
content: '' // Optional markdown text |
|
}; |
|
|
|
// Sign the event first so we can cache it |
|
const signedEvent = await sessionManager.signEvent(event); |
|
|
|
// Cache the event immediately |
|
await cacheEvent(signedEvent); |
|
|
|
// Publish to relays |
|
const relays = relayManager.getProfileReadRelays(); |
|
const result = await nostrClient.publish(signedEvent, { relays }); |
|
|
|
if (result.success.length > 0) { |
|
// Update local state immediately with the cached event |
|
issueStatuses.set(issueId, signedEvent); |
|
|
|
// Also reload statuses to get any other updates from relays |
|
await loadIssueStatuses(); |
|
} else { |
|
alert('Failed to publish status change. Please try again.'); |
|
} |
|
} catch (error) { |
|
// Failed to change issue status |
|
alert('Error changing issue status: ' + (error instanceof Error ? error.message : String(error))); |
|
} finally { |
|
changingStatus.set(issueId, false); |
|
} |
|
} |
|
|
|
function getStatusFromKind(kind: number): string { |
|
switch (kind) { |
|
case KIND.STATUS_OPEN: |
|
return 'open'; |
|
case KIND.STATUS_APPLIED: |
|
return 'resolved'; // For issues, 1631 means "Resolved" |
|
case KIND.STATUS_CLOSED: |
|
return 'closed'; |
|
case KIND.STATUS_DRAFT: |
|
return 'draft'; |
|
default: |
|
return 'open'; |
|
} |
|
} |
|
|
|
function getCurrentStatus(issueId: string): string { |
|
const status = issueStatuses.get(issueId); |
|
if (!status) { |
|
// Default to 'open' if no status event exists |
|
return 'open'; |
|
} |
|
|
|
// Status is determined by the kind of the event |
|
return getStatusFromKind(status.kind); |
|
} |
|
|
|
function getKindFromStatus(status: string): number { |
|
switch (status) { |
|
case 'open': |
|
return KIND.STATUS_OPEN; |
|
case 'resolved': |
|
case 'applied': |
|
case 'merged': |
|
return KIND.STATUS_APPLIED; |
|
case 'closed': |
|
return KIND.STATUS_CLOSED; |
|
case 'draft': |
|
return KIND.STATUS_DRAFT; |
|
default: |
|
return KIND.STATUS_OPEN; |
|
} |
|
} |
|
|
|
const availableStatuses = ['open', 'resolved', 'closed', 'draft']; |
|
|
|
// Filter issues by status |
|
let filteredIssues = $derived.by(() => { |
|
let filtered = issues; |
|
if (statusFilter) { |
|
filtered = issues.filter(issue => getCurrentStatus(issue.id) === statusFilter); |
|
} |
|
return filtered; |
|
}); |
|
|
|
// Paginated issues |
|
let paginatedIssues = $derived.by(() => { |
|
const start = (issuesPage - 1) * ISSUES_PER_PAGE; |
|
const end = start + ISSUES_PER_PAGE; |
|
return filteredIssues.slice(start, end); |
|
}); |
|
|
|
let totalPages = $derived.by(() => { |
|
return Math.ceil(filteredIssues.length / ISSUES_PER_PAGE); |
|
}); |
|
|
|
// Reset to page 1 when filter changes |
|
$effect(() => { |
|
if (statusFilter !== null) { |
|
issuesPage = 1; |
|
} |
|
}); |
|
|
|
async function loadIssueComments() { |
|
if (issues.length === 0) return; |
|
|
|
try { |
|
const issueIds = issues.map(i => i.id); |
|
const relays = relayManager.getCommentReadRelays(); |
|
|
|
// Batch fetch all comments for all issues |
|
// Use cache-first to load comments faster |
|
const comments = await nostrClient.fetchEvents( |
|
[{ '#e': issueIds, kinds: [KIND.COMMENT], limit: 500 }], |
|
relays, |
|
{ useCache: 'cache-first', cacheResults: true } // Prioritize cache |
|
); |
|
|
|
// Group comments by issue ID |
|
const commentsMap = new Map<string, NostrEvent[]>(); |
|
for (const comment of comments) { |
|
const eTag = comment.tags.find(t => t[0] === 'e'); |
|
if (eTag && eTag[1]) { |
|
const issueId = eTag[1]; |
|
if (!commentsMap.has(issueId)) { |
|
commentsMap.set(issueId, []); |
|
} |
|
commentsMap.get(issueId)!.push(comment); |
|
} |
|
} |
|
|
|
issueComments = commentsMap; |
|
} catch (error) { |
|
// Failed to load issue comments |
|
} |
|
} |
|
|
|
// Load maintainer profiles immediately from cache |
|
async function loadMaintainerProfiles() { |
|
if (!repoEvent) return; |
|
|
|
try { |
|
const pubkeys = new Set<string>(); |
|
|
|
// Add repo owner and maintainers |
|
pubkeys.add(repoEvent.pubkey); |
|
const maintainers = getMaintainers(); |
|
maintainers.forEach(m => pubkeys.add(m)); |
|
|
|
if (pubkeys.size === 0) return; |
|
|
|
const uniquePubkeys = Array.from(pubkeys); |
|
|
|
// Fetch profiles (will use cache first, then fetch from network if needed) |
|
const relays = relayManager.getProfileReadRelays(); |
|
await fetchProfiles(uniquePubkeys, relays); |
|
} catch (error) { |
|
// Failed to load profiles - non-critical |
|
} |
|
} |
|
|
|
async function loadAllProfiles() { |
|
if (issues.length === 0) return; |
|
|
|
try { |
|
// Collect all unique pubkeys from: |
|
// 1. Issue authors |
|
// 2. Comment authors |
|
// 3. Status authors |
|
// 4. Repository owner and maintainers |
|
const pubkeys = new Set<string>(); |
|
|
|
// Add repo owner and maintainers |
|
if (repoEvent) { |
|
pubkeys.add(repoEvent.pubkey); |
|
const maintainers = getMaintainers(); |
|
maintainers.forEach(m => pubkeys.add(m)); |
|
} |
|
|
|
// Add issue authors |
|
issues.forEach(issue => pubkeys.add(issue.pubkey)); |
|
|
|
// Add comment authors |
|
for (const comments of issueComments.values()) { |
|
comments.forEach(comment => pubkeys.add(comment.pubkey)); |
|
} |
|
|
|
// Add status authors |
|
for (const status of issueStatuses.values()) { |
|
pubkeys.add(status.pubkey); |
|
} |
|
|
|
const uniquePubkeys = Array.from(pubkeys); |
|
|
|
// Batch fetch all profiles at once |
|
const relays = relayManager.getProfileReadRelays(); |
|
await fetchProfiles(uniquePubkeys, relays); |
|
|
|
} catch (error) { |
|
// Failed to load profiles |
|
// Don't throw - profile loading is best effort |
|
} |
|
} |
|
|
|
async function loadDocumentation() { |
|
if (!repoEvent) return; |
|
|
|
try { |
|
const docs = getDocumentation(); |
|
if (docs.length === 0) return; |
|
|
|
const relays = relayManager.getProfileReadRelays(); |
|
const docMap = new Map<string, NostrEvent>(); |
|
|
|
for (const doc of docs) { |
|
try { |
|
let kind: number; |
|
let pubkey: string; |
|
let dTag: string; |
|
|
|
// If we already have the parsed components, use them |
|
if (doc.kind && doc.pubkey && doc.dTag !== undefined) { |
|
kind = doc.kind; |
|
pubkey = doc.pubkey; |
|
dTag = doc.dTag; |
|
} else { |
|
// Otherwise, try to decode the naddr |
|
try { |
|
const decoded = nip19.decode(doc.naddr); |
|
if (decoded.type !== 'naddr') { |
|
// Invalid documentation naddr format |
|
continue; |
|
} |
|
|
|
const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] }; |
|
kind = naddrData.kind; |
|
pubkey = naddrData.pubkey; |
|
dTag = naddrData.identifier || ''; |
|
} catch (decodeError) { |
|
// Failed to decode documentation naddr |
|
continue; |
|
} |
|
} |
|
|
|
// Merge relays |
|
const docRelays = doc.relay ? [doc.relay] : []; |
|
const standardRelays = relayManager.getProfileReadRelays(); |
|
const allRelays = [...new Set([...standardRelays, ...docRelays])]; |
|
|
|
// Fetch the documentation event |
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], |
|
allRelays, |
|
{ useCache: 'cache-first', cacheResults: true } |
|
); |
|
|
|
if (events.length > 0) { |
|
docMap.set(doc.naddr, events[0]); |
|
} |
|
} catch (error) { |
|
// Failed to load documentation |
|
} |
|
} |
|
|
|
documentationEvents = docMap; |
|
} catch (error) { |
|
// Failed to load documentation |
|
} |
|
} |
|
|
|
function getRepoName(): string { |
|
try { |
|
if (!repoEvent) return 'Repository'; |
|
|
|
if (!Array.isArray(repoEvent.tags)) return 'Repository'; |
|
|
|
const nameTag = repoEvent.tags.find(t => Array.isArray(t) && (t[0] === 'name' || t[0] === 'title')); |
|
if (nameTag && nameTag[1]) return String(nameTag[1]); |
|
|
|
if (repoEvent.content) { |
|
try { |
|
const content = JSON.parse(repoEvent.content); |
|
if (content && typeof content === 'object') { |
|
if (content.name) return String(content.name); |
|
if (content.title) return String(content.title); |
|
} |
|
} catch { |
|
// Not JSON, ignore |
|
} |
|
} |
|
|
|
const dTag = repoEvent.tags.find(t => Array.isArray(t) && t[0] === 'd'); |
|
if (dTag && dTag[1]) return String(dTag[1]); |
|
|
|
return gitRepo?.name || 'Repository'; |
|
} catch (error) { |
|
// Failed to get repo name |
|
return 'Repository'; |
|
} |
|
} |
|
|
|
function getRepoDescription(): string { |
|
try { |
|
if (!repoEvent) return ''; |
|
|
|
if (!Array.isArray(repoEvent.tags)) return ''; |
|
|
|
const descTag = repoEvent.tags.find(t => Array.isArray(t) && (t[0] === 'description' || t[0] === 'summary')); |
|
if (descTag && descTag[1]) return String(descTag[1]); |
|
|
|
if (repoEvent.content) { |
|
try { |
|
const content = JSON.parse(repoEvent.content); |
|
if (content && typeof content === 'object' && content.description) { |
|
return String(content.description); |
|
} |
|
} catch { |
|
// Not JSON, ignore |
|
} |
|
} |
|
|
|
return gitRepo?.description || ''; |
|
} catch (error) { |
|
// Failed to get repo description |
|
return ''; |
|
} |
|
} |
|
|
|
function getWebUrls(): string[] { |
|
if (!repoEvent || !Array.isArray(repoEvent.tags)) return []; |
|
return repoEvent.tags |
|
.filter(t => Array.isArray(t) && t[0] === 'web' && t[1]) |
|
.map(t => String(t[1])); |
|
} |
|
|
|
function getCloneUrls(): string[] { |
|
if (!repoEvent || !Array.isArray(repoEvent.tags)) return []; |
|
return repoEvent.tags |
|
.filter(t => Array.isArray(t) && t[0] === 'clone' && t[1]) |
|
.map(t => { |
|
const url = String(t[1]); |
|
// Convert SSH URLs to HTTPS |
|
if (url.startsWith('git@')) { |
|
const httpsUrl = convertSshToHttps(url); |
|
return httpsUrl || url; // Fallback to original if conversion fails |
|
} |
|
return url; |
|
}); |
|
} |
|
|
|
function getRelays(): string[] { |
|
if (!repoEvent || !Array.isArray(repoEvent.tags)) return []; |
|
const relayTag = repoEvent.tags.find(t => Array.isArray(t) && t[0] === 'relays'); |
|
if (relayTag && relayTag.length > 1) { |
|
return relayTag.slice(1).filter(r => r && typeof r === 'string') as string[]; |
|
} |
|
return []; |
|
} |
|
|
|
function getMaintainers(): string[] { |
|
if (!repoEvent || !Array.isArray(repoEvent.tags)) return []; |
|
const maintainerTag = repoEvent.tags.find(t => Array.isArray(t) && t[0] === 'maintainers'); |
|
if (maintainerTag && maintainerTag.length > 1) { |
|
return maintainerTag.slice(1).filter(m => m && typeof m === 'string') as string[]; |
|
} |
|
return []; |
|
} |
|
|
|
function getDocumentation(): Array<{ naddr: string; relay?: string; kind?: number; pubkey?: string; dTag?: string }> { |
|
if (!repoEvent || !Array.isArray(repoEvent.tags)) return []; |
|
return repoEvent.tags |
|
.filter(t => Array.isArray(t) && t[0] === 'documentation' && t[1]) |
|
.map(t => { |
|
const docValue = String(t[1]); |
|
const relay = t[2] ? String(t[2]) : undefined; |
|
|
|
// Check if it's already a bech32 naddr |
|
if (docValue.startsWith('naddr1')) { |
|
return { naddr: docValue, relay }; |
|
} |
|
|
|
// Otherwise, it might be in format "kind:pubkey:d-tag" |
|
const parts = docValue.split(':'); |
|
if (parts.length >= 3) { |
|
const kind = parseInt(parts[0], 10); |
|
const pubkey = parts[1]; |
|
const dTag = parts.slice(2).join(':'); // In case d-tag contains colons |
|
|
|
// Construct naddr |
|
try { |
|
const naddr = nip19.naddrEncode({ |
|
kind, |
|
pubkey, |
|
identifier: dTag, |
|
relays: relay ? [relay] : [] |
|
}); |
|
return { naddr, relay, kind, pubkey, dTag }; |
|
} catch (error) { |
|
// Failed to encode naddr |
|
return { naddr: docValue, relay, kind, pubkey, dTag }; |
|
} |
|
} |
|
|
|
// Fallback: return as-is |
|
return { naddr: docValue, relay }; |
|
}); |
|
} |
|
|
|
function getImageUrl(): string | null { |
|
if (!repoEvent || !Array.isArray(repoEvent.tags)) return null; |
|
const imageTag = repoEvent.tags.find(t => Array.isArray(t) && t[0] === 'image' && t[1]); |
|
return imageTag?.[1] || null; |
|
} |
|
|
|
function getBannerUrl(): string | null { |
|
if (!repoEvent || !Array.isArray(repoEvent.tags)) return null; |
|
const bannerTag = repoEvent.tags.find(t => Array.isArray(t) && t[0] === 'banner' && t[1]); |
|
return bannerTag?.[1] || null; |
|
} |
|
|
|
function isPrimary(): boolean { |
|
if (!repoEvent || !Array.isArray(repoEvent.tags)) return false; |
|
return repoEvent.tags.some(t => Array.isArray(t) && t[0] === 'primary'); |
|
} |
|
|
|
function formatNpub(pubkey: string): string { |
|
try { |
|
return nip19.npubEncode(pubkey); |
|
} catch { |
|
return pubkey.slice(0, 16) + '...'; |
|
} |
|
} |
|
|
|
function extractRelayDomain(relayUrl: string): string { |
|
try { |
|
// Remove protocol (wss://, ws://, https://, http://) |
|
const url = relayUrl.replace(/^(wss?|https?):\/\//, ''); |
|
// Remove path and port if present |
|
const domain = url.split('/')[0].split(':')[0]; |
|
return domain; |
|
} catch { |
|
return relayUrl; |
|
} |
|
} |
|
|
|
function renderReadme(content: string, format: 'markdown' | 'asciidoc'): string { |
|
let html: string; |
|
if (format === 'asciidoc') { |
|
const result = asciidoctor.convert(content, { safe: 'safe', attributes: { showtitle: true } }); |
|
html = typeof result === 'string' ? result : String(result); |
|
} else { |
|
html = marked.parse(content) as string; |
|
} |
|
// Sanitize the HTML output |
|
html = sanitizeHtml(html); |
|
|
|
// Process relative image and link paths for GitLab repos |
|
if (gitRepo && gitRepo.url) { |
|
html = processRelativePaths(html, gitRepo); |
|
} |
|
|
|
return html; |
|
} |
|
|
|
function processRelativePaths(html: string, repo: GitRepoInfo): string { |
|
// Extract Git URL info |
|
if (!repoEvent) return html; |
|
const gitUrls = extractGitUrls(repoEvent); |
|
if (gitUrls.length === 0) return html; |
|
|
|
const gitUrl = gitUrls[0]; |
|
const urlObj = new URL(gitUrl); |
|
const host = urlObj.origin; |
|
const pathParts = urlObj.pathname.split('/').filter(p => p); |
|
|
|
// Determine platform and extract owner/repo |
|
let owner: string; |
|
let repoName: string; |
|
let baseUrl: string; |
|
let defaultBranch: string; |
|
|
|
if (host.includes('github.com')) { |
|
// GitHub: /owner/repo.git or /owner/repo |
|
if (pathParts.length >= 2) { |
|
owner = pathParts[0]; |
|
repoName = pathParts[1].replace(/\.git$/, ''); |
|
baseUrl = 'https://api.github.com'; |
|
defaultBranch = repo.defaultBranch || 'main'; |
|
} else { |
|
return html; // Can't parse, return as-is |
|
} |
|
} else if (host.includes('gitlab.com') || host.includes('gitea.com') || host.includes('codeberg.org')) { |
|
// GitLab/Gitea: /owner/repo.git or /owner/repo |
|
if (pathParts.length >= 2) { |
|
owner = pathParts[0]; |
|
repoName = pathParts[1].replace(/\.git$/, ''); |
|
if (host.includes('gitlab.com')) { |
|
baseUrl = 'https://gitlab.com/api/v4'; |
|
} else if (host.includes('gitea.com')) { |
|
baseUrl = `${host}/api/v1`; |
|
} else if (host.includes('codeberg.org')) { |
|
baseUrl = 'https://codeberg.org/api/v1'; |
|
} else { |
|
baseUrl = `${host}/api/v1`; |
|
} |
|
defaultBranch = repo.defaultBranch || 'master'; |
|
} else { |
|
return html; // Can't parse, return as-is |
|
} |
|
} else { |
|
return html; // Unknown platform, return as-is |
|
} |
|
|
|
const projectPath = `${owner}/${repoName}`; |
|
const encodedPath = encodeURIComponent(projectPath); |
|
|
|
// Process img src attributes |
|
html = html.replace(/<img([^>]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => { |
|
// Skip if already absolute URL |
|
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:') || src.startsWith('#')) { |
|
return match; |
|
} |
|
|
|
// Convert relative path to GitLab raw file URL |
|
const filePath = src.startsWith('/') ? src.slice(1) : src; |
|
let rawUrl: string; |
|
|
|
if (host.includes('github.com')) { |
|
rawUrl = `https://raw.githubusercontent.com/${projectPath}/${defaultBranch}/${filePath}`; |
|
} else if (host.includes('gitlab.com')) { |
|
rawUrl = `/api/gitea-proxy/projects/${encodedPath}/repository/files/${encodeURIComponent(filePath)}/raw?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`; |
|
} else { |
|
// Gitea/Codeberg |
|
rawUrl = `/api/gitea-proxy/repos/${encodedPath}/raw/${encodeURIComponent(filePath)}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`; |
|
} |
|
|
|
return `<img${before} src="${rawUrl}"${after}>`; |
|
}); |
|
|
|
// Process a href attributes (for relative links to files) |
|
html = html.replace(/<a([^>]*)\shref=["']([^"']+)["']([^>]*)>/gi, (match, before, href, after) => { |
|
// Skip if already absolute URL, anchor, or mailto |
|
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#') || href.startsWith('mailto:')) { |
|
return match; |
|
} |
|
|
|
// Only convert relative paths that look like file paths (have extensions or are in common directories) |
|
const isFile = /\.(md|txt|adoc|rst|png|jpg|jpeg|gif|svg|pdf|zip|tar|gz)$/i.test(href) || |
|
/^(resources|assets|images|img|docs|files)\//i.test(href); |
|
|
|
if (!isFile) { |
|
return match; // Probably a relative anchor or page link, leave as-is |
|
} |
|
|
|
// Convert relative path to GitLab raw file URL |
|
const filePath = href.startsWith('/') ? href.slice(1) : href; |
|
let rawUrl: string; |
|
|
|
if (host.includes('github.com')) { |
|
rawUrl = `https://raw.githubusercontent.com/${projectPath}/${defaultBranch}/${filePath}`; |
|
} else if (host.includes('gitlab.com')) { |
|
rawUrl = `/api/gitea-proxy/projects/${encodedPath}/repository/files/${encodeURIComponent(filePath)}/raw?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`; |
|
} else { |
|
// Gitea/Codeberg |
|
rawUrl = `/api/gitea-proxy/repos/${encodedPath}/raw/${encodeURIComponent(filePath)}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`; |
|
} |
|
|
|
return `<a${before} href="${rawUrl}"${after}>`; |
|
}); |
|
|
|
return html; |
|
} |
|
|
|
function getFileTree(files: GitFile[]): any { |
|
const tree: any = {}; |
|
|
|
for (const file of files) { |
|
const parts = file.path.split('/').filter(p => p); // Remove empty parts |
|
let current = tree; |
|
|
|
for (let i = 0; i < parts.length; i++) { |
|
const part = parts[i]; |
|
if (i === parts.length - 1) { |
|
// File |
|
current[part] = file; |
|
} else { |
|
// Directory |
|
if (!current[part] || current[part].path) { |
|
// Create directory if it doesn't exist or if it's currently a file (shouldn't happen) |
|
current[part] = {}; |
|
} |
|
current = current[part]; |
|
} |
|
} |
|
} |
|
|
|
return tree; |
|
} |
|
|
|
function renderFileTree(tree: any, level = 0): string { |
|
if (!tree || typeof tree !== 'object') return ''; |
|
|
|
let result = ''; |
|
const entries = Object.entries(tree).sort(([a, valA], [b, valB]) => { |
|
const aIsFile = valA && typeof valA === 'object' && 'path' in valA; |
|
const bIsFile = valB && typeof valB === 'object' && 'path' in valB; |
|
const aIsDir = valA && typeof valA === 'object' && !('path' in valA); |
|
const bIsDir = valB && typeof valB === 'object' && !('path' in valB); |
|
if (aIsDir && !bIsDir) return -1; |
|
if (!aIsDir && bIsDir) return 1; |
|
return a.localeCompare(b); |
|
}); |
|
|
|
for (const [name, value] of entries) { |
|
if (!value || typeof value !== 'object') continue; |
|
|
|
const indent = ' '.repeat(level); |
|
if ('path' in value) { |
|
// File |
|
result += `${indent}📄 ${name}\n`; |
|
} else { |
|
// Directory |
|
result += `${indent}📁 ${name}/\n`; |
|
result += renderFileTree(value, level + 1); |
|
} |
|
} |
|
|
|
return result; |
|
} |
|
</script> |
|
|
|
<Header /> |
|
|
|
<main class="container mx-auto px-4 py-8"> |
|
{#if loading} |
|
<div class="loading-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">Loading repository...</p> |
|
</div> |
|
{:else if !repoEvent} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">Repository not found.</p> |
|
</div> |
|
{:else} |
|
{#if getBannerUrl()} |
|
<div class="repo-banner-container"> |
|
<img src={getBannerUrl()!} alt="{getRepoName()} banner" class="repo-banner" /> |
|
</div> |
|
{/if} |
|
<PageHeader title={getRepoName()} onRefresh={loadRepo} refreshLoading={loading || loadingRepo} /> |
|
<div class="repo-header mb-6"> |
|
<div class="repo-header-top"> |
|
{#if getImageUrl()} |
|
<div class="repo-profile-image-container"> |
|
<img src={getImageUrl()!} alt="{getRepoName()}" class="repo-profile-image" /> |
|
</div> |
|
{/if} |
|
<div class="repo-title-section"> |
|
<div class="repo-title-row"> |
|
{#if repoEvent} |
|
<EventMenu event={repoEvent} showContentActions={true} /> |
|
{/if} |
|
</div> |
|
{#if gitRepo?.usingGitHubToken} |
|
<div class="github-token-notice mb-4 p-3 bg-fog-highlight dark:bg-fog-dark-highlight border border-fog-border dark:border-fog-dark-border rounded text-sm text-fog-text dark:text-fog-dark-text"> |
|
<Icon name="key" size={16} class="inline mr-2" /> |
|
Using your saved GitHub API token for authenticated requests |
|
</div> |
|
{/if} |
|
{#if getRepoDescription()} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light mb-4"> |
|
{getRepoDescription()} |
|
</p> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
<!-- Tabs --> |
|
<div class="tabs"> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'metadata'} |
|
onclick={() => setActiveTab('metadata')} |
|
> |
|
Metadata |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'about'} |
|
onclick={() => setActiveTab('about')} |
|
> |
|
About |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'repository'} |
|
onclick={() => setActiveTab('repository')} |
|
> |
|
Repository |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'issues'} |
|
onclick={() => setActiveTab('issues')} |
|
> |
|
Issues {issues.length > 0 ? `(${issues.length})` : ''} |
|
</button> |
|
{#if getDocumentation().length > 0} |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'documentation'} |
|
onclick={() => setActiveTab('documentation')} |
|
> |
|
Documentation |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
<!-- Tab Content --> |
|
<div class="tab-content"> |
|
{#if activeTab === 'metadata'} |
|
<div class="metadata-tab"> |
|
<!-- Repository Metadata --> |
|
<div class="repo-meta-section"> |
|
<!-- Event ID and Naddr --> |
|
<div class="metadata-item mb-4"> |
|
<strong class="metadata-label">Event ID:</strong> |
|
<div class="metadata-value"> |
|
<code class="event-id">{repoEvent.id}</code> |
|
</div> |
|
</div> |
|
<div class="metadata-item mb-4"> |
|
<strong class="metadata-label">Naddr:</strong> |
|
<div class="metadata-value"> |
|
<code class="naddr-code">{naddr}</code> |
|
</div> |
|
</div> |
|
|
|
<!-- Links --> |
|
<div class="metadata-item mb-4"> |
|
<strong class="metadata-label">Links:</strong> |
|
<div class="metadata-value"> |
|
{#if gitRepo?.url} |
|
<a href={gitRepo.url} target="_blank" rel="noopener noreferrer" class="metadata-link"> |
|
{gitRepo.url} |
|
</a> |
|
{/if} |
|
{#each getWebUrls() as webUrl} |
|
<a href={webUrl} target="_blank" rel="noopener noreferrer" class="metadata-link"> |
|
{webUrl} |
|
</a> |
|
{/each} |
|
</div> |
|
</div> |
|
|
|
<!-- Clone URLs --> |
|
{#if getCloneUrls().length > 0} |
|
<div class="metadata-item mb-4"> |
|
<strong class="metadata-label">Clone URLs:</strong> |
|
<div class="metadata-value"> |
|
{#each getCloneUrls() as cloneUrl} |
|
<code class="clone-url">{cloneUrl}</code> |
|
{/each} |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Relays --> |
|
{#if getRelays().length > 0} |
|
<div class="metadata-item mb-4"> |
|
<strong class="metadata-label">Relays:</strong> |
|
<div class="metadata-value"> |
|
{#each getRelays() as relay} |
|
{@const domain = extractRelayDomain(relay)} |
|
<a href="/feed/relay/{domain}" class="relay-link"> |
|
{relay} |
|
</a> |
|
{/each} |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Maintainers --> |
|
{#if getMaintainers().length > 0} |
|
<div class="metadata-item mb-4"> |
|
<strong class="metadata-label">Maintainers:</strong> |
|
<div class="metadata-value maintainers-list"> |
|
{#each getMaintainers() as maintainer} |
|
<div class="maintainer-item" class:is-owner={maintainer === repoEvent.pubkey}> |
|
<ProfileBadge pubkey={maintainer} inline={true} /> |
|
</div> |
|
{/each} |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Primary Badge --> |
|
{#if isPrimary()} |
|
<div class="metadata-item mb-4"> |
|
<span class="primary-badge">Primary Repository</span> |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
{:else if activeTab === 'about'} |
|
<div class="about-tab"> |
|
<!-- README --> |
|
{#if gitRepo?.readme} |
|
<div class="readme-container"> |
|
{@html renderReadme(gitRepo.readme.content, gitRepo.readme.format)} |
|
</div> |
|
{:else} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">No README found.</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{:else if activeTab === 'repository'} |
|
<div class="repository-tab"> |
|
{#if loadingGitRepo} |
|
<div class="loading-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">Loading repository data...</p> |
|
</div> |
|
{:else if gitRepo} |
|
<!-- Branch and Commit Info --> |
|
<div class="repo-info-section mb-6"> |
|
<h2 class="text-xl font-bold text-fog-text dark:text-fog-dark-text mb-4"> |
|
Latest Commit |
|
</h2> |
|
{#if gitRepo.commits.length > 0} |
|
<div class="commit-card"> |
|
<div class="commit-header"> |
|
<span class="commit-sha">{gitRepo.commits[0].sha.slice(0, 7)}</span> |
|
<span class="commit-message">{gitRepo.commits[0].message}</span> |
|
</div> |
|
<div class="commit-meta"> |
|
<span>{gitRepo.commits[0].author}</span> |
|
<span>{new Date(gitRepo.commits[0].date).toLocaleString()}</span> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<h2 class="text-xl font-bold text-fog-text dark:text-fog-dark-text mb-4 mt-6"> |
|
Branches |
|
</h2> |
|
<div class="branches-list"> |
|
{#each gitRepo.branches as branch} |
|
<div class="branch-item" class:default={branch.name === gitRepo.defaultBranch}> |
|
<span class="branch-name">{branch.name}</span> |
|
{#if branch.name === gitRepo.defaultBranch} |
|
<span class="branch-badge">default</span> |
|
{/if} |
|
<span class="branch-commit">{branch.commit.sha.slice(0, 7)}</span> |
|
<span class="branch-message">{branch.commit.message}</span> |
|
</div> |
|
{/each} |
|
</div> |
|
</div> |
|
|
|
<!-- File Structure --> |
|
<div class="file-structure-section"> |
|
<h2 class="text-xl font-bold text-fog-text dark:text-fog-dark-text mb-4"> |
|
File Structure |
|
</h2> |
|
{#if gitRepo.files.length > 0} |
|
<FileExplorer files={gitRepo.files} repoInfo={gitRepo} /> |
|
{:else} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">No files found.</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{:else} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">Git repository data not available.</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{:else if activeTab === 'issues'} |
|
<div class="issues-tab"> |
|
{#if loadingIssues} |
|
<div class="loading-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">Loading issues...</p> |
|
</div> |
|
{:else if issues.length > 0} |
|
<div class="issues-filter"> |
|
<label for="status-filter" class="filter-label">Filter by status:</label> |
|
<select |
|
id="status-filter" |
|
value={statusFilter || 'all'} |
|
onchange={(e) => { |
|
const value = (e.target as HTMLSelectElement).value; |
|
statusFilter = value === 'all' ? null : value; |
|
}} |
|
class="status-filter-select" |
|
> |
|
<option value="all">All</option> |
|
{#each availableStatuses as statusOption} |
|
<option value={statusOption}>{statusOption}</option> |
|
{/each} |
|
</select> |
|
<span class="filter-count"> |
|
{#if statusFilter} |
|
Showing {filteredIssues.length} of {issues.length} issues |
|
{:else} |
|
{issues.length} {issues.length === 1 ? 'issue' : 'issues'} |
|
{/if} |
|
{#if loadingIssueData} |
|
<span class="loading-indicator"> (loading details...)</span> |
|
{/if} |
|
</span> |
|
</div> |
|
<div class="issues-list"> |
|
{#if filteredIssues.length > 0} |
|
{#each paginatedIssues as issue} |
|
{@const currentStatus = getCurrentStatus(issue.id)} |
|
{@const isChanging = changingStatus.get(issue.id) || false} |
|
<div class="issue-item"> |
|
<div class="issue-header"> |
|
<div class="issue-status-control"> |
|
<label for="status-{issue.id}" class="status-label">Status:</label> |
|
<select |
|
id="status-{issue.id}" |
|
value={currentStatus} |
|
onchange={(e) => { |
|
const newStatus = (e.target as HTMLSelectElement).value; |
|
if (newStatus !== currentStatus) { |
|
changeIssueStatus(issue.id, newStatus); |
|
} |
|
}} |
|
disabled={isChanging} |
|
class="status-select" |
|
class:open={currentStatus === 'open'} |
|
class:closed={currentStatus === 'closed'} |
|
class:resolved={currentStatus === 'resolved'} |
|
class:draft={currentStatus === 'draft'} |
|
> |
|
{#each availableStatuses as statusOption} |
|
<option value={statusOption} selected={statusOption === currentStatus}> |
|
{statusOption} |
|
</option> |
|
{/each} |
|
{#if !availableStatuses.includes(currentStatus)} |
|
<option value={currentStatus} selected>{currentStatus}</option> |
|
{/if} |
|
</select> |
|
{#if isChanging} |
|
<span class="status-changing">Updating...</span> |
|
{/if} |
|
</div> |
|
</div> |
|
<FeedPost post={issue} /> |
|
{#if issueComments.has(issue.id)} |
|
<div class="issue-comments"> |
|
<h3 class="comments-header">Comments ({issueComments.get(issue.id)!.length})</h3> |
|
{#each issueComments.get(issue.id)! as comment} |
|
<div class="comment-item"> |
|
<FeedPost post={comment} /> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
|
|
<!-- Pagination controls --> |
|
{#if totalPages > 1} |
|
<div class="pagination pagination-bottom"> |
|
<button |
|
onclick={() => issuesPage = Math.max(1, issuesPage - 1)} |
|
disabled={issuesPage === 1} |
|
class="pagination-button" |
|
aria-label="Previous page" |
|
> |
|
Previous |
|
</button> |
|
<span class="pagination-info"> |
|
Page {issuesPage} of {totalPages} ({filteredIssues.length} {filteredIssues.length === 1 ? 'issue' : 'issues'}) |
|
</span> |
|
<button |
|
onclick={() => issuesPage = Math.min(totalPages, issuesPage + 1)} |
|
disabled={issuesPage === totalPages} |
|
class="pagination-button" |
|
aria-label="Next page" |
|
> |
|
Next |
|
</button> |
|
</div> |
|
{/if} |
|
{:else} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">No issues found with status "{statusFilter}".</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{:else} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">No issues found.</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{:else if activeTab === 'documentation'} |
|
<div class="documentation-tab"> |
|
{#if documentationEvents.size > 0} |
|
<div class="documentation-list"> |
|
{#each Array.from(documentationEvents.entries()) as [docNaddr, docEvent]} |
|
<div class="documentation-item"> |
|
<div class="doc-header"> |
|
<div class="doc-meta"> |
|
<span class="doc-kind">Kind {docEvent.kind}</span> |
|
<a href="/event/{docEvent.id}" onclick={(e) => { e.preventDefault(); sessionStorage.setItem('aitherboard_preloadedEvent', JSON.stringify(docEvent)); goto(`/event/${docEvent.id}`); }} class="doc-event-link">View Event</a> |
|
<EventMenu event={docEvent} showContentActions={true} /> |
|
</div> |
|
</div> |
|
<div class="doc-content"> |
|
{#if docEvent.kind === KIND.LONG_FORM_NOTE || docEvent.kind === KIND.SHORT_TEXT_NOTE} |
|
<MarkdownRenderer content={docEvent.content} event={docEvent} /> |
|
{:else} |
|
<!-- Try to detect if it's asciidoc or markdown --> |
|
{@const isAsciidoc = docEvent.content.includes('= ') || docEvent.content.includes('== ') || docEvent.tags.some(t => Array.isArray(t) && t[0] === 'format' && t[1] === 'asciidoc')} |
|
{#if isAsciidoc} |
|
<div class="readme-container"> |
|
{@html renderReadme(docEvent.content, 'asciidoc')} |
|
</div> |
|
{:else} |
|
<MarkdownRenderer content={docEvent.content} event={docEvent} /> |
|
{/if} |
|
{/if} |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{:else} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">No documentation found.</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
</main> |
|
|
|
<style> |
|
|
|
.loading-state, |
|
.empty-state { |
|
padding: 2rem; |
|
text-align: center; |
|
} |
|
|
|
.repo-banner-container { |
|
width: 100%; |
|
height: 300px; |
|
overflow: hidden; |
|
border-radius: 0.5rem; |
|
margin-bottom: 2rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
:global(.dark) .repo-banner-container { |
|
background: var(--fog-dark-highlight, #475569); |
|
} |
|
|
|
.repo-banner { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: cover; |
|
border-radius: 0.5rem; |
|
} |
|
|
|
.repo-header { |
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
padding-bottom: 1rem; |
|
} |
|
|
|
:global(.dark) .repo-header { |
|
border-bottom-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.repo-header-top { |
|
display: flex; |
|
align-items: flex-start; |
|
gap: 1.5rem; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.repo-profile-image-container { |
|
flex-shrink: 0; |
|
width: 3rem; |
|
height: 3rem; |
|
border-radius: 50%; |
|
overflow: hidden; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border: 2px solid var(--fog-border, #e5e7eb); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
:global(.dark) .repo-profile-image-container { |
|
background: var(--fog-dark-highlight, #475569); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.repo-profile-image { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: contain; |
|
} |
|
|
|
.repo-title-section { |
|
flex: 1; |
|
min-width: 0; |
|
} |
|
|
|
.repo-title-row { |
|
display: flex; |
|
align-items: flex-start; |
|
gap: 1rem; |
|
justify-content: space-between; |
|
flex-wrap: wrap; |
|
} |
|
|
|
|
|
|
|
|
|
.tabs { |
|
display: flex; |
|
gap: 0.5rem; |
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
flex-wrap: wrap; |
|
overflow-x: auto; |
|
-webkit-overflow-scrolling: touch; |
|
scrollbar-width: thin; |
|
} |
|
|
|
:global(.dark) .tabs { |
|
border-bottom-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.tab-button { |
|
padding: 0.75rem 1.25rem; |
|
border: none; |
|
border-bottom: 2px solid transparent; |
|
background: transparent; |
|
color: var(--fog-text-light, #52667a); |
|
font-size: 0.875rem; |
|
font-weight: 500; |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
white-space: nowrap; |
|
min-height: 2.75rem; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
:global(.dark) .tab-button { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.tab-button:hover { |
|
color: var(--fog-text, #1f2937); |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .tab-button:hover { |
|
color: var(--fog-dark-text, #f9fafb); |
|
background: var(--fog-dark-highlight, #475569); |
|
} |
|
|
|
.tab-button.active { |
|
color: var(--fog-text, #1f2937); |
|
border-bottom-color: var(--fog-accent, #64748b); |
|
background: var(--fog-highlight, #f3f4f6); |
|
font-weight: 600; |
|
} |
|
|
|
:global(.dark) .tab-button.active { |
|
color: var(--fog-dark-text, #f9fafb); |
|
border-bottom-color: var(--fog-dark-accent, #94a3b8); |
|
background: var(--fog-dark-highlight, #475569); |
|
} |
|
|
|
.tab-button:focus { |
|
outline: 2px solid var(--fog-accent, #64748b); |
|
outline-offset: 2px; |
|
} |
|
|
|
:global(.dark) .tab-button:focus { |
|
outline-color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
@media (max-width: 640px) { |
|
.tabs { |
|
gap: 0.25rem; |
|
padding-bottom: 0.5rem; |
|
} |
|
|
|
.tab-button { |
|
padding: 0.5rem 0.75rem; |
|
font-size: 0.8125rem; |
|
min-height: 2.5rem; |
|
} |
|
} |
|
|
|
|
|
.tab-content { |
|
margin-top: 2rem; |
|
} |
|
|
|
.readme-container { |
|
padding: 1.5rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
background: var(--fog-post, #ffffff); |
|
} |
|
|
|
:global(.dark) .readme-container { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
} |
|
|
|
.readme-container :global(h1), |
|
.readme-container :global(h2), |
|
.readme-container :global(h3) { |
|
color: var(--fog-text, #1f2937); |
|
margin-top: 1.5rem; |
|
margin-bottom: 0.75rem; |
|
} |
|
|
|
:global(.dark) .readme-container :global(h1), |
|
:global(.dark) .readme-container :global(h2), |
|
:global(.dark) .readme-container :global(h3) { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.readme-container :global(p) { |
|
color: var(--fog-text, #1f2937); |
|
line-height: 1.6; |
|
margin-bottom: 1rem; |
|
} |
|
|
|
:global(.dark) .readme-container :global(p) { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.readme-container :global(code) { |
|
background: var(--fog-highlight, #f3f4f6); |
|
padding: 0.125rem 0.25rem; |
|
border-radius: 0.25rem; |
|
font-family: monospace; |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
:global(.dark) .readme-container :global(code) { |
|
background: var(--fog-dark-highlight, #475569); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
/* Code blocks with highlight.js are styled by vs2015 theme */ |
|
/* Pre blocks for non-highlighted code */ |
|
.readme-container :global(pre) { |
|
padding: 1rem; |
|
border-radius: 0.5rem; |
|
overflow-x: auto; |
|
/* Background will be overridden by vs2015 theme for hljs code blocks */ |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .readme-container :global(pre) { |
|
background: var(--fog-dark-highlight, #475569); |
|
} |
|
|
|
.commit-card { |
|
padding: 1rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
background: var(--fog-post, #ffffff); |
|
} |
|
|
|
:global(.dark) .commit-card { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
} |
|
|
|
.commit-header { |
|
display: flex; |
|
gap: 0.5rem; |
|
align-items: center; |
|
margin-bottom: 0.5rem; |
|
} |
|
|
|
.commit-sha { |
|
font-family: monospace; |
|
color: var(--fog-accent, #64748b); |
|
font-size: 0.875rem; |
|
} |
|
|
|
:global(.dark) .commit-sha { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.commit-message { |
|
color: var(--fog-text, #1f2937); |
|
font-weight: 500; |
|
} |
|
|
|
:global(.dark) .commit-message { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.commit-meta { |
|
display: flex; |
|
gap: 1rem; |
|
font-size: 0.875rem; |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark) .commit-meta { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.branches-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
} |
|
|
|
.branch-item { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.75rem; |
|
padding: 0.75rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.375rem; |
|
background: var(--fog-post, #ffffff); |
|
} |
|
|
|
:global(.dark) .branch-item { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
} |
|
|
|
.branch-item.default { |
|
border-color: var(--fog-accent, #64748b); |
|
} |
|
|
|
:global(.dark) .branch-item.default { |
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.branch-name { |
|
font-weight: 500; |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
:global(.dark) .branch-name { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.branch-badge { |
|
font-size: 0.75rem; |
|
padding: 0.125rem 0.5rem; |
|
border-radius: 0.25rem; |
|
background: var(--fog-accent, #64748b); |
|
color: white; |
|
} |
|
|
|
:global(.dark) .branch-badge { |
|
background: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.branch-commit { |
|
font-family: monospace; |
|
font-size: 0.875rem; |
|
color: var(--fog-accent, #64748b); |
|
} |
|
|
|
:global(.dark) .branch-commit { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.branch-message { |
|
flex: 1; |
|
color: var(--fog-text-light, #52667a); |
|
font-size: 0.875rem; |
|
} |
|
|
|
:global(.dark) .branch-message { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
|
|
.issues-filter { |
|
display: flex; |
|
align-items: center; |
|
gap: 1rem; |
|
margin-bottom: 1.5rem; |
|
padding: 1rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.5rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .issues-filter { |
|
background: var(--fog-dark-highlight, #475569); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.filter-label { |
|
font-weight: 500; |
|
color: var(--fog-text, #1f2937); |
|
font-size: 0.875rem; |
|
} |
|
|
|
:global(.dark) .filter-label { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.status-filter-select { |
|
padding: 0.5rem 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: 0.875rem; |
|
cursor: pointer; |
|
transition: border-color 0.2s, background 0.2s; |
|
} |
|
|
|
:global(.dark) .status-filter-select { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.status-filter-select:hover { |
|
border-color: var(--fog-accent, #64748b); |
|
} |
|
|
|
:global(.dark) .status-filter-select:hover { |
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.status-filter-select:focus { |
|
outline: none; |
|
border-color: var(--fog-accent, #64748b); |
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
|
} |
|
|
|
:global(.dark) .status-filter-select:focus { |
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.2); |
|
} |
|
|
|
.filter-count { |
|
margin-left: auto; |
|
font-size: 0.875rem; |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark) .filter-count { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.issues-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1rem; |
|
} |
|
|
|
.issue-item { |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
padding: 1rem; |
|
background: var(--fog-post, #ffffff); |
|
} |
|
|
|
:global(.dark) .issue-item { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
} |
|
|
|
.issue-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 1rem; |
|
padding: 0.75rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.375rem; |
|
} |
|
|
|
:global(.dark) .issue-header { |
|
background: var(--fog-dark-highlight, #475569); |
|
} |
|
|
|
.issue-status-control { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.75rem; |
|
} |
|
|
|
.status-label { |
|
font-weight: 600; |
|
color: var(--fog-text, #1f2937); |
|
font-size: 0.875rem; |
|
} |
|
|
|
:global(.dark) .status-label { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.status-select { |
|
padding: 0.5rem 1rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.375rem; |
|
background: var(--fog-post, #ffffff); |
|
color: var(--fog-text, #1f2937); |
|
font-size: 0.875rem; |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
min-width: 120px; |
|
} |
|
|
|
:global(.dark) .status-select { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.status-select:hover:not(:disabled) { |
|
border-color: var(--fog-accent, #64748b); |
|
} |
|
|
|
:global(.dark) .status-select:hover:not(:disabled) { |
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.status-select:disabled { |
|
opacity: 0.6; |
|
cursor: not-allowed; |
|
} |
|
|
|
.status-select.open { |
|
border-color: #10b981; |
|
background: #d1fae5; |
|
color: #065f46; |
|
} |
|
|
|
:global(.dark) .status-select.open { |
|
border-color: #10b981; |
|
background: #064e3b; |
|
color: #a7f3d0; |
|
} |
|
|
|
.status-select.closed { |
|
border-color: #ef4444; |
|
background: #fee2e2; |
|
color: #991b1b; |
|
} |
|
|
|
:global(.dark) .status-select.closed { |
|
border-color: #ef4444; |
|
background: #7f1d1d; |
|
color: #fecaca; |
|
} |
|
|
|
.status-select.resolved { |
|
border-color: #3b82f6; |
|
background: #dbeafe; |
|
color: #1e40af; |
|
} |
|
|
|
:global(.dark) .status-select.resolved { |
|
border-color: #3b82f6; |
|
background: #1e3a8a; |
|
color: #bfdbfe; |
|
} |
|
|
|
.status-select.draft { |
|
border-color: #8b5cf6; |
|
background: #ede9fe; |
|
color: #5b21b6; |
|
} |
|
|
|
:global(.dark) .status-select.draft { |
|
border-color: #8b5cf6; |
|
background: #4c1d95; |
|
color: #ddd6fe; |
|
} |
|
|
|
.status-changing { |
|
font-size: 0.875rem; |
|
color: var(--fog-text-light, #52667a); |
|
font-style: italic; |
|
} |
|
|
|
:global(.dark) .status-changing { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.issue-comments { |
|
margin-top: 1rem; |
|
padding-top: 1rem; |
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .issue-comments { |
|
border-top-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.comments-header { |
|
font-size: 1rem; |
|
font-weight: 600; |
|
color: var(--fog-text, #1f2937); |
|
margin-bottom: 0.75rem; |
|
} |
|
|
|
:global(.dark) .comments-header { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.comment-item { |
|
margin-top: 0.75rem; |
|
padding-left: 1rem; |
|
border-left: 2px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .comment-item { |
|
border-left-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
|
|
.repo-meta-section { |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
padding: 1.5rem; |
|
background: var(--fog-post, #ffffff); |
|
overflow-wrap: break-word; |
|
word-wrap: break-word; |
|
} |
|
|
|
:global(.dark) .repo-meta-section { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
} |
|
|
|
.metadata-value { |
|
word-break: break-word; |
|
overflow-wrap: break-word; |
|
max-width: 100%; |
|
} |
|
|
|
|
|
.metadata-item { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
} |
|
|
|
.metadata-label { |
|
font-size: 0.875rem; |
|
font-weight: 600; |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
:global(.dark) .metadata-label { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.metadata-value { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
font-size: 0.875rem; |
|
word-break: break-word; |
|
overflow-wrap: break-word; |
|
max-width: 100%; |
|
} |
|
|
|
.clone-url { |
|
font-family: monospace; |
|
padding: 0.25rem 0.5rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.25rem; |
|
color: var(--fog-text, #1f2937); |
|
word-break: break-all; |
|
word-wrap: break-word; |
|
overflow-wrap: break-word; |
|
display: block; |
|
max-width: 100%; |
|
overflow: hidden; |
|
margin-bottom: 0.5rem; |
|
} |
|
|
|
:global(.dark) .clone-url { |
|
background: var(--fog-dark-highlight, #475569); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.relay-link { |
|
display: inline-block; |
|
padding: 0.25rem 0.5rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.25rem; |
|
color: var(--fog-accent, #64748b); |
|
font-size: 0.875rem; |
|
text-decoration: none; |
|
word-break: break-all; |
|
word-wrap: break-word; |
|
overflow-wrap: break-word; |
|
max-width: 100%; |
|
margin-bottom: 0.5rem; |
|
transition: background 0.2s; |
|
} |
|
|
|
:global(.dark) .relay-link { |
|
background: var(--fog-dark-highlight, #475569); |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.relay-link:hover { |
|
background: var(--fog-accent, #64748b); |
|
color: white; |
|
text-decoration: none; |
|
} |
|
|
|
:global(.dark) .relay-link:hover { |
|
background: var(--fog-dark-accent, #94a3b8); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
|
|
.primary-badge { |
|
display: inline-block; |
|
padding: 0.25rem 0.75rem; |
|
background: var(--fog-accent, #64748b); |
|
color: white; |
|
border-radius: 0.375rem; |
|
font-size: 0.875rem; |
|
font-weight: 500; |
|
} |
|
|
|
:global(.dark) .primary-badge { |
|
background: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.event-id, |
|
.naddr-code { |
|
font-family: monospace; |
|
font-size: 0.875rem; |
|
padding: 0.25rem 0.5rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.25rem; |
|
color: var(--fog-text, #1f2937); |
|
word-break: break-all; |
|
word-wrap: break-word; |
|
overflow-wrap: break-word; |
|
display: block; |
|
max-width: 100%; |
|
overflow: hidden; |
|
} |
|
|
|
:global(.dark) .event-id, |
|
:global(.dark) .naddr-code { |
|
background: var(--fog-dark-highlight, #475569); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.metadata-link { |
|
color: var(--fog-accent, #64748b); |
|
text-decoration: none; |
|
word-break: break-all; |
|
word-wrap: break-word; |
|
overflow-wrap: break-word; |
|
display: block; |
|
max-width: 100%; |
|
} |
|
|
|
:global(.dark) .metadata-link { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.metadata-link:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
.maintainers-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
} |
|
|
|
.maintainer-item { |
|
padding: 0.5rem; |
|
border-radius: 0.375rem; |
|
transition: background 0.2s; |
|
} |
|
|
|
.maintainer-item.is-owner { |
|
background: var(--fog-highlight, #f3f4f6); |
|
border: 1px solid var(--fog-accent, #64748b); |
|
} |
|
|
|
:global(.dark) .maintainer-item.is-owner { |
|
background: var(--fog-dark-highlight, #475569); |
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.documentation-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 2rem; |
|
} |
|
|
|
.documentation-item { |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
padding: 1.5rem; |
|
background: var(--fog-post, #ffffff); |
|
} |
|
|
|
:global(.dark) .documentation-item { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
} |
|
|
|
.doc-header { |
|
display: flex; |
|
justify-content: flex-end; |
|
align-items: center; |
|
margin-bottom: 1rem; |
|
padding-bottom: 1rem; |
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .doc-header { |
|
border-bottom-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.doc-meta { |
|
display: flex; |
|
gap: 1rem; |
|
align-items: center; |
|
font-size: 0.875rem; |
|
} |
|
|
|
@media (max-width: 640px) { |
|
.doc-header { |
|
justify-content: flex-start; |
|
} |
|
|
|
.doc-meta { |
|
flex-direction: column; |
|
align-items: flex-start; |
|
gap: 0.5rem; |
|
} |
|
} |
|
|
|
.doc-kind { |
|
color: var(--fog-text-light, #52667a); |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 0.25rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .doc-kind { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
background: var(--fog-dark-highlight, #475569); |
|
} |
|
|
|
.doc-event-link { |
|
color: var(--fog-accent, #64748b); |
|
text-decoration: none; |
|
} |
|
|
|
:global(.dark) .doc-event-link { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.doc-event-link:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
.doc-content { |
|
margin-top: 1rem; |
|
} |
|
|
|
.pagination { |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
gap: 1rem; |
|
margin-top: 2rem; |
|
padding: 1rem; |
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .pagination { |
|
border-top-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.pagination-button { |
|
padding: 0.5rem 1rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.375rem; |
|
background: var(--fog-post, #ffffff); |
|
color: var(--fog-text, #1f2937); |
|
font-size: 0.875rem; |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
} |
|
|
|
.pagination-button:hover:not(:disabled) { |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-color: var(--fog-accent, #64748b); |
|
} |
|
|
|
.pagination-button:disabled { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
:global(.dark) .pagination-button { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-post, #1f2937); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
:global(.dark) .pagination-button:hover:not(:disabled) { |
|
background: var(--fog-dark-highlight, #475569); |
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.pagination-info { |
|
font-size: 0.875rem; |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark) .pagination-info { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
</style>
|
|
|