diff --git a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx index 361561c..ccdd869 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx @@ -398,7 +398,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { const coiRequired = evalConfig?.coiRequired ?? true // Determine COI state: declared via server or just completed in this session - const coiDeclared = coiCompleted || coiStatus !== undefined + // coiStatus is null when no COI record exists, truthy when declared + const coiDeclared = coiCompleted || (coiStatus != null) const coiConflict = coiHasConflict || (coiStatus?.hasConflict ?? false) // Check if round is active diff --git a/src/components/shared/file-viewer.tsx b/src/components/shared/file-viewer.tsx index 4f12f60..43c60d5 100644 --- a/src/components/shared/file-viewer.tsx +++ b/src/components/shared/file-viewer.tsx @@ -490,28 +490,21 @@ function BulkDownloadButton({ projectId, fileIds }: { projectId: string; fileIds try { const result = await refetch() if (result.data && Array.isArray(result.data)) { - // Download each file via fetch+blob to handle cross-origin URLs + // Each URL already has Content-Disposition: attachment from the server, + // so we can trigger native downloads by navigating to each URL. + // Use hidden iframes to download multiple files without navigating away. for (let i = 0; i < result.data.length; i++) { const item = result.data[i] as { downloadUrl: string; fileName?: string } if (item.downloadUrl) { - try { - const response = await fetch(item.downloadUrl) - const blob = await response.blob() - const blobUrl = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = blobUrl - link.download = item.fileName || `file-${i + 1}` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(blobUrl) - } catch { - // Fall back to opening in new tab if fetch fails - window.open(item.downloadUrl, '_blank') - } - // Small delay between downloads + const iframe = document.createElement('iframe') + iframe.style.display = 'none' + iframe.src = item.downloadUrl + document.body.appendChild(iframe) + // Clean up iframe after download starts + setTimeout(() => document.body.removeChild(iframe), 10_000) + // Small delay between downloads to avoid browser throttling if (i < result.data.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 800)) } } } @@ -520,7 +513,7 @@ function BulkDownloadButton({ projectId, fileIds }: { projectId: string; fileIds } catch { toast.error('Failed to download files') } finally { - setDownloading(false) + setTimeout(() => setDownloading(false), 1000) } } @@ -598,7 +591,7 @@ function FileDownloadButton({ file, className, label }: { file: ProjectFile; cla const [downloading, setDownloading] = useState(false) const { refetch } = trpc.file.getDownloadUrl.useQuery( - { bucket: file.bucket, objectKey: file.objectKey }, + { bucket: file.bucket, objectKey: file.objectKey, forDownload: true, fileName: file.fileName }, { enabled: false } ) @@ -607,42 +600,18 @@ function FileDownloadButton({ file, className, label }: { file: ProjectFile; cla try { const result = await refetch() if (result.data?.url) { - // Try fetch+blob first (works on desktop, some mobile browsers) - try { - const response = await fetch(result.data.url) - if (!response.ok) throw new Error('fetch failed') - const blob = await response.blob() - const blobUrl = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = blobUrl - link.download = file.fileName - // iOS Safari needs the link in the DOM - link.style.display = 'none' - document.body.appendChild(link) - link.click() - // Delay cleanup for iOS - setTimeout(() => { - document.body.removeChild(link) - URL.revokeObjectURL(blobUrl) - }, 100) - } catch { - // Fallback: open in new tab (iOS Safari often blocks blob downloads) - // Open synchronously via a link click to avoid popup blockers - const link = document.createElement('a') - link.href = result.data.url - link.target = '_blank' - link.rel = 'noopener noreferrer' - link.style.display = 'none' - document.body.appendChild(link) - link.click() - setTimeout(() => document.body.removeChild(link), 100) - } + // The presigned URL includes Content-Disposition: attachment header, + // so the browser will natively download the file (works on iOS Safari too). + // Use window.location.href for same-tab navigation which triggers download + // without popup blockers interfering. + window.location.href = result.data.url } } catch (error) { console.error('Failed to download file:', error) toast.error('Failed to download file') } finally { - setDownloading(false) + // Small delay before clearing state so the button doesn't flash + setTimeout(() => setDownloading(false), 1000) } } diff --git a/src/lib/minio.ts b/src/lib/minio.ts index 6518f7f..e32e7ab 100644 --- a/src/lib/minio.ts +++ b/src/lib/minio.ts @@ -84,11 +84,17 @@ export async function getPresignedUrl( bucket: string, objectKey: string, method: 'GET' | 'PUT' = 'GET', - expirySeconds: number = 900 // 15 minutes default + expirySeconds: number = 900, // 15 minutes default + options?: { downloadFileName?: string } ): Promise { let url: string if (method === 'GET') { - url = await minio.presignedGetObject(bucket, objectKey, expirySeconds) + // If downloadFileName is set, include Content-Disposition: attachment header + // so the browser triggers a native download (works on iOS Safari, all browsers) + const respHeaders = options?.downloadFileName + ? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` } + : undefined + url = await minio.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders) } else { url = await minio.presignedPutObject(bucket, objectKey, expirySeconds) } diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts index dbcc7f9..e3d7d9a 100644 --- a/src/server/routers/file.ts +++ b/src/server/routers/file.ts @@ -15,6 +15,8 @@ export const fileRouter = router({ z.object({ bucket: z.string(), objectKey: z.string(), + forDownload: z.boolean().optional(), + fileName: z.string().optional(), }) ) .query(async ({ ctx, input }) => { @@ -109,7 +111,9 @@ export const fileRouter = router({ } } - const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min + const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900, + input.forDownload ? { downloadFileName: input.fileName || input.objectKey.split('/').pop() || 'download' } : undefined + ) // 15 min // Log file access await logAudit({ @@ -699,7 +703,9 @@ export const fileRouter = router({ // Generate signed URLs for each file const results = await Promise.all( files.map(async (file) => { - const downloadUrl = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900) + const downloadUrl = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900, + { downloadFileName: file.fileName } + ) return { fileId: file.id, fileName: file.fileName,