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.
3621 lines
101 KiB
3621 lines
101 KiB
<script lang="ts"> |
|
import { onMount } from 'svelte'; |
|
import { page } from '$app/stores'; |
|
import { goto } from '$app/navigation'; |
|
import CodeEditor from '$lib/components/CodeEditor.svelte'; |
|
import PRDetail from '$lib/components/PRDetail.svelte'; |
|
import UserBadge from '$lib/components/UserBadge.svelte'; |
|
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; |
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
|
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; |
|
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
|
import { KIND } from '$lib/types/nostr.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
|
|
// Get page data for OpenGraph metadata - use $derived to make it reactive |
|
const pageData = $derived($page.data as { |
|
title?: string; |
|
description?: string; |
|
image?: string; |
|
banner?: string; |
|
repoName?: string; |
|
repoDescription?: string; |
|
repoUrl?: string; |
|
repoCloneUrls?: string[]; |
|
repoMaintainers?: string[]; |
|
repoOwnerPubkey?: string; |
|
repoLanguage?: string; |
|
repoTopics?: string[]; |
|
repoWebsite?: string; |
|
repoIsPrivate?: boolean; |
|
}); |
|
|
|
const npub = ($page.params as { npub?: string; repo?: string }).npub || ''; |
|
const repo = ($page.params as { npub?: string; repo?: string }).repo || ''; |
|
|
|
let loading = $state(true); |
|
let error = $state<string | null>(null); |
|
let repoNotFound = $state(false); // Track if repository doesn't exist |
|
let files = $state<Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>>([]); |
|
let currentPath = $state(''); |
|
let currentFile = $state<string | null>(null); |
|
let fileContent = $state(''); |
|
let fileLanguage = $state<'markdown' | 'asciidoc' | 'text'>('text'); |
|
let editedContent = $state(''); |
|
let hasChanges = $state(false); |
|
let saving = $state(false); |
|
let branches = $state<string[]>([]); |
|
let currentBranch = $state('main'); |
|
let commitMessage = $state(''); |
|
let userPubkey = $state<string | null>(null); |
|
let showCommitDialog = $state(false); |
|
let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs'>('files'); |
|
|
|
// Navigation stack for directories |
|
let pathStack = $state<string[]>([]); |
|
|
|
// New file creation |
|
let showCreateFileDialog = $state(false); |
|
let newFileName = $state(''); |
|
let newFileContent = $state(''); |
|
|
|
// Branch creation |
|
let showCreateBranchDialog = $state(false); |
|
let newBranchName = $state(''); |
|
let newBranchFrom = $state('main'); |
|
|
|
// Commit history |
|
let commits = $state<Array<{ hash: string; message: string; author: string; date: string; files: string[] }>>([]); |
|
let loadingCommits = $state(false); |
|
let selectedCommit = $state<string | null>(null); |
|
let showDiff = $state(false); |
|
let diffData = $state<Array<{ file: string; additions: number; deletions: number; diff: string }>>([]); |
|
|
|
// Tags |
|
let tags = $state<Array<{ name: string; hash: string; message?: string }>>([]); |
|
let showCreateTagDialog = $state(false); |
|
let newTagName = $state(''); |
|
let newTagMessage = $state(''); |
|
let newTagRef = $state('HEAD'); |
|
|
|
// Maintainer status |
|
let isMaintainer = $state(false); |
|
let loadingMaintainerStatus = $state(false); |
|
|
|
// Verification status |
|
let verificationStatus = $state<{ verified: boolean; error?: string; message?: string } | null>(null); |
|
let loadingVerification = $state(false); |
|
|
|
// Issues |
|
let issues = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number }>>([]); |
|
let loadingIssues = $state(false); |
|
let showCreateIssueDialog = $state(false); |
|
let newIssueSubject = $state(''); |
|
let newIssueContent = $state(''); |
|
let newIssueLabels = $state<string[]>(['']); |
|
|
|
// Pull Requests |
|
let prs = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number; commitId?: string }>>([]); |
|
let loadingPRs = $state(false); |
|
let showCreatePRDialog = $state(false); |
|
let newPRSubject = $state(''); |
|
let newPRContent = $state(''); |
|
let newPRCommitId = $state(''); |
|
let newPRBranchName = $state(''); |
|
let newPRLabels = $state<string[]>(['']); |
|
let selectedPR = $state<string | null>(null); |
|
|
|
// Documentation |
|
let documentationContent = $state<string | null>(null); |
|
let documentationHtml = $state<string | null>(null); |
|
let loadingDocs = $state(false); |
|
|
|
// README |
|
let readmeContent = $state<string | null>(null); |
|
let readmePath = $state<string | null>(null); |
|
let readmeIsMarkdown = $state(false); |
|
let loadingReadme = $state(false); |
|
let readmeHtml = $state<string>(''); |
|
let highlightedFileContent = $state<string>(''); |
|
|
|
// Fork |
|
let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null); |
|
let forking = $state(false); |
|
|
|
// Repository images |
|
let repoImage = $state<string | null>(null); |
|
let repoBanner = $state<string | null>(null); |
|
|
|
// Mobile view toggle for file list/file viewer |
|
let showFileListOnMobile = $state(true); |
|
|
|
async function loadReadme() { |
|
if (repoNotFound) return; |
|
loadingReadme = true; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch}`); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
if (data.found) { |
|
readmeContent = data.content; |
|
readmePath = data.path; |
|
readmeIsMarkdown = data.isMarkdown; |
|
|
|
// Render markdown if needed |
|
if (readmeIsMarkdown && readmeContent) { |
|
const MarkdownIt = (await import('markdown-it')).default; |
|
const hljsModule = await import('highlight.js'); |
|
const hljs = hljsModule.default || hljsModule; |
|
|
|
const md = new MarkdownIt({ |
|
highlight: function (str: string, lang: string): string { |
|
if (lang && hljs.getLanguage(lang)) { |
|
try { |
|
return '<pre class="hljs"><code>' + |
|
hljs.highlight(str, { language: lang }).value + |
|
'</code></pre>'; |
|
} catch (err) { |
|
// Fallback to escaped HTML if highlighting fails |
|
// This is expected for unsupported languages |
|
} |
|
} |
|
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>'; |
|
} |
|
}); |
|
|
|
readmeHtml = md.render(readmeContent); |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Error loading README:', err); |
|
} finally { |
|
loadingReadme = false; |
|
} |
|
} |
|
|
|
// Map file extensions to highlight.js language names |
|
function getHighlightLanguage(ext: string): string { |
|
const langMap: Record<string, string> = { |
|
'js': 'javascript', |
|
'ts': 'typescript', |
|
'jsx': 'javascript', |
|
'tsx': 'typescript', |
|
'json': 'json', |
|
'css': 'css', |
|
'html': 'xml', |
|
'xml': 'xml', |
|
'yaml': 'yaml', |
|
'yml': 'yaml', |
|
'py': 'python', |
|
'rb': 'ruby', |
|
'go': 'go', |
|
'rs': 'rust', |
|
'java': 'java', |
|
'c': 'c', |
|
'cpp': 'cpp', |
|
'h': 'c', |
|
'hpp': 'cpp', |
|
'sh': 'bash', |
|
'bash': 'bash', |
|
'zsh': 'bash', |
|
'sql': 'sql', |
|
'php': 'php', |
|
'swift': 'swift', |
|
'kt': 'kotlin', |
|
'scala': 'scala', |
|
'r': 'r', |
|
'm': 'objectivec', |
|
'mm': 'objectivec', |
|
'vue': 'xml', |
|
'svelte': 'xml', |
|
'dockerfile': 'dockerfile', |
|
'toml': 'toml', |
|
'ini': 'ini', |
|
'conf': 'ini', |
|
'log': 'plaintext', |
|
'txt': 'plaintext', |
|
'md': 'markdown', |
|
'markdown': 'markdown', |
|
'mdown': 'markdown', |
|
'mkdn': 'markdown', |
|
'mkd': 'markdown', |
|
'mdwn': 'markdown', |
|
'adoc': 'asciidoc', |
|
'asciidoc': 'asciidoc', |
|
'ad': 'asciidoc', |
|
}; |
|
return langMap[ext.toLowerCase()] || 'plaintext'; |
|
} |
|
|
|
async function applySyntaxHighlighting(content: string, ext: string) { |
|
try { |
|
const hljsModule = await import('highlight.js'); |
|
// highlight.js v11+ uses default export |
|
const hljs = hljsModule.default || hljsModule; |
|
const lang = getHighlightLanguage(ext); |
|
|
|
// Register Markdown language if needed (not in highlight.js by default) |
|
if (lang === 'markdown' && !hljs.getLanguage('markdown')) { |
|
hljs.registerLanguage('markdown', function(hljs) { |
|
return { |
|
name: 'Markdown', |
|
aliases: ['md', 'mkdown', 'mkd'], |
|
contains: [ |
|
// Headers |
|
{ |
|
className: 'section', |
|
begin: /^#{1,6}\s+/, |
|
relevance: 10 |
|
}, |
|
// Bold |
|
{ |
|
className: 'strong', |
|
begin: /\*\*[^*]+\*\*/, |
|
relevance: 0 |
|
}, |
|
{ |
|
className: 'strong', |
|
begin: /__[^_]+__/, |
|
relevance: 0 |
|
}, |
|
// Italic |
|
{ |
|
className: 'emphasis', |
|
begin: /\*[^*]+\*/, |
|
relevance: 0 |
|
}, |
|
{ |
|
className: 'emphasis', |
|
begin: /_[^_]+_/, |
|
relevance: 0 |
|
}, |
|
// Inline code |
|
{ |
|
className: 'code', |
|
begin: /`[^`]+`/, |
|
relevance: 0 |
|
}, |
|
// Code blocks |
|
{ |
|
className: 'code', |
|
begin: /^```[\w]*/, |
|
end: /^```$/, |
|
contains: [{ begin: /./ }] |
|
}, |
|
// Links |
|
{ |
|
className: 'link', |
|
begin: /\[/, |
|
end: /\]/, |
|
contains: [ |
|
{ |
|
className: 'string', |
|
begin: /\(/, |
|
end: /\)/ |
|
} |
|
] |
|
}, |
|
// Images |
|
{ |
|
className: 'string', |
|
begin: /!\[/, |
|
end: /\]/ |
|
}, |
|
// Lists |
|
{ |
|
className: 'bullet', |
|
begin: /^(\s*)([*+-]|\d+\.)\s+/, |
|
relevance: 0 |
|
}, |
|
// Blockquotes |
|
{ |
|
className: 'quote', |
|
begin: /^>\s+/, |
|
relevance: 0 |
|
}, |
|
// Horizontal rules |
|
{ |
|
className: 'horizontal_rule', |
|
begin: /^(\*{3,}|-{3,}|_{3,})$/, |
|
relevance: 0 |
|
} |
|
] |
|
}; |
|
}); |
|
} |
|
|
|
// Register AsciiDoc language if needed (not in highlight.js by default) |
|
if (lang === 'asciidoc' && !hljs.getLanguage('asciidoc')) { |
|
hljs.registerLanguage('asciidoc', function(hljs) { |
|
return { |
|
name: 'AsciiDoc', |
|
aliases: ['adoc', 'asciidoc', 'ad'], |
|
contains: [ |
|
// Headers |
|
{ |
|
className: 'section', |
|
begin: /^={1,6}\s+/, |
|
relevance: 10 |
|
}, |
|
// Bold |
|
{ |
|
className: 'strong', |
|
begin: /\*\*[^*]+\*\*/, |
|
relevance: 0 |
|
}, |
|
// Italic |
|
{ |
|
className: 'emphasis', |
|
begin: /_[^_]+_/, |
|
relevance: 0 |
|
}, |
|
// Inline code |
|
{ |
|
className: 'code', |
|
begin: /`[^`]+`/, |
|
relevance: 0 |
|
}, |
|
// Code blocks |
|
{ |
|
className: 'code', |
|
begin: /^----+$/, |
|
end: /^----+$/, |
|
contains: [{ begin: /./ }] |
|
}, |
|
// Lists |
|
{ |
|
className: 'bullet', |
|
begin: /^(\*+|\.+|-+)\s+/, |
|
relevance: 0 |
|
}, |
|
// Links |
|
{ |
|
className: 'link', |
|
begin: /link:/, |
|
end: /\[/, |
|
contains: [{ begin: /\[/, end: /\]/ }] |
|
}, |
|
// Comments |
|
{ |
|
className: 'comment', |
|
begin: /^\/\/.*$/, |
|
relevance: 0 |
|
}, |
|
// Attributes |
|
{ |
|
className: 'attr', |
|
begin: /^:.*:$/, |
|
relevance: 0 |
|
} |
|
] |
|
}; |
|
}); |
|
} |
|
|
|
// Apply highlighting |
|
if (lang === 'plaintext') { |
|
highlightedFileContent = `<pre><code class="hljs">${hljs.highlight(content, { language: 'plaintext' }).value}</code></pre>`; |
|
} else if (hljs.getLanguage(lang)) { |
|
highlightedFileContent = `<pre><code class="hljs language-${lang}">${hljs.highlight(content, { language: lang }).value}</code></pre>`; |
|
} else { |
|
// Fallback to auto-detection |
|
highlightedFileContent = `<pre><code class="hljs">${hljs.highlightAuto(content).value}</code></pre>`; |
|
} |
|
} catch (err) { |
|
console.error('Error applying syntax highlighting:', err); |
|
// Fallback to plain text |
|
highlightedFileContent = `<pre><code class="hljs">${content}</code></pre>`; |
|
} |
|
} |
|
|
|
async function loadForkInfo() { |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/fork`); |
|
if (response.ok) { |
|
forkInfo = await response.json(); |
|
} |
|
} catch (err) { |
|
console.error('Error loading fork info:', err); |
|
} |
|
} |
|
|
|
async function forkRepository() { |
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
forking = true; |
|
error = null; |
|
|
|
try { |
|
// Security: Truncate npub in logs |
|
const truncatedNpub = npub.length > 16 ? `${npub.slice(0, 12)}...` : npub; |
|
console.log(`[Fork UI] Starting fork of ${truncatedNpub}/${repo}...`); |
|
const response = await fetch(`/api/repos/${npub}/${repo}/fork`, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ userPubkey }) |
|
}); |
|
|
|
const data = await response.json(); |
|
|
|
if (response.ok && data.success !== false) { |
|
const message = data.message || `Repository forked successfully! Published to ${data.fork?.publishedTo?.announcement || 0} relay(s).`; |
|
console.log(`[Fork UI] ✓ ${message}`); |
|
// Security: Truncate npub in logs |
|
const truncatedForkNpub = data.fork.npub.length > 16 ? `${data.fork.npub.slice(0, 12)}...` : data.fork.npub; |
|
console.log(`[Fork UI] - Fork location: /repos/${truncatedForkNpub}/${data.fork.repo}`); |
|
console.log(`[Fork UI] - Announcement ID: ${data.fork.announcementId}`); |
|
console.log(`[Fork UI] - Ownership Transfer ID: ${data.fork.ownershipTransferId}`); |
|
|
|
alert(`✓ ${message}\n\nRedirecting to your fork...`); |
|
goto(`/repos/${data.fork.npub}/${data.fork.repo}`); |
|
} else { |
|
const errorMessage = data.error || 'Failed to fork repository'; |
|
const errorDetails = data.details ? `\n\nDetails: ${data.details}` : ''; |
|
const fullError = `${errorMessage}${errorDetails}`; |
|
|
|
console.error(`[Fork UI] ✗ Fork failed: ${errorMessage}`); |
|
if (data.details) { |
|
console.error(`[Fork UI] Details: ${data.details}`); |
|
} |
|
if (data.eventName) { |
|
console.error(`[Fork UI] Failed event: ${data.eventName}`); |
|
} |
|
|
|
error = fullError; |
|
alert(`✗ Fork failed!\n\n${fullError}`); |
|
} |
|
} catch (err) { |
|
const errorMessage = err instanceof Error ? err.message : 'Failed to fork repository'; |
|
console.error(`[Fork UI] ✗ Unexpected error: ${errorMessage}`, err); |
|
error = errorMessage; |
|
alert(`✗ Fork failed!\n\n${errorMessage}`); |
|
} finally { |
|
forking = false; |
|
} |
|
} |
|
|
|
async function loadDocumentation() { |
|
if (loadingDocs || documentationContent !== null) return; |
|
|
|
loadingDocs = true; |
|
try { |
|
// Check if repo is private and user has access |
|
const data = $page.data as typeof pageData; |
|
if (data.repoIsPrivate) { |
|
// Check access via API |
|
const headers: Record<string, string> = {}; |
|
if (userPubkey) { |
|
try { |
|
const decoded = nip19.decode(userPubkey); |
|
if (decoded.type === 'npub') { |
|
headers['X-User-Pubkey'] = decoded.data as string; |
|
} else { |
|
headers['X-User-Pubkey'] = userPubkey; |
|
} |
|
} catch { |
|
headers['X-User-Pubkey'] = userPubkey; |
|
} |
|
} |
|
|
|
const accessResponse = await fetch(`/api/repos/${npub}/${repo}/access`, { headers }); |
|
if (accessResponse.ok) { |
|
const accessData = await accessResponse.json(); |
|
if (!accessData.canView) { |
|
// User doesn't have access, don't load documentation |
|
loadingDocs = false; |
|
return; |
|
} |
|
} else { |
|
// Access check failed, don't load documentation |
|
loadingDocs = false; |
|
return; |
|
} |
|
} |
|
|
|
const decoded = nip19.decode(npub); |
|
if (decoded.type === 'npub') { |
|
const repoOwnerPubkey = decoded.data as string; |
|
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const events = await client.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkey], |
|
'#d': [repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length > 0) { |
|
documentationContent = events[0].content || null; |
|
|
|
// Render as markdown if content exists |
|
if (documentationContent) { |
|
const MarkdownIt = (await import('markdown-it')).default; |
|
const hljsModule = await import('highlight.js'); |
|
const hljs = hljsModule.default || hljsModule; |
|
|
|
const md = new MarkdownIt({ |
|
highlight: function (str: string, lang: string): string { |
|
if (lang && hljs.getLanguage(lang)) { |
|
try { |
|
return hljs.highlight(str, { language: lang }).value; |
|
} catch (__) {} |
|
} |
|
return ''; |
|
} |
|
}); |
|
|
|
documentationHtml = md.render(documentationContent); |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Error loading documentation:', err); |
|
} finally { |
|
loadingDocs = false; |
|
} |
|
} |
|
|
|
async function loadRepoImages() { |
|
try { |
|
// Get images from page data (loaded from announcement) |
|
// Use $page.data directly to ensure we get the latest data |
|
const data = $page.data as typeof pageData; |
|
if (data.image) { |
|
repoImage = data.image; |
|
console.log('[Repo Images] Loaded image from pageData:', repoImage); |
|
} |
|
if (data.banner) { |
|
repoBanner = data.banner; |
|
console.log('[Repo Images] Loaded banner from pageData:', repoBanner); |
|
} |
|
|
|
// Also fetch from announcement directly as fallback (only if not private or user has access) |
|
if (!repoImage && !repoBanner) { |
|
const data = $page.data as typeof pageData; |
|
// Check access for private repos |
|
if (data.repoIsPrivate) { |
|
const headers: Record<string, string> = {}; |
|
if (userPubkey) { |
|
try { |
|
const decoded = nip19.decode(userPubkey); |
|
if (decoded.type === 'npub') { |
|
headers['X-User-Pubkey'] = decoded.data as string; |
|
} else { |
|
headers['X-User-Pubkey'] = userPubkey; |
|
} |
|
} catch { |
|
headers['X-User-Pubkey'] = userPubkey; |
|
} |
|
} |
|
|
|
const accessResponse = await fetch(`/api/repos/${npub}/${repo}/access`, { headers }); |
|
if (!accessResponse.ok) { |
|
// Access check failed, don't fetch images |
|
return; |
|
} |
|
const accessData = await accessResponse.json(); |
|
if (!accessData.canView) { |
|
// User doesn't have access, don't fetch images |
|
return; |
|
} |
|
} |
|
|
|
const decoded = nip19.decode(npub); |
|
if (decoded.type === 'npub') { |
|
const repoOwnerPubkey = decoded.data as string; |
|
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const events = await client.fetchEvents([ |
|
{ |
|
kinds: [30617], // REPO_ANNOUNCEMENT |
|
authors: [repoOwnerPubkey], |
|
'#d': [repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length > 0) { |
|
const announcement = events[0]; |
|
const imageTag = announcement.tags.find((t: string[]) => t[0] === 'image'); |
|
const bannerTag = announcement.tags.find((t: string[]) => t[0] === 'banner'); |
|
|
|
if (imageTag?.[1]) { |
|
repoImage = imageTag[1]; |
|
console.log('[Repo Images] Loaded image from announcement:', repoImage); |
|
} |
|
if (bannerTag?.[1]) { |
|
repoBanner = bannerTag[1]; |
|
console.log('[Repo Images] Loaded banner from announcement:', repoBanner); |
|
} |
|
} else { |
|
console.log('[Repo Images] No announcement found'); |
|
} |
|
} |
|
} |
|
|
|
if (!repoImage && !repoBanner) { |
|
console.log('[Repo Images] No images found in announcement'); |
|
} |
|
} catch (err) { |
|
console.error('Error loading repo images:', err); |
|
} |
|
} |
|
|
|
// Reactively update images when pageData changes (only once, when data becomes available) |
|
$effect(() => { |
|
const data = $page.data as typeof pageData; |
|
// Only update if we have new data and don't already have the images set |
|
if (data.image && data.image !== repoImage) { |
|
repoImage = data.image; |
|
console.log('[Repo Images] Updated image from pageData (reactive):', repoImage); |
|
} |
|
if (data.banner && data.banner !== repoBanner) { |
|
repoBanner = data.banner; |
|
console.log('[Repo Images] Updated banner from pageData (reactive):', repoBanner); |
|
} |
|
}); |
|
|
|
onMount(async () => { |
|
await loadBranches(); |
|
// Skip other API calls if repository doesn't exist |
|
if (repoNotFound) { |
|
loading = false; |
|
return; |
|
} |
|
// Update currentBranch to first available branch if 'main' doesn't exist |
|
if (branches.length > 0 && !branches.includes(currentBranch)) { |
|
currentBranch = branches[0]; |
|
} |
|
await loadFiles(); |
|
await checkAuth(); |
|
await loadTags(); |
|
await checkMaintainerStatus(); |
|
await checkVerification(); |
|
await loadReadme(); |
|
await loadForkInfo(); |
|
await loadRepoImages(); |
|
}); |
|
|
|
async function checkAuth() { |
|
try { |
|
if (isNIP07Available()) { |
|
userPubkey = await getPublicKeyWithNIP07(); |
|
// Recheck maintainer status after auth |
|
await checkMaintainerStatus(); |
|
} |
|
} catch (err) { |
|
console.log('NIP-07 not available or user not connected'); |
|
userPubkey = null; |
|
} |
|
} |
|
|
|
async function login() { |
|
try { |
|
if (!isNIP07Available()) { |
|
alert('NIP-07 extension not found. Please install a Nostr extension like Alby or nos2x.'); |
|
return; |
|
} |
|
userPubkey = await getPublicKeyWithNIP07(); |
|
// Re-check maintainer status after login |
|
await checkMaintainerStatus(); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to connect'; |
|
console.error('Login error:', err); |
|
} |
|
} |
|
|
|
|
|
async function checkMaintainerStatus() { |
|
if (repoNotFound || !userPubkey) { |
|
isMaintainer = false; |
|
return; |
|
} |
|
|
|
loadingMaintainerStatus = true; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/maintainers?userPubkey=${encodeURIComponent(userPubkey)}`); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
isMaintainer = data.isMaintainer || false; |
|
} |
|
} catch (err) { |
|
console.error('Failed to check maintainer status:', err); |
|
isMaintainer = false; |
|
} finally { |
|
loadingMaintainerStatus = false; |
|
} |
|
} |
|
|
|
async function checkVerification() { |
|
if (repoNotFound) return; |
|
loadingVerification = true; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/verify`); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
verificationStatus = data; |
|
} |
|
} catch (err) { |
|
console.error('Failed to check verification:', err); |
|
verificationStatus = { verified: false, error: 'Failed to check verification' }; |
|
} finally { |
|
loadingVerification = false; |
|
} |
|
} |
|
|
|
async function loadBranches() { |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/branches`); |
|
if (response.ok) { |
|
branches = await response.json(); |
|
if (branches.length > 0 && !branches.includes(currentBranch)) { |
|
currentBranch = branches[0]; |
|
} |
|
} else if (response.status === 404) { |
|
// Repository not provisioned yet - set error message and flag |
|
repoNotFound = true; |
|
error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; |
|
} |
|
} catch (err) { |
|
console.error('Failed to load branches:', err); |
|
} |
|
} |
|
|
|
async function loadFiles(path: string = '') { |
|
// Skip if repository doesn't exist |
|
if (repoNotFound) return; |
|
|
|
loading = true; |
|
error = null; |
|
try { |
|
const url = `/api/repos/${npub}/${repo}/tree?ref=${currentBranch}&path=${encodeURIComponent(path)}`; |
|
const response = await fetch(url); |
|
|
|
if (!response.ok) { |
|
if (response.status === 404) { |
|
repoNotFound = true; |
|
throw new Error(`Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`); |
|
} |
|
throw new Error(`Failed to load files: ${response.statusText}`); |
|
} |
|
|
|
files = await response.json(); |
|
currentPath = path; |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load files'; |
|
console.error('Error loading files:', err); |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
|
|
async function loadFile(filePath: string) { |
|
loading = true; |
|
error = null; |
|
try { |
|
const url = `/api/repos/${npub}/${repo}/file?path=${encodeURIComponent(filePath)}&ref=${currentBranch}`; |
|
const response = await fetch(url); |
|
|
|
if (!response.ok) { |
|
throw new Error(`Failed to load file: ${response.statusText}`); |
|
} |
|
|
|
const data = await response.json(); |
|
fileContent = data.content; |
|
editedContent = data.content; |
|
currentFile = filePath; |
|
hasChanges = false; |
|
|
|
// Determine language from file extension |
|
const ext = filePath.split('.').pop()?.toLowerCase(); |
|
if (ext === 'md' || ext === 'markdown') { |
|
fileLanguage = 'markdown'; |
|
} else if (ext === 'adoc' || ext === 'asciidoc') { |
|
fileLanguage = 'asciidoc'; |
|
} else { |
|
fileLanguage = 'text'; |
|
} |
|
|
|
// Apply syntax highlighting for read-only view (non-maintainers) |
|
if (fileContent && !isMaintainer) { |
|
await applySyntaxHighlighting(fileContent, ext || ''); |
|
} |
|
|
|
// Apply syntax highlighting to file content if not in editor |
|
if (fileContent && !isMaintainer) { |
|
// For read-only view, apply highlight.js |
|
await applySyntaxHighlighting(fileContent, ext || ''); |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load file'; |
|
console.error('Error loading file:', err); |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
|
|
function handleContentChange(value: string) { |
|
editedContent = value; |
|
hasChanges = value !== fileContent; |
|
} |
|
|
|
function handleFileClick(file: { name: string; path: string; type: 'file' | 'directory' }) { |
|
if (file.type === 'directory') { |
|
pathStack.push(currentPath); |
|
loadFiles(file.path); |
|
} else { |
|
loadFile(file.path); |
|
// On mobile, switch to file viewer when a file is clicked |
|
if (window.innerWidth <= 768) { |
|
showFileListOnMobile = false; |
|
} |
|
} |
|
} |
|
|
|
function handleBack() { |
|
if (pathStack.length > 0) { |
|
const parentPath = pathStack.pop() || ''; |
|
loadFiles(parentPath); |
|
} else { |
|
loadFiles(''); |
|
} |
|
} |
|
|
|
async function saveFile() { |
|
if (!currentFile || !commitMessage.trim()) { |
|
alert('Please enter a commit message'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension to save files'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
// Get npub from pubkey |
|
const npubFromPubkey = nip19.npubEncode(userPubkey); |
|
|
|
const response = await fetch(`/api/repos/${npub}/${repo}/file`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json' |
|
}, |
|
body: JSON.stringify({ |
|
path: currentFile, |
|
content: editedContent, |
|
commitMessage: commitMessage.trim(), |
|
authorName: 'Web Editor', |
|
authorEmail: `${npubFromPubkey}@nostr`, |
|
branch: currentBranch, |
|
userPubkey: userPubkey, |
|
useNIP07: true // Use NIP-07 for commit signing in web UI |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to save file'); |
|
} |
|
|
|
// Reload file to get updated content |
|
await loadFile(currentFile); |
|
commitMessage = ''; |
|
showCommitDialog = false; |
|
alert('File saved successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to save file'; |
|
console.error('Error saving file:', err); |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
function handleBranchChange(event: Event) { |
|
const target = event.target as HTMLSelectElement; |
|
currentBranch = target.value; |
|
if (currentFile) { |
|
loadFile(currentFile); |
|
} else { |
|
loadFiles(currentPath); |
|
} |
|
} |
|
|
|
async function createFile() { |
|
if (!newFileName.trim()) { |
|
alert('Please enter a file name'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const npubFromPubkey = nip19.npubEncode(userPubkey); |
|
const filePath = currentPath ? `${currentPath}/${newFileName}` : newFileName; |
|
|
|
const response = await fetch(`/api/repos/${npub}/${repo}/file`, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ |
|
path: filePath, |
|
content: newFileContent, |
|
commitMessage: `Create ${newFileName}`, |
|
authorName: 'Web Editor', |
|
authorEmail: `${npubFromPubkey}@nostr`, |
|
branch: currentBranch, |
|
action: 'create', |
|
userPubkey: userPubkey |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to create file'); |
|
} |
|
|
|
showCreateFileDialog = false; |
|
newFileName = ''; |
|
newFileContent = ''; |
|
await loadFiles(currentPath); |
|
alert('File created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create file'; |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function deleteFile(filePath: string) { |
|
if (!confirm(`Are you sure you want to delete ${filePath}?`)) { |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const npubFromPubkey = nip19.npubEncode(userPubkey); |
|
|
|
const response = await fetch(`/api/repos/${npub}/${repo}/file`, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ |
|
path: filePath, |
|
commitMessage: `Delete ${filePath}`, |
|
authorName: 'Web Editor', |
|
authorEmail: `${npubFromPubkey}@nostr`, |
|
branch: currentBranch, |
|
action: 'delete', |
|
userPubkey: userPubkey |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to delete file'); |
|
} |
|
|
|
if (currentFile === filePath) { |
|
currentFile = null; |
|
} |
|
await loadFiles(currentPath); |
|
alert('File deleted successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to delete file'; |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function createBranch() { |
|
if (!newBranchName.trim()) { |
|
alert('Please enter a branch name'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ |
|
branchName: newBranchName, |
|
fromBranch: newBranchFrom |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to create branch'); |
|
} |
|
|
|
showCreateBranchDialog = false; |
|
newBranchName = ''; |
|
await loadBranches(); |
|
alert('Branch created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create branch'; |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function loadCommitHistory() { |
|
loadingCommits = true; |
|
error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/commits?branch=${currentBranch}&limit=50`); |
|
if (response.ok) { |
|
commits = await response.json(); |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load commit history'; |
|
} finally { |
|
loadingCommits = false; |
|
} |
|
} |
|
|
|
async function viewDiff(commitHash: string) { |
|
loadingCommits = true; |
|
error = null; |
|
try { |
|
const parentHash = commits.find(c => c.hash === commitHash) |
|
? commits[commits.findIndex(c => c.hash === commitHash) + 1]?.hash || `${commitHash}^` |
|
: `${commitHash}^`; |
|
|
|
const response = await fetch(`/api/repos/${npub}/${repo}/diff?from=${parentHash}&to=${commitHash}`); |
|
if (response.ok) { |
|
diffData = await response.json(); |
|
selectedCommit = commitHash; |
|
showDiff = true; |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load diff'; |
|
} finally { |
|
loadingCommits = false; |
|
} |
|
} |
|
|
|
async function loadTags() { |
|
if (repoNotFound) return; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/tags`); |
|
if (response.ok) { |
|
tags = await response.json(); |
|
} |
|
} catch (err) { |
|
console.error('Failed to load tags:', err); |
|
} |
|
} |
|
|
|
async function createTag() { |
|
if (!newTagName.trim()) { |
|
alert('Please enter a tag name'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/tags`, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ |
|
tagName: newTagName, |
|
ref: newTagRef, |
|
message: newTagMessage || undefined, |
|
userPubkey: userPubkey |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to create tag'); |
|
} |
|
|
|
showCreateTagDialog = false; |
|
newTagName = ''; |
|
newTagMessage = ''; |
|
await loadTags(); |
|
alert('Tag created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create tag'; |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function loadIssues() { |
|
loadingIssues = true; |
|
error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/issues`); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
issues = data.map((issue: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number }) => ({ |
|
id: issue.id, |
|
subject: issue.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled', |
|
content: issue.content, |
|
status: issue.status || 'open', |
|
author: issue.pubkey, |
|
created_at: issue.created_at |
|
})); |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load issues'; |
|
} finally { |
|
loadingIssues = false; |
|
} |
|
} |
|
|
|
async function createIssue() { |
|
if (!newIssueSubject.trim() || !newIssueContent.trim()) { |
|
alert('Please enter a subject and content'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const { IssuesService } = await import('$lib/services/nostr/issues-service.js'); |
|
|
|
const decoded = nip19.decode(npub); |
|
if (decoded.type !== 'npub') { |
|
throw new Error('Invalid npub format'); |
|
} |
|
const repoOwnerPubkey = decoded.data as string; |
|
|
|
// Get user's relays and combine with defaults |
|
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const { outbox } = await getUserRelays(userPubkey, tempClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
const issuesService = new IssuesService(combinedRelays); |
|
const issue = await issuesService.createIssue( |
|
repoOwnerPubkey, |
|
repo, |
|
newIssueSubject.trim(), |
|
newIssueContent.trim(), |
|
newIssueLabels.filter(l => l.trim()) |
|
); |
|
|
|
showCreateIssueDialog = false; |
|
newIssueSubject = ''; |
|
newIssueContent = ''; |
|
newIssueLabels = ['']; |
|
await loadIssues(); |
|
alert('Issue created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create issue'; |
|
console.error('Error creating issue:', err); |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function loadPRs() { |
|
loadingPRs = true; |
|
error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/prs`); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
prs = data.map((pr: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number; commitId?: string }) => ({ |
|
id: pr.id, |
|
subject: pr.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled', |
|
content: pr.content, |
|
status: pr.status || 'open', |
|
author: pr.pubkey, |
|
created_at: pr.created_at, |
|
commitId: pr.tags.find((t: string[]) => t[0] === 'c')?.[1] |
|
})); |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load pull requests'; |
|
} finally { |
|
loadingPRs = false; |
|
} |
|
} |
|
|
|
async function createPR() { |
|
if (!newPRSubject.trim() || !newPRContent.trim() || !newPRCommitId.trim()) { |
|
alert('Please enter a subject, content, and commit ID'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const { PRsService } = await import('$lib/services/nostr/prs-service.js'); |
|
const { getGitUrl } = await import('$lib/config.js'); |
|
|
|
const decoded = nip19.decode(npub); |
|
if (decoded.type !== 'npub') { |
|
throw new Error('Invalid npub format'); |
|
} |
|
const repoOwnerPubkey = decoded.data as string; |
|
|
|
// Get user's relays and combine with defaults |
|
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const { outbox } = await getUserRelays(userPubkey, tempClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
const cloneUrl = getGitUrl(npub, repo); |
|
const prsService = new PRsService(combinedRelays); |
|
const pr = await prsService.createPullRequest( |
|
repoOwnerPubkey, |
|
repo, |
|
newPRSubject.trim(), |
|
newPRContent.trim(), |
|
newPRCommitId.trim(), |
|
cloneUrl, |
|
newPRBranchName.trim() || undefined, |
|
newPRLabels.filter(l => l.trim()) |
|
); |
|
|
|
showCreatePRDialog = false; |
|
newPRSubject = ''; |
|
newPRContent = ''; |
|
newPRCommitId = ''; |
|
newPRBranchName = ''; |
|
newPRLabels = ['']; |
|
await loadPRs(); |
|
alert('Pull request created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create pull request'; |
|
console.error('Error creating PR:', err); |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
// Only load tab content when tab actually changes, not on every render |
|
let lastTab = $state<string | null>(null); |
|
$effect(() => { |
|
if (activeTab !== lastTab) { |
|
lastTab = activeTab; |
|
if (activeTab === 'history') { |
|
loadCommitHistory(); |
|
} else if (activeTab === 'tags') { |
|
loadTags(); |
|
} else if (activeTab === 'issues') { |
|
loadIssues(); |
|
} else if (activeTab === 'prs') { |
|
loadPRs(); |
|
} else if (activeTab === 'docs') { |
|
loadDocumentation(); |
|
} |
|
} |
|
}); |
|
|
|
// Only load readme when branch changes, not on every render |
|
let lastBranch = $state<string | null>(null); |
|
$effect(() => { |
|
if (currentBranch && currentBranch !== lastBranch) { |
|
lastBranch = currentBranch; |
|
loadReadme(); |
|
} |
|
}); |
|
</script> |
|
|
|
<svelte:head> |
|
<title>{pageData.title || `${repo} - Repository`}</title> |
|
<meta name="description" content={pageData.description || `Repository: ${repo}`} /> |
|
|
|
<!-- OpenGraph / Facebook --> |
|
<meta property="og:type" content="website" /> |
|
<meta property="og:title" content={pageData.title || `${pageData.repoName || repo} - Repository`} /> |
|
<meta property="og:description" content={pageData.description || pageData.repoDescription || `Repository: ${pageData.repoName || repo}`} /> |
|
<meta property="og:url" content={pageData.repoUrl || `https://${$page.url.host}${$page.url.pathname}`} /> |
|
{#if (pageData.image || repoImage) && String(pageData.image || repoImage).trim()} |
|
<meta property="og:image" content={pageData.image || repoImage} /> |
|
{/if} |
|
{#if (pageData.banner || repoBanner) && String(pageData.banner || repoBanner).trim()} |
|
<meta property="og:image:width" content="1200" /> |
|
<meta property="og:image:height" content="630" /> |
|
{/if} |
|
|
|
<!-- Twitter Card --> |
|
<meta name="twitter:card" content={repoBanner || repoImage ? "summary_large_image" : "summary"} /> |
|
<meta name="twitter:title" content={pageData.title || `${pageData.repoName || repo} - Repository`} /> |
|
<meta name="twitter:description" content={pageData.description || pageData.repoDescription || `Repository: ${pageData.repoName || repo}`} /> |
|
{#if pageData.banner || repoBanner} |
|
<meta name="twitter:image" content={pageData.banner || repoBanner} /> |
|
{:else if pageData.image || repoImage} |
|
<meta name="twitter:image" content={pageData.image || repoImage} /> |
|
{/if} |
|
</svelte:head> |
|
|
|
<div class="container"> |
|
<header> |
|
{#if repoBanner} |
|
<div class="repo-banner"> |
|
<img src={repoBanner} alt="" onerror={(e) => { |
|
console.error('[Repo Images] Failed to load banner:', repoBanner); |
|
const target = e.target as HTMLImageElement; |
|
if (target) target.style.display = 'none'; |
|
}} /> |
|
</div> |
|
{/if} |
|
<div class="header-content"> |
|
<div class="header-main"> |
|
<div class="repo-title-section"> |
|
{#if repoImage} |
|
<img src={repoImage} alt="" class="repo-image" onerror={(e) => { |
|
console.error('[Repo Images] Failed to load image:', repoImage); |
|
const target = e.target as HTMLImageElement; |
|
if (target) target.style.display = 'none'; |
|
}} /> |
|
{/if} |
|
<div class="repo-title-text"> |
|
<h1>{pageData.repoName || repo}</h1> |
|
{#if pageData.repoDescription} |
|
<p class="repo-description-header">{pageData.repoDescription}</p> |
|
{:else} |
|
<p class="repo-description-header repo-description-placeholder">No description</p> |
|
{/if} |
|
</div> |
|
</div> |
|
<div class="repo-meta-info"> |
|
{#if pageData.repoLanguage} |
|
<span class="repo-language"> |
|
<img src="/icons/file-text.svg" alt="" class="icon-inline" /> |
|
{pageData.repoLanguage} |
|
</span> |
|
{/if} |
|
{#if pageData.repoIsPrivate} |
|
<span class="repo-privacy-badge private">Private</span> |
|
{:else} |
|
<span class="repo-privacy-badge public">Public</span> |
|
{/if} |
|
{#if forkInfo?.isFork && forkInfo.originalRepo} |
|
<span class="fork-badge">Forked from <a href={`/repos/${forkInfo.originalRepo.npub}/${forkInfo.originalRepo.repo}`}>{forkInfo.originalRepo.repo}</a></span> |
|
{/if} |
|
</div> |
|
{#if pageData.repoOwnerPubkey || (pageData.repoMaintainers && pageData.repoMaintainers.length > 0)} |
|
<div class="repo-contributors"> |
|
<span class="contributors-label">Contributors:</span> |
|
<div class="contributors-list"> |
|
{#if pageData.repoOwnerPubkey} |
|
<a href={`/users/${npub}`} class="contributor-item"> |
|
<UserBadge pubkey={pageData.repoOwnerPubkey} /> |
|
<span class="contributor-badge owner">Owner</span> |
|
</a> |
|
{/if} |
|
{#if pageData.repoMaintainers} |
|
{#each pageData.repoMaintainers.filter(m => m !== pageData.repoOwnerPubkey) as maintainerPubkey} |
|
<a href={`/users/${nip19.npubEncode(maintainerPubkey)}`} class="contributor-item"> |
|
<UserBadge pubkey={maintainerPubkey} /> |
|
<span class="contributor-badge maintainer">Maintainer</span> |
|
</a> |
|
{/each} |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
{#if pageData.repoTopics && pageData.repoTopics.length > 0} |
|
<div class="repo-topics"> |
|
{#each pageData.repoTopics as topic} |
|
<span class="topic-tag">{topic}</span> |
|
{/each} |
|
</div> |
|
{/if} |
|
{#if pageData.repoWebsite} |
|
<div class="repo-website"> |
|
<a href={pageData.repoWebsite} target="_blank" rel="noopener noreferrer"> |
|
<img src="/icons/external-link.svg" alt="" class="icon-inline" /> |
|
{pageData.repoWebsite} |
|
</a> |
|
</div> |
|
{/if} |
|
{#if pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0} |
|
<div class="repo-clone-urls"> |
|
<span class="clone-label">Clone:</span> |
|
{#each pageData.repoCloneUrls.slice(0, 3) as cloneUrl} |
|
<code class="clone-url">{cloneUrl}</code> |
|
{/each} |
|
{#if pageData.repoCloneUrls.length > 3} |
|
<span class="clone-more">+{pageData.repoCloneUrls.length - 3} more</span> |
|
{/if} |
|
</div> |
|
{/if} |
|
<div class="header-actions-bottom"> |
|
{#if userPubkey} |
|
<button onclick={forkRepository} disabled={forking} class="fork-button"> |
|
{forking ? 'Forking...' : 'Fork'} |
|
</button> |
|
{#if isMaintainer} |
|
<a href={`/repos/${npub}/${repo}/settings`} class="settings-button">Settings</a> |
|
{/if} |
|
{#if isMaintainer} |
|
<button onclick={() => showCreateBranchDialog = true} class="create-branch-button">+ New Branch</button> |
|
{/if} |
|
<span class="auth-status"> |
|
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" /> |
|
{#if isMaintainer} |
|
Maintainer |
|
{:else} |
|
Authenticated (Contributor) |
|
{/if} |
|
</span> |
|
{/if} |
|
</div> |
|
</div> |
|
<div class="header-actions"> |
|
{#if branches.length > 0} |
|
<select bind:value={currentBranch} onchange={handleBranchChange} class="branch-select"> |
|
{#each branches as branch} |
|
<option value={branch}>{branch}</option> |
|
{/each} |
|
</select> |
|
{/if} |
|
{#if verificationStatus} |
|
<span class="verification-status" class:verified={verificationStatus.verified} class:unverified={!verificationStatus.verified}> |
|
{#if verificationStatus.verified} |
|
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" /> |
|
Verified |
|
{:else} |
|
<img src="/icons/alert-triangle.svg" alt="Unverified" class="icon-inline" /> |
|
Unverified |
|
{/if} |
|
</span> |
|
{/if} |
|
</div> |
|
</div> |
|
</header> |
|
|
|
<main class="repo-view"> |
|
{#if error} |
|
<div class="error"> |
|
Error: {error} |
|
</div> |
|
{/if} |
|
|
|
<!-- Tabs --> |
|
<div class="tabs"> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'files'} |
|
onclick={() => activeTab = 'files'} |
|
> |
|
Files |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'history'} |
|
onclick={() => activeTab = 'history'} |
|
> |
|
History |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'tags'} |
|
onclick={() => activeTab = 'tags'} |
|
> |
|
Tags |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'issues'} |
|
onclick={() => activeTab = 'issues'} |
|
> |
|
Issues |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'prs'} |
|
onclick={() => activeTab = 'prs'} |
|
> |
|
Pull Requests |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'docs'} |
|
onclick={() => activeTab = 'docs'} |
|
> |
|
Docs |
|
</button> |
|
</div> |
|
|
|
<div class="repo-layout"> |
|
<!-- File Tree Sidebar --> |
|
{#if activeTab === 'files'} |
|
<aside class="file-tree" class:hide-on-mobile={!showFileListOnMobile && activeTab === 'files'}> |
|
<div class="file-tree-header"> |
|
<h2>Files</h2> |
|
<div class="file-tree-actions"> |
|
{#if pathStack.length > 0 || currentPath} |
|
<button onclick={handleBack} class="back-button">← Back</button> |
|
{/if} |
|
{#if userPubkey && isMaintainer} |
|
<button onclick={() => showCreateFileDialog = true} class="create-file-button">+ New File</button> |
|
{/if} |
|
<button |
|
onclick={() => showFileListOnMobile = !showFileListOnMobile} |
|
class="mobile-toggle-button" |
|
title={showFileListOnMobile ? 'Show file viewer' : 'Show file list'} |
|
> |
|
{#if showFileListOnMobile} |
|
<img src="/icons/file-text.svg" alt="Show file viewer" class="icon-inline" /> |
|
{:else} |
|
<img src="/icons/package.svg" alt="Show file list" class="icon-inline" /> |
|
{/if} |
|
</button> |
|
</div> |
|
</div> |
|
{#if loading && !currentFile} |
|
<div class="loading">Loading files...</div> |
|
{:else} |
|
<ul class="file-list"> |
|
{#each files as file} |
|
<li class="file-item" class:directory={file.type === 'directory'} class:selected={currentFile === file.path}> |
|
<button onclick={() => handleFileClick(file)} class="file-button"> |
|
{#if file.type === 'directory'} |
|
<img src="/icons/package.svg" alt="Directory" class="icon-inline" /> |
|
{:else} |
|
<img src="/icons/file-text.svg" alt="File" class="icon-inline" /> |
|
{/if} |
|
{file.name} |
|
{#if file.size !== undefined} |
|
<span class="file-size">({(file.size / 1024).toFixed(1)} KB)</span> |
|
{/if} |
|
</button> |
|
{#if userPubkey && isMaintainer && file.type === 'file'} |
|
<button onclick={() => deleteFile(file.path)} class="delete-file-button" title="Delete file"> |
|
<img src="/icons/x.svg" alt="Delete" class="icon-small" /> |
|
</button> |
|
{/if} |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Commit History View --> |
|
{#if activeTab === 'history'} |
|
<aside class="history-sidebar"> |
|
<div class="history-header"> |
|
<h2>Commit History</h2> |
|
<button onclick={loadCommitHistory} class="refresh-button">Refresh</button> |
|
</div> |
|
{#if loadingCommits} |
|
<div class="loading">Loading commits...</div> |
|
{:else if commits.length === 0} |
|
<div class="empty">No commits found</div> |
|
{:else} |
|
<ul class="commit-list"> |
|
{#each commits as commit} |
|
<li class="commit-item" class:selected={selectedCommit === commit.hash}> |
|
<button onclick={() => viewDiff(commit.hash)} class="commit-button"> |
|
<div class="commit-hash">{commit.hash.slice(0, 7)}</div> |
|
<div class="commit-message">{commit.message}</div> |
|
<div class="commit-meta"> |
|
<span>{commit.author}</span> |
|
<span>{new Date(commit.date).toLocaleString()}</span> |
|
</div> |
|
</button> |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Tags View --> |
|
{#if activeTab === 'tags'} |
|
<aside class="tags-sidebar"> |
|
<div class="tags-header"> |
|
<h2>Tags</h2> |
|
{#if userPubkey && isMaintainer} |
|
<button onclick={() => showCreateTagDialog = true} class="create-tag-button">+ New Tag</button> |
|
{/if} |
|
</div> |
|
{#if tags.length === 0} |
|
<div class="empty">No tags found</div> |
|
{:else} |
|
<ul class="tag-list"> |
|
{#each tags as tag} |
|
<li class="tag-item"> |
|
<div class="tag-name">{tag.name}</div> |
|
<div class="tag-hash">{tag.hash.slice(0, 7)}</div> |
|
{#if tag.message} |
|
<div class="tag-message">{tag.message}</div> |
|
{/if} |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Issues View --> |
|
{#if activeTab === 'issues'} |
|
<aside class="issues-sidebar"> |
|
<div class="issues-header"> |
|
<h2>Issues</h2> |
|
{#if userPubkey} |
|
<button onclick={() => showCreateIssueDialog = true} class="create-issue-button">+ New Issue</button> |
|
{/if} |
|
</div> |
|
{#if loadingIssues} |
|
<div class="loading">Loading issues...</div> |
|
{:else if issues.length === 0} |
|
<div class="empty">No issues found</div> |
|
{:else} |
|
<ul class="issue-list"> |
|
{#each issues as issue} |
|
<li class="issue-item"> |
|
<div class="issue-header"> |
|
<span class="issue-status" class:open={issue.status === 'open'} class:closed={issue.status === 'closed'} class:resolved={issue.status === 'resolved'}> |
|
{issue.status} |
|
</span> |
|
<span class="issue-subject">{issue.subject}</span> |
|
</div> |
|
<div class="issue-meta"> |
|
<span>#{issue.id.slice(0, 7)}</span> |
|
<span>{new Date(issue.created_at * 1000).toLocaleDateString()}</span> |
|
</div> |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Pull Requests View --> |
|
{#if activeTab === 'prs'} |
|
<aside class="prs-sidebar"> |
|
<div class="prs-header"> |
|
<h2>Pull Requests</h2> |
|
{#if userPubkey} |
|
<button onclick={() => showCreatePRDialog = true} class="create-pr-button">+ New PR</button> |
|
{/if} |
|
</div> |
|
{#if loadingPRs} |
|
<div class="loading">Loading pull requests...</div> |
|
{:else if prs.length === 0} |
|
<div class="empty">No pull requests found</div> |
|
{:else} |
|
<ul class="pr-list"> |
|
{#each prs as pr} |
|
<li class="pr-item"> |
|
<div class="pr-header"> |
|
<span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}> |
|
{pr.status} |
|
</span> |
|
<span class="pr-subject">{pr.subject}</span> |
|
</div> |
|
<div class="pr-meta"> |
|
<span>#{pr.id.slice(0, 7)}</span> |
|
{#if pr.commitId} |
|
<span class="pr-commit">Commit: {pr.commitId.slice(0, 7)}</span> |
|
{/if} |
|
<span>{new Date(pr.created_at * 1000).toLocaleDateString()}</span> |
|
</div> |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Editor Area / Diff View / README --> |
|
<div class="editor-area" class:hide-on-mobile={showFileListOnMobile && activeTab === 'files'}> |
|
{#if activeTab === 'files' && readmeContent && !currentFile} |
|
<div class="readme-section"> |
|
<div class="readme-header"> |
|
<h3>README</h3> |
|
<div class="readme-actions"> |
|
<a href={`/api/repos/${npub}/${repo}/raw?path=${readmePath}`} target="_blank" class="raw-link">View Raw</a> |
|
<a href={`/api/repos/${npub}/${repo}/download?format=zip`} class="download-link">Download ZIP</a> |
|
<button |
|
onclick={() => showFileListOnMobile = !showFileListOnMobile} |
|
class="mobile-toggle-button" |
|
title={showFileListOnMobile ? 'Show file viewer' : 'Show file list'} |
|
> |
|
{#if showFileListOnMobile} |
|
<img src="/icons/file-text.svg" alt="Show file viewer" class="icon-inline" /> |
|
{:else} |
|
<img src="/icons/package.svg" alt="Show file list" class="icon-inline" /> |
|
{/if} |
|
</button> |
|
</div> |
|
</div> |
|
{#if loadingReadme} |
|
<div class="loading">Loading README...</div> |
|
{:else if readmeIsMarkdown && readmeHtml} |
|
<div class="readme-content markdown"> |
|
{@html readmeHtml} |
|
</div> |
|
{:else if readmeContent} |
|
<div class="readme-content"> |
|
<pre><code class="hljs language-text">{readmeContent}</code></pre> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'files' && currentFile} |
|
<div class="editor-header"> |
|
<span class="file-path">{currentFile}</span> |
|
<div class="editor-actions"> |
|
{#if hasChanges} |
|
<span class="unsaved-indicator">● Unsaved changes</span> |
|
{/if} |
|
{#if isMaintainer} |
|
<button onclick={() => showCommitDialog = true} disabled={!hasChanges || saving} class="save-button"> |
|
{saving ? 'Saving...' : 'Save'} |
|
</button> |
|
{:else if userPubkey} |
|
<span class="non-maintainer-notice">Only maintainers can edit files. Submit a PR instead.</span> |
|
{/if} |
|
<button |
|
onclick={() => showFileListOnMobile = !showFileListOnMobile} |
|
class="mobile-toggle-button" |
|
title={showFileListOnMobile ? 'Show file viewer' : 'Show file list'} |
|
> |
|
{#if showFileListOnMobile} |
|
<img src="/icons/file-text.svg" alt="Show file viewer" class="icon-inline" /> |
|
{:else} |
|
<img src="/icons/package.svg" alt="Show file list" class="icon-inline" /> |
|
{/if} |
|
</button> |
|
</div> |
|
</div> |
|
|
|
{#if loading} |
|
<div class="loading">Loading file...</div> |
|
{:else} |
|
<div class="editor-container"> |
|
{#if isMaintainer} |
|
<CodeEditor |
|
content={editedContent} |
|
language={fileLanguage} |
|
onChange={handleContentChange} |
|
/> |
|
{:else} |
|
<div class="read-only-editor"> |
|
{#if highlightedFileContent} |
|
{@html highlightedFileContent} |
|
{:else} |
|
<pre><code class="hljs">{fileContent}</code></pre> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
{:else if activeTab === 'files'} |
|
<div class="empty-state"> |
|
<p>Select a file from the sidebar to view and edit it</p> |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'history' && showDiff} |
|
<div class="diff-view"> |
|
<div class="diff-header"> |
|
<h3>Diff for commit {selectedCommit?.slice(0, 7)}</h3> |
|
<button onclick={() => { showDiff = false; selectedCommit = null; }} class="close-button">×</button> |
|
</div> |
|
{#each diffData as diff} |
|
<div class="diff-file"> |
|
<div class="diff-file-header"> |
|
<span class="diff-file-name">{diff.file}</span> |
|
<span class="diff-stats"> |
|
<span class="additions">+{diff.additions}</span> |
|
<span class="deletions">-{diff.deletions}</span> |
|
</span> |
|
</div> |
|
<pre class="diff-content"><code>{diff.diff}</code></pre> |
|
</div> |
|
{/each} |
|
</div> |
|
{:else if activeTab === 'history'} |
|
<div class="empty-state"> |
|
<p>Select a commit to view its diff</p> |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'tags'} |
|
<div class="empty-state"> |
|
<p>Tags are displayed in the sidebar</p> |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'issues'} |
|
<div class="issues-content"> |
|
{#if issues.length === 0} |
|
<div class="empty-state"> |
|
<p>No issues found. Create one to get started!</p> |
|
</div> |
|
{:else} |
|
{#each issues as issue} |
|
<div class="issue-detail"> |
|
<h3>{issue.subject}</h3> |
|
<div class="issue-meta-detail"> |
|
<span class="issue-status" class:open={issue.status === 'open'} class:closed={issue.status === 'closed'} class:resolved={issue.status === 'resolved'}> |
|
{issue.status} |
|
</span> |
|
<span>Created {new Date(issue.created_at * 1000).toLocaleString()}</span> |
|
</div> |
|
<div class="issue-body"> |
|
{@html issue.content.replace(/\n/g, '<br>')} |
|
</div> |
|
</div> |
|
{/each} |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'prs'} |
|
<div class="prs-content"> |
|
{#if prs.length === 0} |
|
<div class="empty-state"> |
|
<p>No pull requests found. Create one to get started!</p> |
|
</div> |
|
{:else if selectedPR} |
|
{#each prs.filter(p => p.id === selectedPR) as pr} |
|
{@const decoded = nip19.decode(npub)} |
|
{#if decoded.type === 'npub'} |
|
{@const repoOwnerPubkey = decoded.data as string} |
|
<PRDetail |
|
{pr} |
|
{npub} |
|
{repo} |
|
{repoOwnerPubkey} |
|
/> |
|
<button onclick={() => selectedPR = null} class="back-btn">← Back to PR List</button> |
|
{/if} |
|
{/each} |
|
{:else} |
|
{#each prs as pr} |
|
<div |
|
class="pr-detail" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => selectedPR = pr.id} |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
selectedPR = pr.id; |
|
} |
|
}} |
|
style="cursor: pointer;"> |
|
<h3>{pr.subject}</h3> |
|
<div class="pr-meta-detail"> |
|
<span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}> |
|
{pr.status} |
|
</span> |
|
{#if pr.commitId} |
|
<span>Commit: {pr.commitId.slice(0, 7)}</span> |
|
{/if} |
|
<span>Created {new Date(pr.created_at * 1000).toLocaleString()}</span> |
|
</div> |
|
<div class="pr-body"> |
|
{@html pr.content.replace(/\n/g, '<br>')} |
|
</div> |
|
</div> |
|
{/each} |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
</main> |
|
|
|
<!-- Create File Dialog --> |
|
{#if showCreateFileDialog} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new file" |
|
onclick={() => showCreateFileDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateFileDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New File</h3> |
|
<label> |
|
File Name: |
|
<input type="text" bind:value={newFileName} placeholder="filename.md" /> |
|
</label> |
|
<label> |
|
Content: |
|
<textarea bind:value={newFileContent} rows="10" placeholder="File content..."></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateFileDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={createFile} disabled={!newFileName.trim() || saving} class="save-button"> |
|
{saving ? 'Creating...' : 'Create'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create Branch Dialog --> |
|
{#if showCreateBranchDialog} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new branch" |
|
onclick={() => showCreateBranchDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateBranchDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Branch</h3> |
|
<label> |
|
Branch Name: |
|
<input type="text" bind:value={newBranchName} placeholder="feature/new-feature" /> |
|
</label> |
|
<label> |
|
From Branch: |
|
<select bind:value={newBranchFrom}> |
|
{#each branches as branch} |
|
<option value={branch}>{branch}</option> |
|
{/each} |
|
</select> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateBranchDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={createBranch} disabled={!newBranchName.trim() || saving} class="save-button"> |
|
{saving ? 'Creating...' : 'Create Branch'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create Tag Dialog --> |
|
{#if showCreateTagDialog} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new tag" |
|
onclick={() => showCreateTagDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateTagDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Tag</h3> |
|
<label> |
|
Tag Name: |
|
<input type="text" bind:value={newTagName} placeholder="v1.0.0" /> |
|
</label> |
|
<label> |
|
Reference (commit/branch): |
|
<input type="text" bind:value={newTagRef} placeholder="HEAD" /> |
|
</label> |
|
<label> |
|
Message (optional, for annotated tag): |
|
<textarea bind:value={newTagMessage} rows="3" placeholder="Tag message..."></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateTagDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={createTag} disabled={!newTagName.trim() || saving} class="save-button"> |
|
{saving ? 'Creating...' : 'Create Tag'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create Issue Dialog --> |
|
{#if showCreateIssueDialog} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new issue" |
|
onclick={() => showCreateIssueDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateIssueDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Issue</h3> |
|
<label> |
|
Subject: |
|
<input type="text" bind:value={newIssueSubject} placeholder="Issue title..." /> |
|
</label> |
|
<label> |
|
Description: |
|
<textarea bind:value={newIssueContent} rows="10" placeholder="Describe the issue..."></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateIssueDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={createIssue} disabled={!newIssueSubject.trim() || !newIssueContent.trim() || saving} class="save-button"> |
|
{saving ? 'Creating...' : 'Create Issue'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create PR Dialog --> |
|
{#if showCreatePRDialog} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new pull request" |
|
onclick={() => showCreatePRDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreatePRDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Pull Request</h3> |
|
<label> |
|
Subject: |
|
<input type="text" bind:value={newPRSubject} placeholder="PR title..." /> |
|
</label> |
|
<label> |
|
Description: |
|
<textarea bind:value={newPRContent} rows="8" placeholder="Describe your changes..."></textarea> |
|
</label> |
|
<label> |
|
Commit ID: |
|
<input type="text" bind:value={newPRCommitId} placeholder="Commit hash..." /> |
|
</label> |
|
<label> |
|
Branch Name (optional): |
|
<input type="text" bind:value={newPRBranchName} placeholder="feature/new-feature" /> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreatePRDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={createPR} disabled={!newPRSubject.trim() || !newPRContent.trim() || !newPRCommitId.trim() || saving} class="save-button"> |
|
{saving ? 'Creating...' : 'Create PR'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Commit Dialog --> |
|
{#if showCommitDialog} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Commit changes" |
|
onclick={() => showCommitDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCommitDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Commit Changes</h3> |
|
<label> |
|
Commit Message: |
|
<textarea |
|
bind:value={commitMessage} |
|
placeholder="Describe your changes..." |
|
rows="4" |
|
></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCommitDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={saveFile} disabled={!commitMessage.trim() || saving} class="save-button"> |
|
{saving ? 'Saving...' : 'Commit & Save'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.container { |
|
height: 100vh; |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
} |
|
|
|
header { |
|
display: flex; |
|
flex-direction: column; |
|
border-bottom: 1px solid var(--border-color); |
|
background: var(--card-bg); |
|
} |
|
|
|
.header-content { |
|
display: flex; |
|
flex-direction: row; |
|
justify-content: space-between; |
|
align-items: flex-start; |
|
padding: 2rem 2rem 1.5rem 2rem; |
|
gap: 2rem; |
|
margin-top: 1rem; |
|
} |
|
|
|
.header-main { |
|
display: flex; |
|
flex-direction: column; |
|
flex: 1; |
|
gap: 1rem; |
|
min-width: 0; |
|
position: relative; |
|
} |
|
|
|
.header-actions-bottom { |
|
display: flex; |
|
flex-direction: row; |
|
align-items: center; |
|
gap: 0.75rem; |
|
flex-wrap: wrap; |
|
margin-top: auto; |
|
align-self: flex-end; |
|
} |
|
|
|
.repo-banner { |
|
width: 100%; |
|
height: 200px; |
|
overflow: hidden; |
|
background: var(--bg-secondary); |
|
margin-bottom: 0; |
|
position: relative; |
|
} |
|
|
|
.repo-banner::before { |
|
content: ''; |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
background: linear-gradient( |
|
to bottom, |
|
transparent 0%, |
|
transparent 60%, |
|
var(--card-bg) 100% |
|
); |
|
z-index: 1; |
|
pointer-events: none; |
|
} |
|
|
|
.repo-banner::after { |
|
content: ''; |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
background: linear-gradient( |
|
to right, |
|
transparent 0%, |
|
transparent 85%, |
|
var(--card-bg) 100% |
|
); |
|
z-index: 1; |
|
pointer-events: none; |
|
} |
|
|
|
.repo-banner img { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: cover; |
|
display: block; |
|
} |
|
|
|
.repo-title-section { |
|
display: flex; |
|
align-items: flex-start; |
|
gap: 1rem; |
|
margin-bottom: 0.5rem; |
|
width: 100%; |
|
} |
|
|
|
.repo-title-text { |
|
flex: 1; |
|
min-width: 0; /* Allow text to shrink */ |
|
} |
|
|
|
.repo-title-text h1 { |
|
margin: 0; |
|
word-wrap: break-word; |
|
} |
|
|
|
.repo-image { |
|
width: 80px; |
|
height: 80px; |
|
border-radius: 50%; |
|
object-fit: cover; |
|
flex-shrink: 0; |
|
display: block; |
|
background: var(--bg-secondary); |
|
border: 3px solid var(--card-bg); |
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
} |
|
|
|
/* Position repo image over banner if banner exists */ |
|
header:has(.repo-banner) .header-content { |
|
margin-top: -30px; /* Overlap banner slightly */ |
|
position: relative; |
|
z-index: 2; |
|
padding-left: 2.5rem; /* Extra padding on left to create space from banner edge */ |
|
} |
|
|
|
header:has(.repo-banner) .repo-image { |
|
border-color: var(--card-bg); |
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); |
|
} |
|
|
|
/* Responsive design for smaller screens */ |
|
.mobile-toggle-button { |
|
display: none; /* Hidden by default on desktop */ |
|
padding: 0.5rem; |
|
background: var(--bg-tertiary); |
|
border: 1px solid var(--border-color); |
|
border-radius: 4px; |
|
cursor: pointer; |
|
transition: background 0.2s; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
.mobile-toggle-button .icon-inline { |
|
width: 16px; |
|
height: 16px; |
|
} |
|
|
|
.mobile-toggle-button:hover { |
|
background: var(--bg-primary); |
|
} |
|
|
|
.hide-on-mobile { |
|
display: none; |
|
} |
|
|
|
@media (max-width: 768px) { |
|
.header-content { |
|
flex-direction: column; |
|
align-items: flex-start; |
|
gap: 1rem; |
|
} |
|
|
|
.header-actions { |
|
width: 100%; |
|
justify-content: flex-start; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.repo-banner { |
|
height: 150px; |
|
} |
|
|
|
header:has(.repo-banner) .header-content { |
|
margin-top: -30px; |
|
} |
|
|
|
.repo-image { |
|
width: 60px; |
|
height: 60px; |
|
} |
|
|
|
/* Mobile toggle button visible on narrow screens */ |
|
.mobile-toggle-button { |
|
display: inline-flex; |
|
} |
|
|
|
/* File tree and editor area full width and height on mobile */ |
|
.file-tree { |
|
width: 100%; |
|
flex: 1 1 auto; |
|
min-height: 0; |
|
flex-basis: auto; |
|
} |
|
|
|
.editor-area { |
|
width: 100%; |
|
flex: 1; |
|
min-height: 0; |
|
max-height: none; |
|
} |
|
|
|
/* Hide the appropriate view based on toggle state */ |
|
.file-tree.hide-on-mobile { |
|
display: none !important; |
|
} |
|
|
|
.editor-area.hide-on-mobile { |
|
display: none !important; |
|
} |
|
|
|
/* Stack layout on mobile */ |
|
.repo-layout { |
|
flex-direction: column; |
|
flex: 1; |
|
min-height: 0; |
|
} |
|
|
|
.repo-image { |
|
width: 64px; |
|
height: 64px; |
|
} |
|
|
|
.repo-title-text h1 { |
|
font-size: 1.5rem; |
|
} |
|
|
|
/* Editor header wraps on mobile */ |
|
.editor-header { |
|
flex-wrap: wrap; |
|
gap: 0.25rem; |
|
padding: 0.5rem 0.75rem; |
|
align-items: flex-start; |
|
} |
|
|
|
.file-path { |
|
flex: 1 1 100%; |
|
min-width: 0; |
|
word-break: break-all; |
|
margin-bottom: 0; |
|
padding-bottom: 0; |
|
} |
|
|
|
.editor-actions { |
|
flex: 1 1 auto; |
|
justify-content: flex-end; |
|
min-width: 0; |
|
gap: 0.5rem; |
|
} |
|
|
|
.non-maintainer-notice { |
|
font-size: 0.7rem; |
|
flex: 1 1 100%; |
|
order: 2; |
|
margin-top: 0; |
|
padding-top: 0.25rem; |
|
line-height: 1.3; |
|
} |
|
|
|
/* Make tabs more compact on mobile */ |
|
.tabs { |
|
padding: 0.4rem 0.5rem; |
|
gap: 0.2rem; |
|
} |
|
|
|
.tab-button { |
|
padding: 0.35rem 0.6rem; |
|
font-size: 0.75rem; |
|
} |
|
} |
|
|
|
/* Desktop: always show both file tree and editor */ |
|
@media (min-width: 769px) { |
|
.file-tree.hide-on-mobile { |
|
display: flex; |
|
} |
|
|
|
.editor-area.hide-on-mobile { |
|
display: flex; |
|
} |
|
|
|
.mobile-toggle-button { |
|
display: none; |
|
} |
|
} |
|
|
|
.repo-image[src=""], |
|
.repo-image:not([src]) { |
|
display: none; |
|
} |
|
|
|
.repo-banner img[src=""], |
|
.repo-banner img:not([src]) { |
|
display: none; |
|
} |
|
|
|
.repo-description-header { |
|
margin: 0.25rem 0 0 0; |
|
color: var(--text-primary); |
|
font-size: 0.9rem; |
|
line-height: 1.4; |
|
max-width: 100%; |
|
word-wrap: break-word; |
|
} |
|
|
|
.repo-description-placeholder { |
|
color: var(--text-muted); |
|
font-style: italic; |
|
} |
|
|
|
.fork-badge { |
|
padding: 0.25rem 0.5rem; |
|
background: var(--accent-light); |
|
color: var(--accent); |
|
border-radius: 4px; |
|
font-size: 0.85rem; |
|
margin-left: 0.5rem; |
|
} |
|
|
|
.fork-badge a { |
|
color: var(--accent); |
|
text-decoration: none; |
|
} |
|
|
|
.fork-badge a:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
.repo-meta-info { |
|
display: flex; |
|
flex-wrap: wrap; |
|
align-items: center; |
|
gap: 0.75rem; |
|
margin-top: 0.5rem; |
|
} |
|
|
|
.repo-language { |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.25rem; |
|
font-size: 0.875rem; |
|
color: var(--text-muted); |
|
} |
|
|
|
.repo-privacy-badge { |
|
padding: 0.125rem 0.5rem; |
|
border-radius: 0.25rem; |
|
font-size: 0.75rem; |
|
font-weight: 500; |
|
text-transform: uppercase; |
|
} |
|
|
|
.repo-privacy-badge.private { |
|
background: var(--error-bg); |
|
color: var(--error-text); |
|
} |
|
|
|
.repo-privacy-badge.public { |
|
background: var(--success-bg); |
|
color: var(--success-text); |
|
} |
|
|
|
.repo-topics { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 0.5rem; |
|
margin-top: 0.5rem; |
|
} |
|
|
|
.topic-tag { |
|
padding: 0.25rem 0.5rem; |
|
background: var(--accent-light); |
|
color: var(--accent); |
|
border-radius: 0.25rem; |
|
font-size: 0.75rem; |
|
} |
|
|
|
.repo-website { |
|
margin-top: 0.5rem; |
|
font-size: 0.875rem; |
|
} |
|
|
|
.repo-website a { |
|
color: var(--link-color); |
|
text-decoration: none; |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.25rem; |
|
} |
|
|
|
.repo-website a:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
.repo-clone-urls { |
|
margin-top: 0.5rem; |
|
display: flex; |
|
flex-wrap: wrap; |
|
align-items: center; |
|
gap: 0.5rem; |
|
font-size: 0.75rem; |
|
} |
|
|
|
.clone-label { |
|
color: var(--text-muted); |
|
font-weight: 500; |
|
} |
|
|
|
.clone-url { |
|
padding: 0.125rem 0.375rem; |
|
background: var(--bg-secondary); |
|
border: 1px solid var(--border-color); |
|
border-radius: 0.25rem; |
|
font-family: 'IBM Plex Mono', monospace; |
|
font-size: 0.75rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.clone-more { |
|
color: var(--text-muted); |
|
font-size: 0.75rem; |
|
} |
|
|
|
.repo-contributors { |
|
margin-top: 0.75rem; |
|
display: flex; |
|
flex-wrap: wrap; |
|
align-items: center; |
|
gap: 0.75rem; |
|
} |
|
|
|
.contributors-label { |
|
font-size: 0.875rem; |
|
color: var(--text-muted); |
|
font-weight: 500; |
|
} |
|
|
|
.contributors-list { |
|
display: flex; |
|
flex-wrap: wrap; |
|
align-items: center; |
|
gap: 0.5rem; |
|
} |
|
|
|
.contributor-item { |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
text-decoration: none; |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 0.5rem; |
|
background: var(--bg-secondary); |
|
border: 1px solid var(--border-color); |
|
transition: all 0.2s ease; |
|
} |
|
|
|
.contributor-item:hover { |
|
border-color: var(--accent); |
|
background: var(--card-bg); |
|
} |
|
|
|
.contributor-badge { |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 0.25rem; |
|
font-size: 0.7rem; |
|
font-weight: 700; |
|
text-transform: uppercase; |
|
white-space: nowrap; |
|
letter-spacing: 0.05em; |
|
border: 1px solid transparent; |
|
/* Ensure minimum size for touch targets */ |
|
min-height: 1.5rem; |
|
display: inline-flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
.contributor-badge.owner { |
|
/* High contrast colors that work in both light and dark modes */ |
|
background: #4a5568; |
|
color: #ffffff; |
|
border-color: #2d3748; |
|
} |
|
|
|
/* Dark mode adjustments for owner badge */ |
|
@media (prefers-color-scheme: dark) { |
|
.contributor-badge.owner { |
|
background: #718096; |
|
color: #ffffff; |
|
border-color: #a0aec0; |
|
} |
|
} |
|
|
|
.contributor-badge.maintainer { |
|
/* High contrast colors that work in both light and dark modes */ |
|
background: #22543d; |
|
color: #ffffff; |
|
border-color: #1a202c; |
|
} |
|
|
|
/* Dark mode adjustments for maintainer badge */ |
|
@media (prefers-color-scheme: dark) { |
|
.contributor-badge.maintainer { |
|
background: #48bb78; |
|
color: #1a202c; |
|
border-color: #68d391; |
|
} |
|
} |
|
|
|
header h1 { |
|
margin: 0; |
|
font-size: 1.5rem; |
|
color: var(--text-primary); |
|
} |
|
.header-actions { |
|
display: flex; |
|
flex-direction: row; |
|
align-items: center; |
|
gap: 0.75rem; |
|
flex-shrink: 0; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.branch-select { |
|
padding: 0.5rem; |
|
border: 1px solid var(--input-border); |
|
border-radius: 0.25rem; |
|
background: var(--input-bg); |
|
color: var(--text-primary); |
|
font-family: 'IBM Plex Serif', serif; |
|
} |
|
|
|
.auth-status { |
|
font-size: 0.875rem; |
|
color: var(--text-primary); |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.25rem; |
|
} |
|
|
|
.repo-view { |
|
flex: 1; |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
} |
|
|
|
.repo-layout { |
|
flex: 1; |
|
display: flex; |
|
overflow: hidden; |
|
} |
|
|
|
.file-tree { |
|
width: 300px; |
|
min-width: 300px; |
|
max-width: 300px; |
|
border-right: 1px solid var(--border-color); |
|
background: var(--bg-secondary); |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
flex: 0 0 300px; /* Fixed width, don't grow or shrink */ |
|
min-height: 0; /* Allow flex child to shrink */ |
|
} |
|
|
|
.file-tree-header { |
|
padding: 1rem; |
|
border-bottom: 1px solid var(--border-color); |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
} |
|
|
|
.file-tree-header h2 { |
|
margin: 0; |
|
font-size: 1rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.back-button { |
|
padding: 0.25rem 0.5rem; |
|
font-size: 0.875rem; |
|
background: var(--bg-tertiary); |
|
border: 1px solid var(--border-color); |
|
border-radius: 0.25rem; |
|
cursor: pointer; |
|
color: var(--text-primary); |
|
transition: background 0.2s ease; |
|
} |
|
|
|
.back-button:hover { |
|
background: var(--bg-secondary); |
|
} |
|
|
|
.file-list { |
|
list-style: none; |
|
padding: 0.5rem 0; |
|
margin: 0; |
|
overflow-y: auto; |
|
overflow-x: hidden; |
|
flex: 1; |
|
min-height: 0; /* Allows flex child to shrink below content size */ |
|
width: 100%; /* Fill horizontal space */ |
|
} |
|
|
|
.file-item { |
|
margin: 0; |
|
} |
|
|
|
.file-button { |
|
width: 100%; |
|
padding: 0.5rem 1rem; |
|
text-align: left; |
|
background: none; |
|
border: none; |
|
cursor: pointer; |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
font-size: 0.875rem; |
|
color: var(--text-primary); |
|
transition: background 0.2s ease; |
|
box-sizing: border-box; |
|
} |
|
|
|
.file-button:hover { |
|
background: var(--bg-tertiary); |
|
} |
|
|
|
.file-item.selected .file-button { |
|
background: var(--accent-light); |
|
color: var(--accent); |
|
} |
|
|
|
.file-size { |
|
color: var(--text-muted); |
|
font-size: 0.75rem; |
|
margin-left: auto; |
|
} |
|
|
|
.editor-area { |
|
flex: 1; |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
background: var(--card-bg); |
|
max-height: calc(200vh - 400px); /* Twice the original height */ |
|
} |
|
|
|
.editor-header { |
|
padding: 0.75rem 1rem; |
|
border-bottom: 1px solid var(--border-color); |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
} |
|
|
|
.file-path { |
|
font-family: 'IBM Plex Mono', monospace; |
|
font-size: 0.875rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.editor-actions { |
|
display: flex; |
|
align-items: center; |
|
gap: 1rem; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.unsaved-indicator { |
|
color: var(--warning-text); |
|
font-size: 0.875rem; |
|
} |
|
|
|
.non-maintainer-notice { |
|
font-size: 0.75rem; |
|
color: var(--text-muted); |
|
white-space: normal; |
|
line-height: 1.4; |
|
} |
|
|
|
.save-button { |
|
padding: 0.5rem 1rem; |
|
background: var(--button-primary); |
|
color: white; |
|
border: none; |
|
border-radius: 0.25rem; |
|
cursor: pointer; |
|
font-size: 0.875rem; |
|
font-family: 'IBM Plex Serif', serif; |
|
transition: background 0.2s ease; |
|
} |
|
|
|
.save-button:hover:not(:disabled) { |
|
background: var(--button-primary-hover); |
|
} |
|
|
|
.save-button:disabled { |
|
background: var(--text-muted); |
|
cursor: not-allowed; |
|
opacity: 0.6; |
|
} |
|
|
|
.editor-container { |
|
flex: 1; |
|
overflow: hidden; |
|
display: flex; |
|
flex-direction: column; |
|
min-height: 0; /* Allows flex child to shrink below content size */ |
|
} |
|
|
|
.empty-state { |
|
flex: 1; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
color: var(--text-muted); |
|
} |
|
|
|
.loading { |
|
padding: 2rem; |
|
text-align: center; |
|
color: var(--text-muted); |
|
} |
|
|
|
.error { |
|
background: var(--error-bg); |
|
color: var(--error-text); |
|
padding: 1rem; |
|
margin: 1rem; |
|
border-radius: 0.5rem; |
|
border: 1px solid var(--error-text); |
|
} |
|
|
|
.modal-overlay { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
background: rgba(0, 0, 0, 0.5); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
z-index: 1000; |
|
} |
|
|
|
.modal { |
|
background: var(--card-bg); |
|
padding: 2rem; |
|
border-radius: 0.5rem; |
|
min-width: 400px; |
|
max-width: 600px; |
|
border: 1px solid var(--border-color); |
|
} |
|
|
|
.modal h3 { |
|
margin: 0 0 1rem 0; |
|
color: var(--text-primary); |
|
} |
|
|
|
.modal label { |
|
display: block; |
|
margin-bottom: 1rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.modal input, |
|
.modal textarea, |
|
.modal select { |
|
width: 100%; |
|
padding: 0.5rem; |
|
border: 1px solid var(--input-border); |
|
border-radius: 0.25rem; |
|
font-family: 'IBM Plex Serif', serif; |
|
background: var(--input-bg); |
|
color: var(--text-primary); |
|
margin-top: 0.5rem; |
|
} |
|
|
|
.modal input:focus, |
|
.modal textarea:focus, |
|
.modal select:focus { |
|
outline: none; |
|
border-color: var(--input-focus); |
|
} |
|
|
|
.modal-actions { |
|
display: flex; |
|
justify-content: flex-end; |
|
gap: 1rem; |
|
} |
|
|
|
.cancel-button { |
|
padding: 0.5rem 1rem; |
|
background: var(--bg-tertiary); |
|
border: 1px solid var(--border-color); |
|
border-radius: 0.25rem; |
|
cursor: pointer; |
|
color: var(--text-primary); |
|
font-family: 'IBM Plex Serif', serif; |
|
transition: background 0.2s ease; |
|
} |
|
|
|
.cancel-button:hover { |
|
background: var(--bg-secondary); |
|
} |
|
|
|
.save-button { |
|
background: var(--button-primary); |
|
color: white; |
|
} |
|
|
|
.save-button:hover:not(:disabled) { |
|
background: var(--button-primary-hover); |
|
} |
|
|
|
/* Tabs */ |
|
.tabs { |
|
display: flex; |
|
gap: 0.25rem; |
|
padding: 0.5rem 1rem; |
|
border-bottom: 1px solid var(--border-color); |
|
background: var(--card-bg); |
|
overflow-x: auto; |
|
overflow-y: hidden; |
|
scrollbar-width: thin; |
|
-webkit-overflow-scrolling: touch; |
|
} |
|
|
|
.tabs::-webkit-scrollbar { |
|
height: 4px; |
|
} |
|
|
|
.tabs::-webkit-scrollbar-track { |
|
background: var(--bg-secondary); |
|
} |
|
|
|
.tabs::-webkit-scrollbar-thumb { |
|
background: var(--border-color); |
|
border-radius: 2px; |
|
} |
|
|
|
.tab-button { |
|
padding: 0.4rem 0.75rem; |
|
background: none; |
|
border: none; |
|
border-bottom: 2px solid transparent; |
|
cursor: pointer; |
|
font-size: 0.8rem; |
|
color: var(--text-muted); |
|
font-family: 'IBM Plex Serif', serif; |
|
transition: color 0.2s ease, border-color 0.2s ease; |
|
white-space: nowrap; |
|
flex-shrink: 0; |
|
} |
|
|
|
.tab-button:hover { |
|
color: var(--text-primary); |
|
} |
|
|
|
.tab-button.active { |
|
color: var(--accent); |
|
border-bottom-color: var(--accent); |
|
} |
|
|
|
/* File tree actions */ |
|
.file-tree-actions { |
|
display: flex; |
|
gap: 0.5rem; |
|
} |
|
|
|
.create-file-button, .create-branch-button, .create-tag-button { |
|
padding: 0.25rem 0.5rem; |
|
font-size: 0.75rem; |
|
background: var(--button-primary); |
|
color: white; |
|
border: none; |
|
border-radius: 0.25rem; |
|
cursor: pointer; |
|
font-family: 'IBM Plex Serif', serif; |
|
transition: background 0.2s ease; |
|
} |
|
|
|
.create-file-button:hover, .create-branch-button:hover, .create-tag-button:hover { |
|
background: var(--button-primary-hover); |
|
} |
|
|
|
.delete-file-button { |
|
padding: 0.25rem; |
|
background: none; |
|
border: none; |
|
cursor: pointer; |
|
font-size: 0.75rem; |
|
opacity: 0.6; |
|
} |
|
|
|
.delete-file-button:hover { |
|
opacity: 1; |
|
} |
|
|
|
.file-item { |
|
display: flex; |
|
align-items: center; |
|
} |
|
|
|
.file-item .file-button { |
|
flex: 1; |
|
} |
|
|
|
/* History sidebar */ |
|
.history-sidebar, .tags-sidebar { |
|
width: 300px; |
|
border-right: 1px solid var(--border-color); |
|
background: var(--bg-secondary); |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
} |
|
|
|
.history-header, .tags-header { |
|
padding: 1rem; |
|
border-bottom: 1px solid var(--border-color); |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
} |
|
|
|
.history-header h2, .tags-header h2 { |
|
margin: 0; |
|
font-size: 1rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.refresh-button { |
|
padding: 0.25rem 0.5rem; |
|
font-size: 0.75rem; |
|
background: var(--bg-tertiary); |
|
border: 1px solid var(--border-color); |
|
border-radius: 0.25rem; |
|
cursor: pointer; |
|
color: var(--text-primary); |
|
transition: background 0.2s ease; |
|
} |
|
|
|
.refresh-button:hover { |
|
background: var(--bg-secondary); |
|
} |
|
|
|
.commit-list, .tag-list { |
|
list-style: none; |
|
padding: 0; |
|
margin: 0; |
|
overflow-y: auto; |
|
flex: 1; |
|
} |
|
|
|
.commit-item, .tag-item { |
|
border-bottom: 1px solid #e5e7eb; |
|
} |
|
|
|
.commit-button { |
|
width: 100%; |
|
padding: 0.75rem 1rem; |
|
text-align: left; |
|
background: none; |
|
border: none; |
|
cursor: pointer; |
|
display: block; |
|
} |
|
|
|
.commit-button:hover { |
|
background: var(--bg-tertiary); |
|
} |
|
|
|
.commit-item.selected .commit-button { |
|
background: var(--accent-light); |
|
} |
|
|
|
.commit-hash { |
|
font-family: 'IBM Plex Mono', monospace; |
|
font-size: 0.75rem; |
|
color: var(--text-muted); |
|
margin-bottom: 0.25rem; |
|
} |
|
|
|
.commit-message { |
|
font-weight: 500; |
|
margin-bottom: 0.25rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.commit-meta { |
|
font-size: 0.75rem; |
|
color: var(--text-muted); |
|
display: flex; |
|
gap: 1rem; |
|
} |
|
|
|
.tag-item { |
|
padding: 0.75rem 1rem; |
|
} |
|
|
|
.tag-name { |
|
font-weight: 500; |
|
color: var(--link-color); |
|
margin-bottom: 0.25rem; |
|
} |
|
|
|
.tag-hash { |
|
font-family: 'IBM Plex Mono', monospace; |
|
font-size: 0.75rem; |
|
color: var(--text-muted); |
|
margin-bottom: 0.25rem; |
|
} |
|
|
|
.tag-message { |
|
font-size: 0.875rem; |
|
color: var(--text-muted); |
|
} |
|
|
|
/* Diff view */ |
|
.diff-view { |
|
flex: 1; |
|
overflow: auto; |
|
padding: 1rem; |
|
} |
|
|
|
.diff-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 1rem; |
|
padding-bottom: 0.5rem; |
|
border-bottom: 1px solid var(--border-color); |
|
} |
|
|
|
.diff-header h3 { |
|
margin: 0; |
|
color: var(--text-primary); |
|
} |
|
|
|
.close-button { |
|
padding: 0.25rem 0.5rem; |
|
background: var(--bg-tertiary); |
|
border: 1px solid var(--border-color); |
|
border-radius: 0.25rem; |
|
cursor: pointer; |
|
font-size: 1.25rem; |
|
line-height: 1; |
|
color: var(--text-primary); |
|
transition: background 0.2s ease; |
|
} |
|
|
|
.close-button:hover { |
|
background: var(--bg-secondary); |
|
} |
|
|
|
.diff-file { |
|
margin-bottom: 2rem; |
|
} |
|
|
|
.diff-file-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
padding: 0.5rem; |
|
background: var(--bg-secondary); |
|
border-radius: 0.25rem; |
|
margin-bottom: 0.5rem; |
|
} |
|
|
|
.diff-file-name { |
|
font-family: 'IBM Plex Mono', monospace; |
|
font-weight: 500; |
|
color: var(--text-primary); |
|
} |
|
|
|
.diff-stats { |
|
display: flex; |
|
gap: 0.5rem; |
|
} |
|
|
|
.additions { |
|
color: var(--success-text); |
|
} |
|
|
|
.deletions { |
|
color: var(--error-text); |
|
} |
|
|
|
.diff-content { |
|
background: var(--bg-tertiary); |
|
color: var(--text-primary); |
|
padding: 1rem; |
|
border-radius: 0.25rem; |
|
overflow-x: auto; |
|
font-size: 0.875rem; |
|
line-height: 1.5; |
|
border: 1px solid var(--border-color); |
|
} |
|
|
|
.diff-content code { |
|
font-family: 'IBM Plex Mono', monospace; |
|
white-space: pre; |
|
} |
|
|
|
.read-only-editor { |
|
height: 100%; |
|
overflow-y: auto; |
|
overflow-x: hidden; |
|
padding: 1.5rem; |
|
min-height: 0; /* Allows flex child to shrink below content size */ |
|
} |
|
|
|
.read-only-editor :global(.hljs) { |
|
padding: 1rem; |
|
background: var(--bg-tertiary); |
|
color: var(--text-primary); |
|
border-radius: 4px; |
|
overflow-x: auto; |
|
margin: 0; |
|
border: 1px solid var(--border-color); |
|
} |
|
|
|
.read-only-editor :global(pre) { |
|
margin: 0; |
|
padding: 0; |
|
} |
|
|
|
.read-only-editor :global(code) { |
|
font-family: 'IBM Plex Mono', monospace; |
|
font-size: 14px; |
|
line-height: 1.5; |
|
display: block; |
|
white-space: pre; |
|
} |
|
|
|
.readme-section { |
|
display: flex; |
|
flex-direction: column; |
|
height: 100%; |
|
overflow: hidden; |
|
} |
|
|
|
.readme-header { |
|
padding: 1rem; |
|
border-bottom: 1px solid var(--border-color); |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
flex-shrink: 0; |
|
} |
|
|
|
.readme-header h3 { |
|
margin: 0; |
|
font-size: 1.25rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.readme-actions { |
|
display: flex; |
|
gap: 1rem; |
|
align-items: center; |
|
} |
|
|
|
.readme-content { |
|
flex: 1; |
|
overflow-y: auto; |
|
overflow-x: hidden; |
|
padding: 1.5rem; |
|
min-height: 0; /* Allows flex child to shrink below content size */ |
|
} |
|
|
|
.readme-content.markdown { |
|
padding: 1.5rem; |
|
} |
|
|
|
.readme-content.markdown :global(h1), |
|
.readme-content.markdown :global(h2), |
|
.readme-content.markdown :global(h3), |
|
.readme-content.markdown :global(h4), |
|
.readme-content.markdown :global(h5), |
|
.readme-content.markdown :global(h6) { |
|
margin-top: 1.5rem; |
|
margin-bottom: 0.75rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.readme-content.markdown :global(p) { |
|
margin-bottom: 1rem; |
|
line-height: 1.6; |
|
} |
|
|
|
.readme-content.markdown :global(code) { |
|
background: var(--bg-secondary); |
|
padding: 0.2rem 0.4rem; |
|
border-radius: 3px; |
|
font-family: 'IBM Plex Mono', monospace; |
|
font-size: 0.9em; |
|
} |
|
|
|
.readme-content.markdown :global(pre) { |
|
background: var(--bg-secondary); |
|
padding: 1rem; |
|
border-radius: 4px; |
|
overflow-x: auto; |
|
border: 1px solid var(--border-light); |
|
margin: 1rem 0; |
|
} |
|
|
|
.readme-content.markdown :global(pre code) { |
|
background: none; |
|
padding: 0; |
|
} |
|
|
|
.readme-content :global(.hljs) { |
|
background: var(--bg-secondary); |
|
padding: 1rem; |
|
border-radius: 4px; |
|
overflow-x: auto; |
|
border: 1px solid var(--border-light); |
|
} |
|
|
|
.readme-content :global(pre.hljs) { |
|
margin: 1rem 0; |
|
} |
|
|
|
/* Issues and PRs */ |
|
.issues-sidebar, .prs-sidebar { |
|
width: 300px; |
|
border-right: 1px solid var(--border-color); |
|
background: var(--bg-secondary); |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
} |
|
|
|
.issues-header, .prs-header { |
|
padding: 1rem; |
|
border-bottom: 1px solid var(--border-color); |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
} |
|
|
|
.issues-header h2, .prs-header h2 { |
|
margin: 0; |
|
font-size: 1rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.create-issue-button, .create-pr-button { |
|
padding: 0.25rem 0.5rem; |
|
font-size: 0.75rem; |
|
background: var(--button-primary); |
|
color: white; |
|
border: none; |
|
border-radius: 0.25rem; |
|
cursor: pointer; |
|
font-family: 'IBM Plex Serif', serif; |
|
transition: background 0.2s ease; |
|
} |
|
|
|
.create-issue-button:hover, .create-pr-button:hover { |
|
background: var(--button-primary-hover); |
|
} |
|
|
|
.issue-list, .pr-list { |
|
list-style: none; |
|
padding: 0; |
|
margin: 0; |
|
overflow-y: auto; |
|
flex: 1; |
|
} |
|
|
|
.issue-item, .pr-item { |
|
padding: 0.75rem 1rem; |
|
border-bottom: 1px solid var(--border-color); |
|
cursor: pointer; |
|
transition: background 0.2s ease; |
|
} |
|
|
|
.issue-item:hover, .pr-item:hover { |
|
background: var(--bg-tertiary); |
|
} |
|
|
|
.issue-header, .pr-header { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
margin-bottom: 0.25rem; |
|
} |
|
|
|
.issue-status, .pr-status { |
|
padding: 0.125rem 0.5rem; |
|
border-radius: 0.25rem; |
|
font-size: 0.75rem; |
|
font-weight: 500; |
|
text-transform: uppercase; |
|
} |
|
|
|
.issue-status.open, .pr-status.open { |
|
background: var(--accent-light); |
|
color: var(--accent); |
|
} |
|
|
|
.issue-status.closed, .pr-status.closed { |
|
background: var(--error-bg); |
|
color: var(--error-text); |
|
} |
|
|
|
.issue-status.resolved, .pr-status.merged { |
|
background: var(--success-bg); |
|
color: var(--success-text); |
|
} |
|
|
|
.issue-subject, .pr-subject { |
|
font-weight: 500; |
|
flex: 1; |
|
color: var(--text-primary); |
|
} |
|
|
|
.issue-meta, .pr-meta { |
|
font-size: 0.75rem; |
|
color: var(--text-muted); |
|
display: flex; |
|
gap: 0.75rem; |
|
} |
|
|
|
.pr-commit { |
|
font-family: 'IBM Plex Mono', monospace; |
|
} |
|
|
|
.issues-content, .prs-content { |
|
flex: 1; |
|
overflow-y: auto; |
|
padding: 2rem; |
|
background: var(--card-bg); |
|
} |
|
|
|
.issue-detail, .pr-detail { |
|
margin-bottom: 2rem; |
|
padding-bottom: 2rem; |
|
border-bottom: 1px solid var(--border-color); |
|
} |
|
|
|
.issue-detail h3, .pr-detail h3 { |
|
margin: 0 0 0.5rem 0; |
|
font-size: 1.5rem; |
|
color: var(--text-primary); |
|
} |
|
|
|
.issue-meta-detail, .pr-meta-detail { |
|
display: flex; |
|
align-items: center; |
|
gap: 1rem; |
|
margin-bottom: 1rem; |
|
font-size: 0.875rem; |
|
color: var(--text-muted); |
|
} |
|
|
|
.issue-body, .pr-body { |
|
line-height: 1.6; |
|
color: var(--text-primary); |
|
} |
|
|
|
.verification-status { |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 0.25rem; |
|
font-size: 0.75rem; |
|
font-weight: 500; |
|
margin-left: 0.5rem; |
|
} |
|
|
|
.verification-status.verified { |
|
background: var(--success-bg); |
|
color: var(--success-text); |
|
} |
|
|
|
.verification-status.unverified { |
|
background: var(--error-bg); |
|
color: var(--error-text); |
|
} |
|
|
|
.icon-inline { |
|
width: 1em; |
|
height: 1em; |
|
vertical-align: middle; |
|
display: inline-block; |
|
margin-right: 0.25rem; |
|
/* Make icons visible on dark backgrounds by inverting to light */ |
|
filter: brightness(0) saturate(100%) invert(1); |
|
} |
|
|
|
.icon-small { |
|
width: 16px; |
|
height: 16px; |
|
vertical-align: middle; |
|
/* Make icons visible on dark backgrounds by inverting to light */ |
|
filter: brightness(0) saturate(100%) invert(1); |
|
} |
|
|
|
/* Theme-aware icon colors */ |
|
.auth-status .icon-inline { |
|
filter: brightness(0) saturate(100%) invert(1); |
|
opacity: 0.8; |
|
} |
|
|
|
.verification-status.verified .icon-inline { |
|
/* Green checkmark for verified */ |
|
filter: brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%); |
|
} |
|
|
|
.verification-status.unverified .icon-inline { |
|
/* Orange/yellow warning for unverified */ |
|
filter: brightness(0) saturate(100%) invert(67%) sepia(93%) saturate(1352%) hue-rotate(358deg) brightness(102%) contrast(106%); |
|
} |
|
|
|
.file-button .icon-inline { |
|
filter: brightness(0) saturate(100%) invert(1); |
|
opacity: 0.7; |
|
} |
|
|
|
.delete-file-button .icon-small { |
|
/* Red for delete button */ |
|
filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(346deg) brightness(104%) contrast(97%); |
|
} |
|
|
|
</style>
|
|
|