COI gate + admin review, mobile file viewer fixes for iOS
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s
- Integrate COI declaration dialog into jury evaluate page (blocks evaluation until declared) - Add COI review section to admin round page with clear/reassign/note actions - Fix mobile: remove inline preview (viewport too small), add labeled buttons - Fix iOS: open-in-new-tab uses synchronous window.open to avoid popup blocker - Fix iOS: download falls back to direct link if fetch+blob fails (CORS/Safari) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,8 @@ import {
|
|||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Search,
|
Search,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
ShieldAlert,
|
||||||
|
Eye,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -1755,6 +1757,9 @@ export default function RoundDetailPage() {
|
|||||||
{/* Individual Assignments Table */}
|
{/* Individual Assignments Table */}
|
||||||
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
||||||
|
|
||||||
|
{/* Conflict of Interest Declarations */}
|
||||||
|
<COIReviewSection roundId={roundId} />
|
||||||
|
|
||||||
{/* Unassigned Queue */}
|
{/* Unassigned Queue */}
|
||||||
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
||||||
|
|
||||||
@@ -3264,3 +3269,148 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── COI Review Section ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function COIReviewSection({ roundId }: { roundId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: declarations, isLoading } = trpc.evaluation.listCOIByStage.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 15_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.evaluation.listCOIByStage.invalidate({ roundId })
|
||||||
|
toast.success('COI review updated')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Don't show section if no declarations
|
||||||
|
if (!isLoading && (!declarations || declarations.length === 0)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
|
||||||
|
const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-4 w-4" />
|
||||||
|
Conflict of Interest Declarations
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
|
||||||
|
{conflictCount > 0 && (
|
||||||
|
<> — <span className="text-amber-600 font-medium">{conflictCount} conflict{conflictCount !== 1 ? 's' : ''}</span></>
|
||||||
|
)}
|
||||||
|
{unreviewedCount > 0 && (
|
||||||
|
<> ({unreviewedCount} pending review)</>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||||
|
<span>Juror</span>
|
||||||
|
<span>Project</span>
|
||||||
|
<span>Conflict</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Action</span>
|
||||||
|
</div>
|
||||||
|
{declarations?.map((coi: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={coi.id}
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||||
|
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
||||||
|
coi.hasConflict && !coi.reviewedAt && 'border-l-4 border-l-amber-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{coi.user?.name || coi.user?.email || 'Unknown'}</span>
|
||||||
|
<span className="truncate text-muted-foreground">{coi.assignment?.project?.title || 'Unknown'}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] justify-center',
|
||||||
|
coi.hasConflict
|
||||||
|
? 'bg-red-50 text-red-700 border-red-200'
|
||||||
|
: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{coi.hasConflict ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
{coi.hasConflict ? (coi.conflictType || 'Unspecified') : '\u2014'}
|
||||||
|
</span>
|
||||||
|
{coi.hasConflict ? (
|
||||||
|
coi.reviewedAt ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] justify-center',
|
||||||
|
coi.reviewAction === 'cleared'
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||||
|
: coi.reviewAction === 'reassigned'
|
||||||
|
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||||
|
: 'bg-gray-50 text-gray-600 border-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{coi.reviewAction === 'cleared' ? 'Cleared' : coi.reviewAction === 'reassigned' ? 'Reassigned' : 'Noted'}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs">
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'cleared' })}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 mr-2 text-emerald-600" />
|
||||||
|
Clear — no real conflict
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'reassigned' })}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
|
||||||
|
Reassign to another juror
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'noted' })}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 mr-2 text-gray-600" />
|
||||||
|
Note — keep as is
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2 } from 'lucide-react'
|
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
|
||||||
|
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||||
|
|
||||||
@@ -68,6 +69,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
{ enabled: !!myAssignment?.id }
|
{ enabled: !!myAssignment?.id }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// COI (Conflict of Interest) check
|
||||||
|
const { data: coiStatus, isLoading: coiLoading } = trpc.evaluation.getCOIStatus.useQuery(
|
||||||
|
{ assignmentId: myAssignment?.id ?? '' },
|
||||||
|
{ enabled: !!myAssignment?.id }
|
||||||
|
)
|
||||||
|
const [coiCompleted, setCOICompleted] = useState(false)
|
||||||
|
const [coiHasConflict, setCOIHasConflict] = useState(false)
|
||||||
|
|
||||||
// Fetch the active evaluation form for this round
|
// Fetch the active evaluation form for this round
|
||||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
@@ -385,6 +394,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COI config
|
||||||
|
const coiRequired = evalConfig?.coiRequired ?? true
|
||||||
|
|
||||||
|
// Determine COI state: declared via server or just completed in this session
|
||||||
|
const coiDeclared = coiCompleted || coiStatus !== undefined
|
||||||
|
const coiConflict = coiHasConflict || (coiStatus?.hasConflict ?? false)
|
||||||
|
|
||||||
// Check if round is active
|
// Check if round is active
|
||||||
const isRoundActive = round.status === 'ROUND_ACTIVE'
|
const isRoundActive = round.status === 'ROUND_ACTIVE'
|
||||||
|
|
||||||
@@ -421,6 +437,79 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COI gate: if COI is required, not yet declared, and we have an assignment
|
||||||
|
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||||
|
Evaluate Project
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<COIDeclarationDialog
|
||||||
|
open={true}
|
||||||
|
assignmentId={myAssignment.id}
|
||||||
|
projectTitle={project.title}
|
||||||
|
onComplete={(hasConflict) => {
|
||||||
|
setCOICompleted(true)
|
||||||
|
setCOIHasConflict(hasConflict)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// COI conflict declared — block evaluation
|
||||||
|
if (coiRequired && coiConflict) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||||
|
Evaluate Project
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
|
<CardContent className="flex items-start gap-4 p-6">
|
||||||
|
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
|
||||||
|
<ShieldAlert className="h-6 w-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Conflict of Interest Declared</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
You declared a conflict of interest for this project. An administrator will
|
||||||
|
review your declaration. You cannot evaluate this project while the conflict
|
||||||
|
is under review.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-4" asChild>
|
||||||
|
<Link href={`/jury/competitions/${roundId}` as Route}>
|
||||||
|
Back to Round
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -333,36 +333,16 @@ function FileItem({ file }: { file: ProjectFile }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile action buttons — visible only on small screens */}
|
{/* Mobile action buttons — no inline preview on mobile, just open + download */}
|
||||||
<div className="flex md:hidden items-center gap-2 mt-3 pt-3 border-t">
|
<div className="flex md:hidden items-center gap-2 mt-3 pt-3 border-t">
|
||||||
{canPreview && (
|
<FileOpenButton file={file} className="flex-1" label="Open in New Tab" />
|
||||||
<Button
|
<FileDownloadButton file={file} className="flex-1" label="Download" />
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setShowPreview(!showPreview)}
|
|
||||||
>
|
|
||||||
{showPreview ? (
|
|
||||||
<>
|
|
||||||
<X className="mr-1.5 h-4 w-4" />
|
|
||||||
Close
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="mr-1.5 h-4 w-4" />
|
|
||||||
Preview
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<FileOpenButton file={file} />
|
|
||||||
<FileDownloadButton file={file} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview area */}
|
{/* Preview area — desktop only */}
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div className="rounded-lg border bg-muted/50 overflow-hidden">
|
<div className="hidden md:block rounded-lg border bg-muted/50 overflow-hidden">
|
||||||
{isLoadingUrl ? (
|
{isLoadingUrl ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
@@ -561,7 +541,7 @@ function BulkDownloadButton({ projectId, fileIds }: { projectId: string; fileIds
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileOpenButton({ file }: { file: ProjectFile }) {
|
function FileOpenButton({ file, className, label }: { file: ProjectFile; className?: string; label?: string }) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
||||||
@@ -571,13 +551,25 @@ function FileOpenButton({ file }: { file: ProjectFile }) {
|
|||||||
|
|
||||||
const handleOpen = async () => {
|
const handleOpen = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
// Open the blank window immediately in the click handler (synchronous)
|
||||||
|
// so iOS Safari doesn't block it as a popup
|
||||||
|
const newWindow = window.open('about:blank', '_blank')
|
||||||
try {
|
try {
|
||||||
const result = await refetch()
|
const result = await refetch()
|
||||||
if (result.data?.url) {
|
if (result.data?.url) {
|
||||||
window.open(result.data.url, '_blank')
|
if (newWindow) {
|
||||||
|
newWindow.location.href = result.data.url
|
||||||
|
} else {
|
||||||
|
// Fallback: if popup was still blocked, navigate current tab
|
||||||
|
window.location.href = result.data.url
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newWindow?.close()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get URL:', error)
|
console.error('Failed to get URL:', error)
|
||||||
|
newWindow?.close()
|
||||||
|
toast.error('Failed to open file')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -590,17 +582,19 @@ function FileOpenButton({ file }: { file: ProjectFile }) {
|
|||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label="Open file in new tab"
|
aria-label="Open file in new tab"
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className={cn('h-4 w-4 animate-spin', label && 'mr-1.5')} />
|
||||||
) : (
|
) : (
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className={cn('h-4 w-4', label && 'mr-1.5')} />
|
||||||
)}
|
)}
|
||||||
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileDownloadButton({ file }: { file: ProjectFile }) {
|
function FileDownloadButton({ file, className, label }: { file: ProjectFile; className?: string; label?: string }) {
|
||||||
const [downloading, setDownloading] = useState(false)
|
const [downloading, setDownloading] = useState(false)
|
||||||
|
|
||||||
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
||||||
@@ -613,17 +607,36 @@ function FileDownloadButton({ file }: { file: ProjectFile }) {
|
|||||||
try {
|
try {
|
||||||
const result = await refetch()
|
const result = await refetch()
|
||||||
if (result.data?.url) {
|
if (result.data?.url) {
|
||||||
// Fetch as blob to force download (cross-origin URLs ignore <a download>)
|
// Try fetch+blob first (works on desktop, some mobile browsers)
|
||||||
|
try {
|
||||||
const response = await fetch(result.data.url)
|
const response = await fetch(result.data.url)
|
||||||
|
if (!response.ok) throw new Error('fetch failed')
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = blobUrl
|
link.href = blobUrl
|
||||||
link.download = file.fileName
|
link.download = file.fileName
|
||||||
|
// iOS Safari needs the link in the DOM
|
||||||
|
link.style.display = 'none'
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
|
// Delay cleanup for iOS
|
||||||
|
setTimeout(() => {
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
URL.revokeObjectURL(blobUrl)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to download file:', error)
|
console.error('Failed to download file:', error)
|
||||||
@@ -640,12 +653,14 @@ function FileDownloadButton({ file }: { file: ProjectFile }) {
|
|||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
aria-label="Download file"
|
aria-label="Download file"
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
{downloading ? (
|
{downloading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className={cn('h-4 w-4 animate-spin', label && 'mr-1.5')} />
|
||||||
) : (
|
) : (
|
||||||
<Download className="h-4 w-4" />
|
<Download className={cn('h-4 w-4', label && 'mr-1.5')} />
|
||||||
)}
|
)}
|
||||||
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user