Browse Source
Nostr-Signature: eafa232557affbacb430b467507febc201f0a8f54f4b9ecf57e315c32e51a589 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 53c58aabe0bfad6e432a8bb980c2046fc14bc8163825fde2ac766a449ce4418adb1049ac732c7fc7ecc7ad050539fb68c023d54f2b6c390e478616b5c0b91a31main
9 changed files with 1590 additions and 551 deletions
@ -0,0 +1,418 @@
@@ -0,0 +1,418 @@
|
||||
<script lang="ts"> |
||||
import { downloadRepository } from '../utils/download.js'; |
||||
import { buildApiHeaders } from '../utils/api-client.js'; |
||||
import logger from '$lib/services/logger.js'; |
||||
import TabsMenu from '$lib/components/TabsMenu.svelte'; |
||||
|
||||
interface Props { |
||||
npub: string; |
||||
repo: string; |
||||
tags: Array<{ name: string; hash: string; message?: string; date?: number }>; |
||||
releases: Array<{ |
||||
id: string; |
||||
tagName: string; |
||||
tagHash?: string; |
||||
releaseNotes?: string; |
||||
isDraft?: boolean; |
||||
isPrerelease?: boolean; |
||||
created_at: number; |
||||
pubkey: string; |
||||
}>; |
||||
selectedTag: string | null; |
||||
isMaintainer: boolean; |
||||
userPubkeyHex: string | null; |
||||
repoOwnerPubkeyDerived: string; |
||||
isRepoCloned: boolean | null; |
||||
canViewRepo: boolean; |
||||
canUseApiFallback: boolean; |
||||
needsClone: boolean; |
||||
cloneTooltip: string; |
||||
activeTab: string; |
||||
tabs: Array<{ id: string; label: string; icon?: string }>; |
||||
showLeftPanelOnMobile: boolean; |
||||
onTagSelect: (tagName: string) => void; |
||||
onTabChange: (tab: string) => void; |
||||
onToggleMobilePanel: () => void; |
||||
onCreateTag: () => void; |
||||
onCreateRelease: (tagName: string, tagHash: string) => void; |
||||
onLoadTags: () => Promise<void>; |
||||
} |
||||
|
||||
let { |
||||
npub, |
||||
repo, |
||||
tags, |
||||
releases, |
||||
selectedTag, |
||||
isMaintainer, |
||||
userPubkeyHex, |
||||
repoOwnerPubkeyDerived, |
||||
isRepoCloned, |
||||
canViewRepo, |
||||
canUseApiFallback, |
||||
needsClone, |
||||
cloneTooltip, |
||||
activeTab, |
||||
tabs, |
||||
showLeftPanelOnMobile, |
||||
onTagSelect, |
||||
onTabChange, |
||||
onToggleMobilePanel, |
||||
onCreateTag, |
||||
onCreateRelease, |
||||
onLoadTags |
||||
}: Props = $props(); |
||||
|
||||
let loadingTags = $state(false); |
||||
let downloadError = $state<string | null>(null); |
||||
|
||||
async function handleDownloadTag(tagName: string) { |
||||
downloadError = null; |
||||
try { |
||||
logger.info({ npub, repo, tag: tagName }, '[TagsTab] Starting tag download'); |
||||
await downloadRepository({ |
||||
npub, |
||||
repo, |
||||
ref: tagName, |
||||
filename: `${repo}-${tagName}.zip` |
||||
}); |
||||
logger.info({ npub, repo, tag: tagName }, '[TagsTab] Tag download completed'); |
||||
} catch (err) { |
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to download tag'; |
||||
logger.error({ error: err, npub, repo, tag: tagName }, '[TagsTab] Tag download failed'); |
||||
downloadError = errorMessage; |
||||
// Show error to user |
||||
alert(`Download failed: ${errorMessage}`); |
||||
} |
||||
} |
||||
|
||||
async function handleCreateTag() { |
||||
if (!userPubkeyHex) { |
||||
alert('Please connect your NIP-07 extension'); |
||||
return; |
||||
} |
||||
onCreateTag(); |
||||
} |
||||
|
||||
function handleCreateRelease(tagName: string, tagHash: string) { |
||||
onCreateRelease(tagName, tagHash); |
||||
} |
||||
</script> |
||||
|
||||
{#if activeTab === 'tags' && canViewRepo} |
||||
<aside class="tags-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'tags'}> |
||||
<div class="tags-header"> |
||||
<TabsMenu |
||||
{activeTab} |
||||
{tabs} |
||||
onTabChange={(tab) => onTabChange(tab as string)} |
||||
/> |
||||
<h2>Tags {#if isRepoCloned === false && canUseApiFallback}<span class="read-only-badge">Read-Only</span>{/if}</h2> |
||||
{#if userPubkeyHex && isMaintainer} |
||||
<button |
||||
onclick={handleCreateTag} |
||||
class="create-tag-button" |
||||
disabled={needsClone} |
||||
title={needsClone ? cloneTooltip : 'Create a new tag'} |
||||
> |
||||
<img src="/icons/plus.svg" alt="New Tag" class="icon" /> |
||||
</button> |
||||
{/if} |
||||
<button |
||||
onclick={onToggleMobilePanel} |
||||
class="mobile-toggle-button" |
||||
title="Show content" |
||||
> |
||||
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> |
||||
</button> |
||||
</div> |
||||
{#if loadingTags} |
||||
<div class="loading">Loading tags...</div> |
||||
{:else if tags.length > 0} |
||||
<ul class="tag-list"> |
||||
{#each tags as tag} |
||||
{@const tagHash = tag.hash || ''} |
||||
{#if tagHash} |
||||
<li class="tag-item" class:selected={selectedTag === tag.name}> |
||||
<button |
||||
onclick={() => onTagSelect(tag.name)} |
||||
class="tag-item-button" |
||||
> |
||||
<div class="tag-name">{tag.name}</div> |
||||
<div class="tag-hash">{tagHash.slice(0, 7)}</div> |
||||
{#if tag.date} |
||||
<div class="tag-date">{new Date(tag.date * 1000).toLocaleDateString()}</div> |
||||
{/if} |
||||
{#if releases.find(r => r.tagName === tag.name)} |
||||
<img src="/icons/package.svg" alt="Has release" class="tag-has-release-icon" title="This tag has a release" /> |
||||
{/if} |
||||
</button> |
||||
</li> |
||||
{/if} |
||||
{/each} |
||||
</ul> |
||||
{:else} |
||||
<div class="empty-state"> |
||||
<p>No tags found</p> |
||||
</div> |
||||
{/if} |
||||
</aside> |
||||
{/if} |
||||
|
||||
{#if activeTab === 'tags'} |
||||
<div class="tags-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'tags'}> |
||||
<div class="content-header-mobile"> |
||||
<button |
||||
onclick={onToggleMobilePanel} |
||||
class="mobile-toggle-button" |
||||
title="Show list" |
||||
> |
||||
<img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" /> |
||||
</button> |
||||
</div> |
||||
{#if selectedTag} |
||||
{@const tag = tags.find(t => t.name === selectedTag)} |
||||
{@const release = releases.find(r => r.tagName === selectedTag)} |
||||
{#if tag} |
||||
<div class="tag-detail"> |
||||
<div class="tag-detail-header"> |
||||
<h3>{tag.name}</h3> |
||||
<div class="tag-detail-meta"> |
||||
<span>Tag: {tag.hash?.slice(0, 7) || 'N/A'}</span> |
||||
{#if tag.date} |
||||
<span class="tag-date">Created {new Date(tag.date * 1000).toLocaleString()}</span> |
||||
{/if} |
||||
<button |
||||
type="button" |
||||
class="download-tag-button" |
||||
title="Download source code as ZIP" |
||||
onclick={async (e) => { |
||||
e.preventDefault(); |
||||
e.stopPropagation(); |
||||
e.stopImmediatePropagation(); |
||||
await handleDownloadTag(tag.name); |
||||
}} |
||||
> |
||||
<img src="/icons/download.svg" alt="Download" class="icon-inline" /> |
||||
Download ZIP |
||||
</button> |
||||
{#if downloadError} |
||||
<div class="error-message" style="color: red; margin-top: 0.5rem;"> |
||||
{downloadError} |
||||
</div> |
||||
{/if} |
||||
{#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned && !release} |
||||
<button |
||||
onclick={() => handleCreateRelease(tag.name, tag.hash || '')} |
||||
class="release-tag-button" |
||||
title="Create a release for this tag" |
||||
> |
||||
Release this tag |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{#if tag.message} |
||||
<div class="tag-message"> |
||||
<p>{tag.message}</p> |
||||
</div> |
||||
{/if} |
||||
{#if release} |
||||
<div class="tag-release-section"> |
||||
<h4>Release</h4> |
||||
<div class="release-info"> |
||||
{#if release.isDraft} |
||||
<span class="release-badge draft">Draft</span> |
||||
{/if} |
||||
{#if release.isPrerelease} |
||||
<span class="release-badge prerelease">Pre-release</span> |
||||
{/if} |
||||
<div class="release-meta"> |
||||
<span>Released {new Date(release.created_at * 1000).toLocaleDateString()}</span> |
||||
</div> |
||||
{#if release.releaseNotes} |
||||
<div class="release-notes"> |
||||
{@html release.releaseNotes.replace(/\n/g, '<br>')} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
{:else} |
||||
<div class="empty-state"> |
||||
<p>Select a tag from the sidebar to view details</p> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.tags-sidebar { |
||||
width: 300px; |
||||
border-right: 1px solid var(--border-color); |
||||
overflow-y: auto; |
||||
background: var(--bg-primary); |
||||
} |
||||
|
||||
.tags-header { |
||||
padding: 1rem; |
||||
border-bottom: 1px solid var(--border-color); |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.tags-header h2 { |
||||
flex: 1; |
||||
margin: 0; |
||||
font-size: 1.2rem; |
||||
} |
||||
|
||||
.create-tag-button { |
||||
padding: 0.5rem; |
||||
background: var(--button-primary); |
||||
border: none; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.tag-list { |
||||
list-style: none; |
||||
padding: 0; |
||||
margin: 0; |
||||
} |
||||
|
||||
.tag-item { |
||||
border-bottom: 1px solid var(--border-color); |
||||
} |
||||
|
||||
.tag-item-button { |
||||
width: 100%; |
||||
padding: 0.75rem 1rem; |
||||
text-align: left; |
||||
background: transparent; |
||||
border: none; |
||||
cursor: pointer; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.25rem; |
||||
} |
||||
|
||||
.tag-item-button:hover { |
||||
background: var(--bg-secondary); |
||||
} |
||||
|
||||
.tag-item.selected .tag-item-button { |
||||
background: var(--bg-secondary); |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.tag-name { |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.tag-hash { |
||||
font-size: 0.85rem; |
||||
color: var(--text-muted); |
||||
font-family: monospace; |
||||
} |
||||
|
||||
.tag-date { |
||||
font-size: 0.8rem; |
||||
color: var(--text-muted); |
||||
} |
||||
|
||||
.tags-content { |
||||
flex: 1; |
||||
padding: 1rem; |
||||
overflow-y: auto; |
||||
} |
||||
|
||||
.tag-detail { |
||||
max-width: 800px; |
||||
} |
||||
|
||||
.tag-detail-header h3 { |
||||
margin: 0 0 0.5rem 0; |
||||
} |
||||
|
||||
.tag-detail-meta { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 1rem; |
||||
align-items: center; |
||||
margin-top: 0.5rem; |
||||
} |
||||
|
||||
.tag-message { |
||||
margin-top: 1rem; |
||||
padding: 1rem; |
||||
background: var(--bg-secondary); |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.tag-release-section { |
||||
margin-top: 1rem; |
||||
padding: 1rem; |
||||
background: var(--bg-secondary); |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.release-badge { |
||||
display: inline-block; |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 4px; |
||||
font-size: 0.85rem; |
||||
margin-right: 0.5rem; |
||||
} |
||||
|
||||
.release-badge.draft { |
||||
background: var(--warning-bg); |
||||
color: var(--warning-text); |
||||
} |
||||
|
||||
.release-badge.prerelease { |
||||
background: var(--info-bg); |
||||
color: var(--info-text); |
||||
} |
||||
|
||||
.download-tag-button { |
||||
display: inline-flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
padding: 0.5rem 1rem; |
||||
background: var(--button-primary); |
||||
color: var(--accent-text, #ffffff); |
||||
border: none; |
||||
border-radius: 4px; |
||||
text-decoration: none; |
||||
font-size: 0.9rem; |
||||
font-family: 'IBM Plex Serif', serif; |
||||
transition: background 0.2s ease; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.download-tag-button:hover { |
||||
background: var(--button-primary-hover); |
||||
} |
||||
|
||||
.download-tag-button .icon-inline { |
||||
width: 16px; |
||||
height: 16px; |
||||
} |
||||
|
||||
.tag-has-release-icon { |
||||
width: 16px; |
||||
height: 16px; |
||||
vertical-align: middle; |
||||
opacity: 0.8; |
||||
} |
||||
|
||||
.error-message { |
||||
color: var(--error-color, red); |
||||
font-size: 0.85rem; |
||||
margin-top: 0.5rem; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
|
||||
/** |
||||
* API client utilities for repository operations |
||||
* Provides centralized API call functions with error handling and logging |
||||
*/ |
||||
|
||||
import { get } from 'svelte/store'; |
||||
import { userStore } from '$lib/stores/user-store.js'; |
||||
import logger from '$lib/services/logger.js'; |
||||
|
||||
/** |
||||
* Builds API headers with user pubkey for authenticated requests |
||||
*/ |
||||
export function buildApiHeaders(): Record<string, string> { |
||||
const headers: Record<string, string> = {}; |
||||
const currentUser = get(userStore); |
||||
const currentUserPubkeyHex = currentUser?.userPubkeyHex; |
||||
if (currentUserPubkeyHex) { |
||||
headers['X-User-Pubkey'] = currentUserPubkeyHex; |
||||
logger.debug({ pubkey: currentUserPubkeyHex.substring(0, 16) + '...' }, '[API] Sending X-User-Pubkey header'); |
||||
} |
||||
return headers; |
||||
} |
||||
|
||||
/** |
||||
* Makes an API request with error handling and logging |
||||
*/ |
||||
export async function apiRequest<T>( |
||||
url: string, |
||||
options: RequestInit = {} |
||||
): Promise<T> { |
||||
const headers = { |
||||
...buildApiHeaders(), |
||||
...options.headers, |
||||
'Content-Type': 'application/json', |
||||
}; |
||||
|
||||
logger.debug({ url, method: options.method || 'GET' }, '[API] Making request'); |
||||
|
||||
try { |
||||
const response = await fetch(url, { |
||||
...options, |
||||
headers, |
||||
credentials: 'same-origin', |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
let errorMessage = `API request failed: ${response.status} ${response.statusText}`; |
||||
try { |
||||
const errorData = await response.json(); |
||||
if (errorData.message) { |
||||
errorMessage = errorData.message; |
||||
} else if (errorData.error) { |
||||
errorMessage = errorData.error; |
||||
} |
||||
} catch { |
||||
try { |
||||
const text = await response.text(); |
||||
if (text) { |
||||
errorMessage = text.substring(0, 200); |
||||
} |
||||
} catch { |
||||
// Ignore parsing errors
|
||||
} |
||||
} |
||||
logger.error({ url, status: response.status, error: errorMessage }, '[API] Request failed'); |
||||
throw new Error(errorMessage); |
||||
} |
||||
|
||||
const data = await response.json(); |
||||
logger.debug({ url }, '[API] Request successful'); |
||||
return data as T; |
||||
} catch (err) { |
||||
logger.error({ url, error: err }, '[API] Request error'); |
||||
throw err; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Makes a POST request |
||||
*/ |
||||
export async function apiPost<T>( |
||||
url: string, |
||||
body: unknown |
||||
): Promise<T> { |
||||
return apiRequest<T>(url, { |
||||
method: 'POST', |
||||
body: JSON.stringify(body), |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Makes a PUT request |
||||
*/ |
||||
export async function apiPut<T>( |
||||
url: string, |
||||
body: unknown |
||||
): Promise<T> { |
||||
return apiRequest<T>(url, { |
||||
method: 'PUT', |
||||
body: JSON.stringify(body), |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Makes a DELETE request |
||||
*/ |
||||
export async function apiDelete<T>(url: string): Promise<T> { |
||||
return apiRequest<T>(url, { |
||||
method: 'DELETE', |
||||
}); |
||||
} |
||||
@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
|
||||
/** |
||||
* Download utility for repository downloads |
||||
* Handles downloading repository archives with proper error handling and logging |
||||
*/ |
||||
|
||||
import { get } from 'svelte/store'; |
||||
import { userStore } from '$lib/stores/user-store.js'; |
||||
import logger from '$lib/services/logger.js'; |
||||
|
||||
interface DownloadOptions { |
||||
npub: string; |
||||
repo: string; |
||||
ref?: string; |
||||
filename?: string; |
||||
} |
||||
|
||||
let isDownloading = false; |
||||
|
||||
/** |
||||
* Builds API headers with user pubkey for authenticated requests |
||||
*/ |
||||
function buildApiHeaders(): Record<string, string> { |
||||
const headers: Record<string, string> = {}; |
||||
const currentUser = get(userStore); |
||||
const currentUserPubkeyHex = currentUser?.userPubkeyHex; |
||||
if (currentUserPubkeyHex) { |
||||
headers['X-User-Pubkey'] = currentUserPubkeyHex; |
||||
logger.debug({ pubkey: currentUserPubkeyHex.substring(0, 16) + '...' }, '[Download] Sending X-User-Pubkey header'); |
||||
} else { |
||||
logger.debug('[Download] No user pubkey available, sending request without X-User-Pubkey header'); |
||||
} |
||||
return headers; |
||||
} |
||||
|
||||
/** |
||||
* Downloads a repository archive (ZIP or TAR.GZ) |
||||
* @param options Download options including npub, repo, ref, and filename |
||||
* @returns Promise that resolves when download is initiated |
||||
*/ |
||||
export async function downloadRepository(options: DownloadOptions): Promise<void> { |
||||
const { npub, repo, ref, filename } = options; |
||||
|
||||
if (typeof window === 'undefined') { |
||||
logger.warn('[Download] Attempted download in SSR context'); |
||||
return; |
||||
} |
||||
|
||||
// Prevent multiple simultaneous downloads
|
||||
if (isDownloading) { |
||||
logger.debug('[Download] Download already in progress, skipping...'); |
||||
return; |
||||
} |
||||
isDownloading = true; |
||||
|
||||
// Prevent page navigation during download
|
||||
const preventReloadHandler = (e: BeforeUnloadEvent) => { |
||||
if (!isDownloading) { |
||||
return; |
||||
} |
||||
e.preventDefault(); |
||||
e.returnValue = ''; |
||||
return ''; |
||||
}; |
||||
|
||||
window.addEventListener('beforeunload', preventReloadHandler); |
||||
|
||||
try { |
||||
// Build download URL
|
||||
const params = new URLSearchParams(); |
||||
if (ref) { |
||||
params.set('ref', ref); |
||||
} |
||||
params.set('format', 'zip'); |
||||
const downloadUrl = `/api/repos/${npub}/${repo}/download?${params.toString()}`; |
||||
|
||||
logger.info({ url: downloadUrl, ref }, '[Download] Starting download'); |
||||
|
||||
// Fetch with proper headers
|
||||
const response = await fetch(downloadUrl, { |
||||
method: 'GET', |
||||
credentials: 'same-origin', |
||||
headers: buildApiHeaders() |
||||
}); |
||||
|
||||
logger.debug({ status: response.status, statusText: response.statusText }, '[Download] Response received'); |
||||
|
||||
if (!response.ok) { |
||||
// Try to get error message from response
|
||||
let errorMessage = `Download failed: ${response.status} ${response.statusText}`; |
||||
try { |
||||
const errorData = await response.json(); |
||||
if (errorData.message) { |
||||
errorMessage = errorData.message; |
||||
} else if (errorData.error) { |
||||
errorMessage = errorData.error; |
||||
} |
||||
} catch { |
||||
// If response is not JSON, use status text
|
||||
try { |
||||
const text = await response.text(); |
||||
if (text) { |
||||
errorMessage = text.substring(0, 200); // Limit length
|
||||
} |
||||
} catch { |
||||
// Ignore text parsing errors
|
||||
} |
||||
} |
||||
logger.error({ error: errorMessage, status: response.status }, '[Download] Download failed'); |
||||
throw new Error(errorMessage); |
||||
} |
||||
|
||||
// Check content type
|
||||
const contentType = response.headers.get('content-type'); |
||||
if (!contentType || (!contentType.includes('zip') && !contentType.includes('octet-stream'))) { |
||||
logger.warn({ contentType }, '[Download] Unexpected content type'); |
||||
} |
||||
|
||||
logger.debug('[Download] Converting to blob...'); |
||||
const blob = await response.blob(); |
||||
logger.debug({ size: blob.size }, '[Download] Blob created'); |
||||
|
||||
if (blob.size === 0) { |
||||
throw new Error('Downloaded file is empty'); |
||||
} |
||||
|
||||
// Use File System Access API if available (most reliable, no navigation)
|
||||
const downloadFileName = filename || `${repo}${ref ? `-${ref}` : ''}.zip`; |
||||
|
||||
if ('showSaveFilePicker' in window) { |
||||
try { |
||||
// @ts-ignore - File System Access API
|
||||
const fileHandle = await window.showSaveFilePicker({ |
||||
suggestedName: downloadFileName, |
||||
types: [{ |
||||
description: 'ZIP files', |
||||
accept: { 'application/zip': ['.zip'] } |
||||
}] |
||||
}); |
||||
|
||||
const writable = await fileHandle.createWritable(); |
||||
await writable.write(blob); |
||||
await writable.close(); |
||||
|
||||
logger.info('[Download] File saved using File System Access API'); |
||||
return; // Success, exit early - no navigation possible
|
||||
} catch (saveErr: any) { |
||||
// User cancelled or API not fully supported
|
||||
if (saveErr.name === 'AbortError') { |
||||
logger.debug('[Download] User cancelled file save'); |
||||
return; |
||||
} |
||||
logger.debug({ error: saveErr }, '[Download] File System Access API failed, using fallback'); |
||||
} |
||||
} |
||||
|
||||
// Use direct link method (more reliable, works with CSP)
|
||||
const url = window.URL.createObjectURL(blob); |
||||
logger.debug('[Download] Created blob URL, using direct link method'); |
||||
|
||||
// Create a temporary link element and trigger download
|
||||
const link = document.createElement('a'); |
||||
link.href = url; |
||||
link.download = downloadFileName; |
||||
link.style.display = 'none'; |
||||
link.setAttribute('download', downloadFileName); // Ensure download attribute is set
|
||||
|
||||
// Append to body temporarily
|
||||
document.body.appendChild(link); |
||||
|
||||
// Use requestAnimationFrame to ensure DOM is ready
|
||||
requestAnimationFrame(() => { |
||||
try { |
||||
// Trigger click
|
||||
link.click(); |
||||
logger.debug('[Download] Download triggered via direct link'); |
||||
|
||||
// Clean up after a short delay
|
||||
setTimeout(() => { |
||||
try { |
||||
if (link.parentNode) { |
||||
document.body.removeChild(link); |
||||
} |
||||
// Revoke blob URL after a delay to ensure download started
|
||||
setTimeout(() => { |
||||
window.URL.revokeObjectURL(url); |
||||
logger.debug('[Download] Cleaned up link and blob URL'); |
||||
}, 1000); |
||||
} catch (cleanupErr) { |
||||
logger.error({ error: cleanupErr }, '[Download] Cleanup error'); |
||||
// Still try to revoke the URL
|
||||
try { |
||||
window.URL.revokeObjectURL(url); |
||||
} catch (revokeErr) { |
||||
logger.error({ error: revokeErr }, '[Download] Failed to revoke blob URL'); |
||||
} |
||||
} |
||||
}, 100); |
||||
} catch (clickErr) { |
||||
logger.error({ error: clickErr }, '[Download] Error triggering download'); |
||||
// Clean up on error
|
||||
try { |
||||
if (link.parentNode) { |
||||
document.body.removeChild(link); |
||||
} |
||||
window.URL.revokeObjectURL(url); |
||||
} catch (cleanupErr) { |
||||
logger.error({ error: cleanupErr }, '[Download] Cleanup error after click failure'); |
||||
} |
||||
throw new Error('Failed to trigger download'); |
||||
} |
||||
}); |
||||
|
||||
logger.info('[Download] Download initiated successfully'); |
||||
} catch (err) { |
||||
logger.error({ error: err, npub, repo, ref }, '[Download] Download error'); |
||||
const errorMessage = err instanceof Error ? err.message : String(err); |
||||
// Show user-friendly error message
|
||||
alert(`Download failed: ${errorMessage}`); |
||||
// Don't re-throw - handle error gracefully to prevent page navigation issues
|
||||
} finally { |
||||
// Remove beforeunload listener
|
||||
try { |
||||
window.removeEventListener('beforeunload', preventReloadHandler); |
||||
} catch (removeErr) { |
||||
logger.warn({ error: removeErr }, '[Download] Error removing beforeunload listener'); |
||||
} |
||||
|
||||
// Reset download flag after a delay
|
||||
setTimeout(() => { |
||||
isDownloading = false; |
||||
}, 3000); // Longer delay to ensure download completed
|
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue