Browse Source
Nostr-Signature: eafa232557affbacb430b467507febc201f0a8f54f4b9ecf57e315c32e51a589 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 53c58aabe0bfad6e432a8bb980c2046fc14bc8163825fde2ac766a449ce4418adb1049ac732c7fc7ecc7ad050539fb68c023d54f2b6c390e478616b5c0b91a31main
9 changed files with 1590 additions and 551 deletions
@ -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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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