Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/components/layout/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Heading } from '../ui/heading.tsx'
import { LoadingState } from '../ui/loading-state.tsx'
import { PageTitle } from '../ui/page-title.tsx'
import DragNDrop from '../upload/drag-n-drop.tsx'
import { UploadError } from '../upload/upload-error.tsx'
import { UploadStatus } from '../upload/upload-status.tsx'

// Completed state for displaying upload history
Expand Down Expand Up @@ -87,6 +88,10 @@ export default function Content() {
{showActiveUpload && uploadedFile && (
<div className="space-y-6">
<Heading tag="h2">Current upload</Heading>

{/* Show error alert if upload failed */}
<UploadError orchestration={orchestration} />

<UploadStatus
cid={activeUpload.currentCid}
fileName={uploadedFile.file.name}
Expand Down
168 changes: 116 additions & 52 deletions src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
@@ -1,73 +1,137 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { clsx } from 'clsx'
import { AlertTriangle, CircleAlert, CircleCheck } from 'lucide-react'
import { AlertTriangle, CircleAlert, CircleCheck, Info, type LucideIcon } from 'lucide-react'
import { ButtonBase } from '@/components/ui/button/button-base.tsx'

const variantConfig = {
success: {
containerClass: 'bg-green-950/60 border border-green-900/40',
textClass: 'text-green-300',
iconClass: 'text-green-300',
Icon: CircleCheck,
buttonClass: 'bg-green-700 hover:bg-green-600 text-green-50',
const alertVariants = cva('flex items-center gap-3 p-4 rounded-xl border', {
variants: {
variant: {
success: 'bg-green-950/60 border-green-900/40 text-green-200',
error: 'bg-red-950/60 border-red-900/40 text-red-300',
info: 'bg-brand-950/60 border-brand-900/40 text-brand-400',
warning: 'bg-yellow-600/30 border-yellow-400/20 text-yellow-100',
neutral: 'bg-zinc-900 border-zinc-700/40 text-zinc-100',
},
},
error: {
containerClass: 'bg-red-950/60 border border-red-900/40',
textClass: 'text-red-300',
iconClass: 'text-red-300',
Icon: AlertTriangle,
buttonClass: 'bg-red-700 hover:bg-red-600 text-red-50',
defaultVariants: {
variant: 'neutral',
},
info: {
containerClass: 'bg-brand-950/60 border border-brand-900/40',
textClass: 'text-brand-500',
iconClass: 'text-brand-500',
Icon: CircleCheck,
buttonClass: 'bg-brand-700 hover:bg-brand-600 text-brand-50',
})

const messageVariants = cva('text-base', {
variants: {
variant: {
success: 'text-green-300',
error: 'text-red-400',
info: 'text-brand-500',
warning: 'text-yellow-200',
neutral: 'text-zinc-100',
},
},
warning: {
containerClass: 'bg-yellow-600/30 border border-yellow-400/20',
textClass: 'text-yellow-200',
iconClass: 'text-yellow-200',
Icon: CircleAlert,
buttonClass: 'bg-yellow-700 hover:bg-yellow-600 text-white',
})

const descriptionVariants = cva('', {
variants: {
variant: {
success: 'text-green-200',
error: 'text-red-300',
info: 'text-brand-400',
warning: 'text-yellow-100',
neutral: 'text-zinc-200',
},
},
neutral: {
containerClass: 'bg-zinc-900 border border-zinc-700/40',
textClass: 'text-zinc-100',
iconClass: 'text-zinc-400',
Icon: CircleAlert,
buttonClass: 'bg-zinc-700 hover:bg-zinc-600 text-zinc-100',
})

const iconVariants = cva('', {
variants: {
variant: {
success: 'text-green-300',
error: 'text-red-400',
info: 'text-brand-500',
warning: 'text-yellow-200',
neutral: 'text-zinc-400',
},
},
}
})

const sharedButtonStyle = 'w-fit flex-shrink-0'

const primaryButtonVariants = cva(sharedButtonStyle, {
variants: {
variant: {
success: 'bg-green-700 hover:bg-green-600 text-green-50',
error: 'bg-red-700 hover:bg-red-600 text-red-50',
info: 'bg-brand-700 hover:bg-brand-600 text-brand-50',
warning: 'bg-yellow-700 hover:bg-yellow-600 text-white',
neutral: 'bg-zinc-700 hover:bg-zinc-600 text-zinc-100',
},
},
})

const secondaryButtonVariants = cva(sharedButtonStyle, {
variants: {
variant: {
success: 'hover:bg-green-950/90 border border-green-700 text-green-500',
error: 'hover:bg-red-950/90 border border-red-700 text-red-500',
info: 'hover:bg-brand-950/90 border border-brand-700 text-brand-500',
warning: 'hover:bg-yellow-950/90 border border-yellow-700 text-yellow-500',
neutral: 'hover:bg-zinc-950/90 border border-zinc-600 text-zinc-400',
},
},
})

export type AlertVariant = NonNullable<VariantProps<typeof alertVariants>['variant']>

export type AlertVariant = keyof typeof variantConfig
type ButtonType = {
children: React.ReactNode
onClick?: React.ComponentProps<'button'>['onClick']
}

type AlertProps = {
variant: AlertVariant
variant?: AlertVariant
message: string
button?: {
children: string
onClick: React.ComponentProps<'button'>['onClick']
}
description?: string
button?: ButtonType
cancelButton?: ButtonType
}

const ICONS: Record<AlertVariant, LucideIcon> = {
success: CircleCheck,
error: AlertTriangle,
info: Info,
warning: CircleAlert,
neutral: CircleAlert,
}

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

return (
<div className={clsx(containerClass, 'flex items-center gap-3 p-4 rounded-xl')} role="alert">
<span aria-hidden="true" className={iconClass}>
<div className={alertVariants({ variant })} role="alert">
<span aria-hidden="true" className={iconVariants({ variant })}>
<Icon size={22} />
</span>
<span className={clsx(textClass, 'flex-1')}>{message}</span>
{button && (
<button
className={clsx(buttonClass, 'px-4 py-2 rounded-lg font-medium flex-shrink-0 cursor-pointer')}
type="button"
{...button}
/>

<div className="flex-1 flex flex-col gap-0.5">
<span className={clsx(messageVariants({ variant }), description && 'font-semibold')}>{message}</span>
{description && <span className={descriptionVariants({ variant })}>{description}</span>}
</div>

{(button || cancelButton) && (
<div className="flex gap-3">
{cancelButton && (
<ButtonBase
{...cancelButton}
className={secondaryButtonVariants({ variant })}
size="sm"
variant="unstyled"
/>
)}
{button && (
<ButtonBase {...button} className={primaryButtonVariants({ variant })} size="sm" variant="unstyled" />
)}
</div>
)}
</div>
)
}

export { Alert }
15 changes: 11 additions & 4 deletions src/components/ui/button/button-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/utils/cn.ts'

const buttonVariants = cva(
'inline-flex items-center justify-center font-medium px-5 py-3 rounded-md transition-colors w-full hover:opacity-90',
'inline-flex items-center justify-center font-medium transition-colors w-full cursor-pointer',
{
variants: {
variant: {
primary: 'bg-brand-800 text-zinc-100 border border-transparent disabled:bg-button-brand-disabled',
primary:
'bg-brand-800 text-zinc-100 border border-transparent disabled:bg-button-brand-disabled hover:bg-brand-700',
secondary: 'bg-transparent text-zinc-100 border border-zinc-800 hover:bg-zinc-800',
unstyled: '',
},
size: {
sm: 'text-sm px-4 py-2 rounded-md',
md: 'text-base px-5 py-3 rounded-lg',
},
loading: {
true: 'cursor-wait',
Expand All @@ -19,6 +25,7 @@ const buttonVariants = cva(
},
},
defaultVariants: {
size: 'md',
variant: 'primary',
loading: false,
disabled: false,
Expand All @@ -31,10 +38,10 @@ type ButtonBaseProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
loading?: boolean
}

function ButtonBase({ className, variant, loading, children, disabled, ...props }: ButtonBaseProps) {
function ButtonBase({ className, variant, loading, children, disabled, size = 'md', ...props }: ButtonBaseProps) {
return (
<button
className={cn(buttonVariants({ variant, loading, disabled, className }))}
className={cn(buttonVariants({ variant, loading, disabled, className, size }))}
disabled={disabled || loading}
{...props}
>
Expand Down
26 changes: 26 additions & 0 deletions src/components/upload/upload-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { useUploadOrchestration } from '../../hooks/use-upload-orchestration.ts'
import { Alert } from '../ui/alert.tsx'

interface UploadErrorProps {
orchestration: ReturnType<typeof useUploadOrchestration>
}

function UploadError({ orchestration }: UploadErrorProps) {
const { activeUpload, uploadedFile, retryUpload, cancelUpload } = orchestration

if (!activeUpload.error) {
return null
}

return (
<Alert
button={{ children: 'Retry Upload', onClick: retryUpload }}
cancelButton={{ children: 'Cancel', onClick: cancelUpload }}
description={activeUpload.error}
message={`Upload failed - ${uploadedFile?.file.name}`}
variant="error"
/>
)
}

export { UploadError }
39 changes: 28 additions & 11 deletions src/hooks/use-filecoin-upload.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createCarFromFile } from 'filecoin-pin/core/unixfs'
import { checkUploadReadiness, executeUpload } from 'filecoin-pin/core/upload'
import pino from 'pino'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Progress } from '../types/upload-progress.ts'
import { formatFileSize } from '../utils/format-file-size.ts'
import { useFilecoinPinContext } from './use-filecoin-pin-context.ts'
Expand Down Expand Up @@ -51,7 +51,17 @@ export const INPI_ERROR_MESSAGE =
* actions so they stay dumb and declarative.
*/
export const useFilecoinUpload = () => {
const { synapse, storageContext, providerInfo } = useFilecoinPinContext()
const { synapse, storageContext, providerInfo, ensureDataSet } = useFilecoinPinContext()

// Use refs to track the latest context values, so the upload callback can access them
// even if the dataset is initialized after the callback is created
const storageContextRef = useRef(storageContext)
const providerInfoRef = useRef(providerInfo)

useEffect(() => {
storageContextRef.current = storageContext
providerInfoRef.current = providerInfo
}, [storageContext, providerInfo])

const [uploadState, setUploadState] = useState<UploadState>({
isUploading: false,
Expand Down Expand Up @@ -149,20 +159,27 @@ export const useFilecoinUpload = () => {
},
})

// Ensure we have storage context from provider (created during data set initialization)
if (!storageContext || !providerInfo) {
// This should never happen because the upload button is disabled if the data set is not ready
throw new Error('Storage context not ready. Please ensure a data set is initialized before uploading.')
// Ensure we have a data set ready before uploading
console.debug('[FilecoinUpload] Ensuring data set is ready before upload...')
await ensureDataSet()

// Get the latest storage context and provider info from refs
// (these may have been updated by ensureDataSet if dataset wasn't ready)
const currentStorageContext = storageContextRef.current
const currentProviderInfo = providerInfoRef.current

if (!currentStorageContext || !currentProviderInfo) {
throw new Error('Storage context not ready. Failed to initialize data set. Please try again.')
}

console.debug('[FilecoinUpload] Using storage context from provider:', {
providerInfo,
dataSetId: storageContext.dataSetId,
providerInfo: currentProviderInfo,
dataSetId: currentStorageContext.dataSetId,
})

const synapseService = {
storage: storageContext,
providerInfo,
storage: currentStorageContext,
providerInfo: currentProviderInfo,
synapse,
}

Expand Down Expand Up @@ -226,7 +243,7 @@ export const useFilecoinUpload = () => {
}))
}
},
[updateProgress, synapse, storageContext, providerInfo]
[updateProgress, synapse, ensureDataSet]
)

const resetUpload = useCallback(() => {
Expand Down
Loading