Skip to content

Commit 7cf4269

Browse files
SgtPookibarbarapericCharlyMartin
authored
fix: show error on upload failure (#71)
* fix: show upload error and require user interaction * fix: upload while waiting on dataset, and error show * fix: use existing components in upload-error.tsx * refactor: enhance Alert component and integrate into UploadError * refactor(ui): enhance Alert and Button components with size variants and improved styling - Split button variants in Alert for better maintainability and added secondary styles - Introduced size prop and unstyled variant in ButtonBase for flexibility - Updated icon type and refactored button rendering for consistency and reusability --------- Co-authored-by: Barbara Peric <[email protected]> Co-authored-by: Charly Martin <[email protected]>
1 parent 970ffe9 commit 7cf4269

File tree

6 files changed

+223
-84
lines changed

6 files changed

+223
-84
lines changed

src/components/layout/content.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Heading } from '../ui/heading.tsx'
1111
import { LoadingState } from '../ui/loading-state.tsx'
1212
import { PageTitle } from '../ui/page-title.tsx'
1313
import DragNDrop from '../upload/drag-n-drop.tsx'
14+
import { UploadError } from '../upload/upload-error.tsx'
1415
import { UploadStatus } from '../upload/upload-status.tsx'
1516

1617
// Completed state for displaying upload history
@@ -87,6 +88,10 @@ export default function Content() {
8788
{showActiveUpload && uploadedFile && (
8889
<div className="space-y-6">
8990
<Heading tag="h2">Current upload</Heading>
91+
92+
{/* Show error alert if upload failed */}
93+
<UploadError orchestration={orchestration} />
94+
9095
<UploadStatus
9196
cid={activeUpload.currentCid}
9297
fileName={uploadedFile.file.name}

src/components/ui/alert.tsx

Lines changed: 116 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,137 @@
1+
import { cva, type VariantProps } from 'class-variance-authority'
12
import { clsx } from 'clsx'
2-
import { AlertTriangle, CircleAlert, CircleCheck } from 'lucide-react'
3+
import { AlertTriangle, CircleAlert, CircleCheck, Info, type LucideIcon } from 'lucide-react'
4+
import { ButtonBase } from '@/components/ui/button/button-base.tsx'
35

4-
const variantConfig = {
5-
success: {
6-
containerClass: 'bg-green-950/60 border border-green-900/40',
7-
textClass: 'text-green-300',
8-
iconClass: 'text-green-300',
9-
Icon: CircleCheck,
10-
buttonClass: 'bg-green-700 hover:bg-green-600 text-green-50',
6+
const alertVariants = cva('flex items-center gap-3 p-4 rounded-xl border', {
7+
variants: {
8+
variant: {
9+
success: 'bg-green-950/60 border-green-900/40 text-green-200',
10+
error: 'bg-red-950/60 border-red-900/40 text-red-300',
11+
info: 'bg-brand-950/60 border-brand-900/40 text-brand-400',
12+
warning: 'bg-yellow-600/30 border-yellow-400/20 text-yellow-100',
13+
neutral: 'bg-zinc-900 border-zinc-700/40 text-zinc-100',
14+
},
1115
},
12-
error: {
13-
containerClass: 'bg-red-950/60 border border-red-900/40',
14-
textClass: 'text-red-300',
15-
iconClass: 'text-red-300',
16-
Icon: AlertTriangle,
17-
buttonClass: 'bg-red-700 hover:bg-red-600 text-red-50',
16+
defaultVariants: {
17+
variant: 'neutral',
1818
},
19-
info: {
20-
containerClass: 'bg-brand-950/60 border border-brand-900/40',
21-
textClass: 'text-brand-500',
22-
iconClass: 'text-brand-500',
23-
Icon: CircleCheck,
24-
buttonClass: 'bg-brand-700 hover:bg-brand-600 text-brand-50',
19+
})
20+
21+
const messageVariants = cva('text-base', {
22+
variants: {
23+
variant: {
24+
success: 'text-green-300',
25+
error: 'text-red-400',
26+
info: 'text-brand-500',
27+
warning: 'text-yellow-200',
28+
neutral: 'text-zinc-100',
29+
},
2530
},
26-
warning: {
27-
containerClass: 'bg-yellow-600/30 border border-yellow-400/20',
28-
textClass: 'text-yellow-200',
29-
iconClass: 'text-yellow-200',
30-
Icon: CircleAlert,
31-
buttonClass: 'bg-yellow-700 hover:bg-yellow-600 text-white',
31+
})
32+
33+
const descriptionVariants = cva('', {
34+
variants: {
35+
variant: {
36+
success: 'text-green-200',
37+
error: 'text-red-300',
38+
info: 'text-brand-400',
39+
warning: 'text-yellow-100',
40+
neutral: 'text-zinc-200',
41+
},
3242
},
33-
neutral: {
34-
containerClass: 'bg-zinc-900 border border-zinc-700/40',
35-
textClass: 'text-zinc-100',
36-
iconClass: 'text-zinc-400',
37-
Icon: CircleAlert,
38-
buttonClass: 'bg-zinc-700 hover:bg-zinc-600 text-zinc-100',
43+
})
44+
45+
const iconVariants = cva('', {
46+
variants: {
47+
variant: {
48+
success: 'text-green-300',
49+
error: 'text-red-400',
50+
info: 'text-brand-500',
51+
warning: 'text-yellow-200',
52+
neutral: 'text-zinc-400',
53+
},
3954
},
40-
}
55+
})
56+
57+
const sharedButtonStyle = 'w-fit flex-shrink-0'
58+
59+
const primaryButtonVariants = cva(sharedButtonStyle, {
60+
variants: {
61+
variant: {
62+
success: 'bg-green-700 hover:bg-green-600 text-green-50',
63+
error: 'bg-red-700 hover:bg-red-600 text-red-50',
64+
info: 'bg-brand-700 hover:bg-brand-600 text-brand-50',
65+
warning: 'bg-yellow-700 hover:bg-yellow-600 text-white',
66+
neutral: 'bg-zinc-700 hover:bg-zinc-600 text-zinc-100',
67+
},
68+
},
69+
})
70+
71+
const secondaryButtonVariants = cva(sharedButtonStyle, {
72+
variants: {
73+
variant: {
74+
success: 'hover:bg-green-950/90 border border-green-700 text-green-500',
75+
error: 'hover:bg-red-950/90 border border-red-700 text-red-500',
76+
info: 'hover:bg-brand-950/90 border border-brand-700 text-brand-500',
77+
warning: 'hover:bg-yellow-950/90 border border-yellow-700 text-yellow-500',
78+
neutral: 'hover:bg-zinc-950/90 border border-zinc-600 text-zinc-400',
79+
},
80+
},
81+
})
82+
83+
export type AlertVariant = NonNullable<VariantProps<typeof alertVariants>['variant']>
4184

42-
export type AlertVariant = keyof typeof variantConfig
85+
type ButtonType = {
86+
children: React.ReactNode
87+
onClick?: React.ComponentProps<'button'>['onClick']
88+
}
4389

4490
type AlertProps = {
45-
variant: AlertVariant
91+
variant?: AlertVariant
4692
message: string
47-
button?: {
48-
children: string
49-
onClick: React.ComponentProps<'button'>['onClick']
50-
}
93+
description?: string
94+
button?: ButtonType
95+
cancelButton?: ButtonType
96+
}
97+
98+
const ICONS: Record<AlertVariant, LucideIcon> = {
99+
success: CircleCheck,
100+
error: AlertTriangle,
101+
info: Info,
102+
warning: CircleAlert,
103+
neutral: CircleAlert,
51104
}
52105

53-
function Alert({ variant, message, button }: AlertProps) {
54-
const { containerClass, textClass, iconClass, buttonClass, Icon } = variantConfig[variant]
106+
export function Alert({ variant = 'neutral', message, description, button, cancelButton }: AlertProps) {
107+
const Icon = ICONS[variant]
55108

56109
return (
57-
<div className={clsx(containerClass, 'flex items-center gap-3 p-4 rounded-xl')} role="alert">
58-
<span aria-hidden="true" className={iconClass}>
110+
<div className={alertVariants({ variant })} role="alert">
111+
<span aria-hidden="true" className={iconVariants({ variant })}>
59112
<Icon size={22} />
60113
</span>
61-
<span className={clsx(textClass, 'flex-1')}>{message}</span>
62-
{button && (
63-
<button
64-
className={clsx(buttonClass, 'px-4 py-2 rounded-lg font-medium flex-shrink-0 cursor-pointer')}
65-
type="button"
66-
{...button}
67-
/>
114+
115+
<div className="flex-1 flex flex-col gap-0.5">
116+
<span className={clsx(messageVariants({ variant }), description && 'font-semibold')}>{message}</span>
117+
{description && <span className={descriptionVariants({ variant })}>{description}</span>}
118+
</div>
119+
120+
{(button || cancelButton) && (
121+
<div className="flex gap-3">
122+
{cancelButton && (
123+
<ButtonBase
124+
{...cancelButton}
125+
className={secondaryButtonVariants({ variant })}
126+
size="sm"
127+
variant="unstyled"
128+
/>
129+
)}
130+
{button && (
131+
<ButtonBase {...button} className={primaryButtonVariants({ variant })} size="sm" variant="unstyled" />
132+
)}
133+
</div>
68134
)}
69135
</div>
70136
)
71137
}
72-
73-
export { Alert }

src/components/ui/button/button-base.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ import { cva, type VariantProps } from 'class-variance-authority'
22
import { cn } from '@/utils/cn.ts'
33

44
const buttonVariants = cva(
5-
'inline-flex items-center justify-center font-medium px-5 py-3 rounded-md transition-colors w-full hover:opacity-90',
5+
'inline-flex items-center justify-center font-medium transition-colors w-full cursor-pointer',
66
{
77
variants: {
88
variant: {
9-
primary: 'bg-brand-800 text-zinc-100 border border-transparent disabled:bg-button-brand-disabled',
9+
primary:
10+
'bg-brand-800 text-zinc-100 border border-transparent disabled:bg-button-brand-disabled hover:bg-brand-700',
1011
secondary: 'bg-transparent text-zinc-100 border border-zinc-800 hover:bg-zinc-800',
12+
unstyled: '',
13+
},
14+
size: {
15+
sm: 'text-sm px-4 py-2 rounded-md',
16+
md: 'text-base px-5 py-3 rounded-lg',
1117
},
1218
loading: {
1319
true: 'cursor-wait',
@@ -19,6 +25,7 @@ const buttonVariants = cva(
1925
},
2026
},
2127
defaultVariants: {
28+
size: 'md',
2229
variant: 'primary',
2330
loading: false,
2431
disabled: false,
@@ -31,10 +38,10 @@ type ButtonBaseProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
3138
loading?: boolean
3239
}
3340

34-
function ButtonBase({ className, variant, loading, children, disabled, ...props }: ButtonBaseProps) {
41+
function ButtonBase({ className, variant, loading, children, disabled, size = 'md', ...props }: ButtonBaseProps) {
3542
return (
3643
<button
37-
className={cn(buttonVariants({ variant, loading, disabled, className }))}
44+
className={cn(buttonVariants({ variant, loading, disabled, className, size }))}
3845
disabled={disabled || loading}
3946
{...props}
4047
>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { useUploadOrchestration } from '../../hooks/use-upload-orchestration.ts'
2+
import { Alert } from '../ui/alert.tsx'
3+
4+
interface UploadErrorProps {
5+
orchestration: ReturnType<typeof useUploadOrchestration>
6+
}
7+
8+
function UploadError({ orchestration }: UploadErrorProps) {
9+
const { activeUpload, uploadedFile, retryUpload, cancelUpload } = orchestration
10+
11+
if (!activeUpload.error) {
12+
return null
13+
}
14+
15+
return (
16+
<Alert
17+
button={{ children: 'Retry Upload', onClick: retryUpload }}
18+
cancelButton={{ children: 'Cancel', onClick: cancelUpload }}
19+
description={activeUpload.error}
20+
message={`Upload failed - ${uploadedFile?.file.name}`}
21+
variant="error"
22+
/>
23+
)
24+
}
25+
26+
export { UploadError }

src/hooks/use-filecoin-upload.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createCarFromFile } from 'filecoin-pin/core/unixfs'
22
import { checkUploadReadiness, executeUpload } from 'filecoin-pin/core/upload'
33
import pino from 'pino'
4-
import { useCallback, useMemo, useState } from 'react'
4+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
55
import type { Progress } from '../types/upload-progress.ts'
66
import { formatFileSize } from '../utils/format-file-size.ts'
77
import { useFilecoinPinContext } from './use-filecoin-pin-context.ts'
@@ -51,7 +51,17 @@ export const INPI_ERROR_MESSAGE =
5151
* actions so they stay dumb and declarative.
5252
*/
5353
export const useFilecoinUpload = () => {
54-
const { synapse, storageContext, providerInfo } = useFilecoinPinContext()
54+
const { synapse, storageContext, providerInfo, ensureDataSet } = useFilecoinPinContext()
55+
56+
// Use refs to track the latest context values, so the upload callback can access them
57+
// even if the dataset is initialized after the callback is created
58+
const storageContextRef = useRef(storageContext)
59+
const providerInfoRef = useRef(providerInfo)
60+
61+
useEffect(() => {
62+
storageContextRef.current = storageContext
63+
providerInfoRef.current = providerInfo
64+
}, [storageContext, providerInfo])
5565

5666
const [uploadState, setUploadState] = useState<UploadState>({
5767
isUploading: false,
@@ -149,20 +159,27 @@ export const useFilecoinUpload = () => {
149159
},
150160
})
151161

152-
// Ensure we have storage context from provider (created during data set initialization)
153-
if (!storageContext || !providerInfo) {
154-
// This should never happen because the upload button is disabled if the data set is not ready
155-
throw new Error('Storage context not ready. Please ensure a data set is initialized before uploading.')
162+
// Ensure we have a data set ready before uploading
163+
console.debug('[FilecoinUpload] Ensuring data set is ready before upload...')
164+
await ensureDataSet()
165+
166+
// Get the latest storage context and provider info from refs
167+
// (these may have been updated by ensureDataSet if dataset wasn't ready)
168+
const currentStorageContext = storageContextRef.current
169+
const currentProviderInfo = providerInfoRef.current
170+
171+
if (!currentStorageContext || !currentProviderInfo) {
172+
throw new Error('Storage context not ready. Failed to initialize data set. Please try again.')
156173
}
157174

158175
console.debug('[FilecoinUpload] Using storage context from provider:', {
159-
providerInfo,
160-
dataSetId: storageContext.dataSetId,
176+
providerInfo: currentProviderInfo,
177+
dataSetId: currentStorageContext.dataSetId,
161178
})
162179

163180
const synapseService = {
164-
storage: storageContext,
165-
providerInfo,
181+
storage: currentStorageContext,
182+
providerInfo: currentProviderInfo,
166183
synapse,
167184
}
168185

@@ -226,7 +243,7 @@ export const useFilecoinUpload = () => {
226243
}))
227244
}
228245
},
229-
[updateProgress, synapse, storageContext, providerInfo]
246+
[updateProgress, synapse, ensureDataSet]
230247
)
231248

232249
const resetUpload = useCallback(() => {

0 commit comments

Comments
 (0)