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