Browse Source
Nostr-Signature: 716cfe7b5d8b788e6e24092a6ad7e92de0b3d383c43a343f3c5bec4d2bbdd4b9 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc e80ed3d9d471bd6907e212edfd7cf3f6039fa80e4434c35f0591729515eaa98c7a8ac54f2ac6f7a2fefb7846de0e2f0a120543a0dbe862c47c7710a653189b0cmain
31 changed files with 2118 additions and 1112 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,122 @@ |
|||||||
|
/** |
||||||
|
* Authentication operations service |
||||||
|
* Handles user authentication and login |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { RepoState } from '../stores/repo-state.js'; |
||||||
|
import { userStore } from '$lib/stores/user-store.js'; |
||||||
|
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Check authentication status |
||||||
|
*/ |
||||||
|
export async function checkAuth(state: RepoState): Promise<void> { |
||||||
|
// Check userStore first
|
||||||
|
const currentUser = get(userStore); |
||||||
|
if (currentUser.userPubkey && currentUser.userPubkeyHex) { |
||||||
|
state.user.pubkey = currentUser.userPubkey; |
||||||
|
state.user.pubkeyHex = currentUser.userPubkeyHex; |
||||||
|
// Recheck maintainer status and bookmark status after auth
|
||||||
|
// These will be called by useUserStoreEffect hook
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Login with NIP-07 |
||||||
|
*/ |
||||||
|
export async function login( |
||||||
|
state: RepoState, |
||||||
|
callbacks: { |
||||||
|
checkMaintainerStatus: () => Promise<void>; |
||||||
|
loadBookmarkStatus: () => Promise<void>; |
||||||
|
} |
||||||
|
): Promise<void> { |
||||||
|
// Check userStore first
|
||||||
|
const currentUser = get(userStore); |
||||||
|
if (currentUser.userPubkey && currentUser.userPubkeyHex) { |
||||||
|
state.user.pubkey = currentUser.userPubkey; |
||||||
|
state.user.pubkeyHex = currentUser.userPubkeyHex; |
||||||
|
// Recheck maintainer status and bookmark status after auth
|
||||||
|
await callbacks.checkMaintainerStatus(); |
||||||
|
await callbacks.loadBookmarkStatus(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Get public key from NIP-07 extension
|
||||||
|
const pubkey = await getPublicKeyWithNIP07(); |
||||||
|
if (!pubkey) { |
||||||
|
state.error = 'Failed to get public key from NIP-07 extension'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
state.user.pubkey = pubkey; |
||||||
|
|
||||||
|
// Convert npub to hex if needed
|
||||||
|
let pubkeyHex: string; |
||||||
|
if (pubkey.startsWith('npub')) { |
||||||
|
try { |
||||||
|
const { nip19 } = await import('nostr-tools'); |
||||||
|
const decoded = nip19.decode(pubkey); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
pubkeyHex = decoded.data as string; |
||||||
|
} else { |
||||||
|
state.error = 'Invalid public key format'; |
||||||
|
return; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
state.error = 'Invalid public key format'; |
||||||
|
return; |
||||||
|
} |
||||||
|
} else { |
||||||
|
pubkeyHex = pubkey; |
||||||
|
} |
||||||
|
|
||||||
|
state.user.pubkeyHex = pubkeyHex; |
||||||
|
|
||||||
|
// Check write access and update user store
|
||||||
|
const { determineUserLevel } = await import('$lib/services/nostr/user-level-service.js'); |
||||||
|
const levelResult = await determineUserLevel(state.user.pubkey, state.user.pubkeyHex); |
||||||
|
|
||||||
|
// Update user store with write access level
|
||||||
|
userStore.setUser( |
||||||
|
levelResult.userPubkey, |
||||||
|
levelResult.userPubkeyHex, |
||||||
|
levelResult.level, |
||||||
|
levelResult.error || null |
||||||
|
); |
||||||
|
|
||||||
|
// Update activity tracking
|
||||||
|
const { updateActivity } = await import('$lib/services/activity-tracker.js'); |
||||||
|
updateActivity(); |
||||||
|
|
||||||
|
// Check for pending transfer events
|
||||||
|
if (state.user.pubkeyHex) { |
||||||
|
try { |
||||||
|
const response = await fetch('/api/transfers/pending', { |
||||||
|
headers: { |
||||||
|
'X-User-Pubkey': state.user.pubkeyHex |
||||||
|
} |
||||||
|
}); |
||||||
|
if (response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
if (data.pendingTransfers && data.pendingTransfers.length > 0) { |
||||||
|
window.dispatchEvent(new CustomEvent('pendingTransfers', {
|
||||||
|
detail: { transfers: data.pendingTransfers }
|
||||||
|
})); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Failed to check for pending transfers:', err); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Re-check maintainer status and bookmark status after login
|
||||||
|
await callbacks.checkMaintainerStatus(); |
||||||
|
await callbacks.loadBookmarkStatus(); |
||||||
|
} catch (err) { |
||||||
|
state.error = err instanceof Error ? err.message : 'Failed to connect'; |
||||||
|
console.error('Login error:', err); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
/** |
||||||
|
* Patch handler utilities |
||||||
|
* UI interaction handlers for patch operations |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { RepoState } from '../stores/repo-state.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle patch code selection |
||||||
|
*/ |
||||||
|
export function handlePatchCodeSelection( |
||||||
|
text: string, |
||||||
|
startLine: number, |
||||||
|
endLine: number, |
||||||
|
startPos: number, |
||||||
|
endPos: number, |
||||||
|
state: RepoState |
||||||
|
): void { |
||||||
|
if (!text.trim() || !state.user.pubkey) return; |
||||||
|
|
||||||
|
state.forms.patchHighlight.text = text; |
||||||
|
state.forms.patchHighlight.startLine = startLine; |
||||||
|
state.forms.patchHighlight.endLine = endLine; |
||||||
|
state.forms.patchHighlight.startPos = startPos; |
||||||
|
state.forms.patchHighlight.endPos = endPos; |
||||||
|
state.openDialog = 'patchHighlight'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Start patch comment |
||||||
|
*/ |
||||||
|
export function startPatchComment( |
||||||
|
parentId: string | undefined, |
||||||
|
state: RepoState |
||||||
|
): void { |
||||||
|
if (!state.user.pubkey) { |
||||||
|
alert('Please connect your NIP-07 extension'); |
||||||
|
return; |
||||||
|
} |
||||||
|
state.forms.patchComment.replyingTo = parentId || null; |
||||||
|
state.openDialog = 'patchComment'; |
||||||
|
} |
||||||
@ -0,0 +1,87 @@ |
|||||||
|
/** |
||||||
|
* Content rendering utility |
||||||
|
* Renders content as Markdown (default) or AsciiDoc (for kinds 30041 and 30818) |
||||||
|
* Consolidates rendering logic used by DocsViewer, PRsTab, IssuesTab, and PatchesTab |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Render content as HTML based on kind or contentType |
||||||
|
* @param content - The content to render |
||||||
|
* @param kindOrType - The Nostr event kind (30041 or 30818 for AsciiDoc) or contentType string ('asciidoc' for AsciiDoc, everything else for Markdown) |
||||||
|
* @returns Promise<string> - Rendered HTML |
||||||
|
*/ |
||||||
|
export async function renderContent( |
||||||
|
content: string,
|
||||||
|
kindOrType?: number | 'markdown' | 'asciidoc' | 'text' |
||||||
|
): Promise<string> { |
||||||
|
if (!content) return ''; |
||||||
|
|
||||||
|
// Determine if we should use AsciiDoc
|
||||||
|
let useAsciiDoc = false; |
||||||
|
if (typeof kindOrType === 'number') { |
||||||
|
// Nostr event kind: 30041 or 30818 for AsciiDoc
|
||||||
|
useAsciiDoc = kindOrType === 30041 || kindOrType === 30818; |
||||||
|
} else if (typeof kindOrType === 'string') { |
||||||
|
// Content type string: 'asciidoc' for AsciiDoc
|
||||||
|
useAsciiDoc = kindOrType === 'asciidoc'; |
||||||
|
} |
||||||
|
|
||||||
|
if (useAsciiDoc) { |
||||||
|
// Use AsciiDoc parser
|
||||||
|
const asciidoctor = (await import('asciidoctor')).default(); |
||||||
|
const result = asciidoctor.convert(content, { |
||||||
|
safe: 'safe', |
||||||
|
attributes: { |
||||||
|
'source-highlighter': 'highlight.js' |
||||||
|
} |
||||||
|
}); |
||||||
|
return typeof result === 'string' ? result : String(result); |
||||||
|
} else if (typeof kindOrType === 'string' && kindOrType === 'text') { |
||||||
|
// Plain text - escape HTML
|
||||||
|
return content |
||||||
|
.replace(/&/g, '&') |
||||||
|
.replace(/</g, '<') |
||||||
|
.replace(/>/g, '>') |
||||||
|
.replace(/\n/g, '<br>'); |
||||||
|
} else { |
||||||
|
// Use Markdown parser (default)
|
||||||
|
const MarkdownIt = (await import('markdown-it')).default; |
||||||
|
const hljsModule = await import('highlight.js'); |
||||||
|
const hljs = hljsModule.default || hljsModule; |
||||||
|
|
||||||
|
const md = new MarkdownIt({ |
||||||
|
html: true, |
||||||
|
linkify: true, |
||||||
|
typographer: true, |
||||||
|
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
|
||||||
|
} |
||||||
|
} |
||||||
|
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>'; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
let rendered = md.render(content); |
||||||
|
|
||||||
|
// Add IDs to headings for anchor links (like DocsViewer does)
|
||||||
|
rendered = rendered.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (match, level, text) => { |
||||||
|
const textContent = text.replace(/<[^>]*>/g, '').trim(); |
||||||
|
const slug = textContent |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^\w\s-]/g, '') |
||||||
|
.replace(/\s+/g, '-') |
||||||
|
.replace(/-+/g, '-') |
||||||
|
.replace(/^-|-$/g, ''); |
||||||
|
|
||||||
|
return `<h${level} id="${slug}">${text}</h${level}>`; |
||||||
|
}); |
||||||
|
|
||||||
|
return rendered; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,134 @@ |
|||||||
|
/** |
||||||
|
* File handler utilities |
||||||
|
* UI interaction handlers for file operations |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { RepoState } from '../stores/repo-state.js'; |
||||||
|
import { getMimeType } from './file-helpers.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle content change in file editor |
||||||
|
*/ |
||||||
|
export function handleContentChange(value: string, state: RepoState): void { |
||||||
|
state.files.editedContent = value; |
||||||
|
state.files.hasChanges = value !== state.files.content; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle file click (directory or file) |
||||||
|
*/ |
||||||
|
export function handleFileClick( |
||||||
|
file: { name: string; path: string; type: 'file' | 'directory' }, |
||||||
|
state: RepoState, |
||||||
|
callbacks: { |
||||||
|
loadFiles: (path: string) => Promise<void>; |
||||||
|
loadFile: (path: string) => Promise<void>; |
||||||
|
} |
||||||
|
): void { |
||||||
|
if (file.type === 'directory') { |
||||||
|
state.files.pathStack.push(state.files.currentPath); |
||||||
|
callbacks.loadFiles(file.path); |
||||||
|
} else { |
||||||
|
callbacks.loadFile(file.path); |
||||||
|
// On mobile, switch to file viewer when a file is clicked
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth <= 768) { |
||||||
|
state.ui.showFileListOnMobile = false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Copy file content to clipboard |
||||||
|
*/ |
||||||
|
export async function copyFileContent( |
||||||
|
state: RepoState, |
||||||
|
event?: Event |
||||||
|
): Promise<void> { |
||||||
|
if (!state.files.content || state.preview.copying) return; |
||||||
|
|
||||||
|
state.preview.copying = true; |
||||||
|
try { |
||||||
|
await navigator.clipboard.writeText(state.files.content); |
||||||
|
// Show temporary feedback
|
||||||
|
const button = event?.target as HTMLElement; |
||||||
|
if (button) { |
||||||
|
const originalTitle = button.getAttribute('title') || ''; |
||||||
|
button.setAttribute('title', 'Copied!'); |
||||||
|
setTimeout(() => { |
||||||
|
button.setAttribute('title', originalTitle); |
||||||
|
}, 2000); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Failed to copy file content:', err); |
||||||
|
alert('Failed to copy file content to clipboard'); |
||||||
|
} finally { |
||||||
|
state.preview.copying = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Download file |
||||||
|
*/ |
||||||
|
export function downloadFile(state: RepoState): void { |
||||||
|
if (!state.files.content || !state.files.currentFile) return; |
||||||
|
|
||||||
|
try { |
||||||
|
// Determine MIME type based on file extension
|
||||||
|
const ext = state.files.currentFile.split('.').pop()?.toLowerCase() || ''; |
||||||
|
const mimeType = getMimeType(ext); |
||||||
|
|
||||||
|
const blob = new Blob([state.files.content], { type: mimeType }); |
||||||
|
const url = URL.createObjectURL(blob); |
||||||
|
const a = document.createElement('a'); |
||||||
|
a.href = url; |
||||||
|
a.download = state.files.currentFile.split('/').pop() || 'file'; |
||||||
|
document.body.appendChild(a); |
||||||
|
a.click(); |
||||||
|
document.body.removeChild(a); |
||||||
|
URL.revokeObjectURL(url); |
||||||
|
} catch (err) { |
||||||
|
console.error('Failed to download file:', err); |
||||||
|
alert('Failed to download file'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle back navigation in file browser |
||||||
|
*/ |
||||||
|
export function handleBack( |
||||||
|
state: RepoState, |
||||||
|
callbacks: { |
||||||
|
loadFiles: (path: string) => Promise<void>; |
||||||
|
} |
||||||
|
): void { |
||||||
|
if (state.files.pathStack.length > 0) { |
||||||
|
const parentPath = state.files.pathStack.pop() || ''; |
||||||
|
callbacks.loadFiles(parentPath); |
||||||
|
} else { |
||||||
|
callbacks.loadFiles(''); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Toggle word wrap |
||||||
|
*/ |
||||||
|
export async function toggleWordWrap( |
||||||
|
state: RepoState, |
||||||
|
callbacks: { |
||||||
|
applySyntaxHighlighting: (content: string, ext: string) => Promise<void>; |
||||||
|
} |
||||||
|
): Promise<void> { |
||||||
|
state.ui.wordWrap = !state.ui.wordWrap; |
||||||
|
console.log('Word wrap toggled:', state.ui.wordWrap); |
||||||
|
// Force DOM update by accessing the element
|
||||||
|
await new Promise(resolve => { |
||||||
|
requestAnimationFrame(() => { |
||||||
|
requestAnimationFrame(resolve); |
||||||
|
}); |
||||||
|
}); |
||||||
|
// Re-apply syntax highlighting to refresh the display
|
||||||
|
if (state.files.currentFile && state.files.content) { |
||||||
|
const ext = state.files.currentFile.split('.').pop() || ''; |
||||||
|
await callbacks.applySyntaxHighlighting(state.files.content, ext); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
/** |
||||||
|
* Repository handler utilities |
||||||
|
* UI interaction handlers for repository operations |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { RepoState } from '../stores/repo-state.js'; |
||||||
|
import type { Page } from '@sveltejs/kit'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Copy clone URL to clipboard |
||||||
|
*/ |
||||||
|
export async function copyCloneUrl( |
||||||
|
state: RepoState, |
||||||
|
pageData: { gitDomain?: string } | undefined, |
||||||
|
pageUrl: Page['url'] | undefined |
||||||
|
): Promise<void> { |
||||||
|
if (state.clone.copyingUrl) return; |
||||||
|
|
||||||
|
state.clone.copyingUrl = true; |
||||||
|
try { |
||||||
|
// Guard against SSR
|
||||||
|
if (typeof window === 'undefined') return; |
||||||
|
if (!pageUrl) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Use gitDomain from page data if available, otherwise use current URL host
|
||||||
|
// gitDomain is set from GIT_DOMAIN env var and is the actual production domain
|
||||||
|
let host: string; |
||||||
|
let protocol: string; |
||||||
|
|
||||||
|
if (pageData?.gitDomain) { |
||||||
|
const gitDomain = pageData.gitDomain; |
||||||
|
// Check if gitDomain is localhost - if so, we should use the actual current domain
|
||||||
|
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
||||||
|
|
||||||
|
if (isLocalhost) { |
||||||
|
// During development, use the actual current domain from the URL
|
||||||
|
host = pageUrl.host; |
||||||
|
protocol = pageUrl.protocol.slice(0, -1); // Remove trailing ":"
|
||||||
|
} else { |
||||||
|
// Use the configured git domain (production)
|
||||||
|
host = gitDomain; |
||||||
|
protocol = 'https'; // Production domains should use HTTPS
|
||||||
|
} |
||||||
|
} else { |
||||||
|
// Fallback to current URL
|
||||||
|
host = pageUrl.host; |
||||||
|
protocol = pageUrl.protocol.slice(0, -1); |
||||||
|
} |
||||||
|
|
||||||
|
// Use /api/git/ format for better compatibility with commit signing hook
|
||||||
|
const cloneUrl = `${protocol}://${host}/api/git/${state.npub}/${state.repo}.git`; |
||||||
|
const cloneCommand = `git clone ${cloneUrl}`; |
||||||
|
|
||||||
|
// Try to use the Clipboard API
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) { |
||||||
|
await navigator.clipboard.writeText(cloneCommand); |
||||||
|
alert(`Clone command copied to clipboard!\n\n${cloneCommand}`); |
||||||
|
} else { |
||||||
|
// Fallback: create a temporary textarea
|
||||||
|
const textarea = document.createElement('textarea'); |
||||||
|
textarea.value = cloneCommand; |
||||||
|
textarea.style.position = 'fixed'; |
||||||
|
textarea.style.opacity = '0'; |
||||||
|
document.body.appendChild(textarea); |
||||||
|
textarea.select(); |
||||||
|
document.execCommand('copy'); |
||||||
|
document.body.removeChild(textarea); |
||||||
|
alert(`Clone command copied to clipboard!\n\n${cloneCommand}`); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Failed to copy clone command:', err); |
||||||
|
alert('Failed to copy clone command to clipboard'); |
||||||
|
} finally { |
||||||
|
state.clone.copyingUrl = false; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue