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

<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>