@@ -6,14 +6,15 @@ import { Skeleton } from '@/components/ui/skeleton';
66import Link from 'next/link' ;
77import { cn } from '@/lib/utils' ;
88import { Loader2 , AlertCircle } from 'lucide-react' ;
9+ import { useToast } from '@/hooks/use-toast' ;
910
1011interface MarkdownViewerProps {
1112 content : string ;
1213 assetContext ?: 'contest' | 'problem' ;
1314 assetContextId ?: string ;
1415}
1516
16- // Custom Hook to fetch a protected asset and return a blob URL
17+ // Custom Hook to fetch a protected asset and return a blob URL (for images on mount)
1718const useAuthenticatedAsset = ( apiUrl : string | null ) => {
1819 const [ assetUrl , setAssetUrl ] = useState < string | null > ( null ) ;
1920 const [ isLoading , setIsLoading ] = useState ( true ) ;
@@ -64,7 +65,7 @@ const useAuthenticatedAsset = (apiUrl: string | null) => {
6465 return { assetUrl, isLoading, error } ;
6566} ;
6667
67- // Component to display the authenticated image
68+ // Component to display the authenticated image (Working correctly)
6869const AuthenticatedImage = ( { apiUrl, ...props } : { apiUrl : string ; [ key : string ] : any } ) => {
6970 const { assetUrl, isLoading, error } = useAuthenticatedAsset ( apiUrl ) ;
7071
@@ -80,17 +81,66 @@ const AuthenticatedImage = ({ apiUrl, ...props }: { apiUrl: string; [key: string
8081 return < img src = { assetUrl } { ...props } alt = { props . alt || '' } /> ;
8182} ;
8283
83- // Component to create a downloadable link for an authenticated asset
8484const AuthenticatedLink = ( { apiUrl, children, ...props } : { apiUrl : string ; children : React . ReactNode ; [ key : string ] : any } ) => {
85- const { assetUrl, isLoading, error } = useAuthenticatedAsset ( apiUrl ) ;
85+ const [ isDownloading , setIsDownloading ] = useState ( false ) ;
86+ const { toast } = useToast ( ) ;
87+
88+ const handleDownloadClick = async ( event : React . MouseEvent < HTMLAnchorElement > ) => {
89+ event . preventDefault ( ) ;
90+ if ( isDownloading ) return ;
8691
87- // Try to get a filename from the original href
88- const filename = props . href ?. split ( '/' ) . pop ( ) ;
92+ setIsDownloading ( true ) ;
93+ try {
94+ const response = await api . get ( apiUrl , { responseType : 'blob' } ) ;
95+ const blob = new Blob ( [ response . data ] ) ;
96+
97+ // Create a temporary link to trigger the download
98+ const url = window . URL . createObjectURL ( blob ) ;
99+ const link = document . createElement ( 'a' ) ;
100+ link . href = url ;
101+
102+ // Attempt to get a filename from Content-Disposition header, falling back to URL parsing
103+ const contentDisposition = response . headers [ 'content-disposition' ] ;
104+ let filename = props . href ?. split ( '/' ) . pop ( ) || 'download' ; // Fallback filename
105+ if ( contentDisposition ) {
106+ const filenameMatch = contentDisposition . match ( / f i l e n a m e = " ? ( .+ ?) " ? $ / ) ;
107+ if ( filenameMatch && filenameMatch . length > 1 ) {
108+ filename = filenameMatch [ 1 ] ;
109+ }
110+ }
111+ link . setAttribute ( 'download' , filename ) ;
89112
90- if ( isLoading ) return < span className = { cn ( 'inline-flex items-center' , props . className ) } > < Loader2 className = "mr-2 h-4 w-4 animate-spin" /> { children } </ span > ;
91- if ( error || ! assetUrl ) return < span className = { cn ( 'text-destructive' , props . className ) } > { children } [Failed to load]</ span > ;
113+ document . body . appendChild ( link ) ;
114+ link . click ( ) ;
115+
116+ // Clean up the temporary link and object URL
117+ link . remove ( ) ;
118+ window . URL . revokeObjectURL ( url ) ;
119+
120+ } catch ( error ) {
121+ console . error ( 'Download failed:' , error ) ;
122+ toast ( {
123+ variant : "destructive" ,
124+ title : "Download Failed" ,
125+ description : "Could not download the requested file." ,
126+ } ) ;
127+ } finally {
128+ setIsDownloading ( false ) ;
129+ }
130+ } ;
92131
93- return < a href = { assetUrl } download = { filename || true } { ...props } > { children } </ a > ;
132+ return (
133+ < a href = { apiUrl } onClick = { handleDownloadClick } { ...props } >
134+ { isDownloading ? (
135+ < span className = "inline-flex items-center" >
136+ < Loader2 className = "mr-2 h-4 w-4 animate-spin" />
137+ { children }
138+ </ span >
139+ ) : (
140+ children
141+ ) }
142+ </ a >
143+ ) ;
94144} ;
95145
96146// Main Markdown Viewer component
@@ -106,8 +156,6 @@ export default function MarkdownViewer({ content, assetContext, assetContextId }
106156
107157 const components = {
108158 img : ( { node, ...props } : any ) => {
109- // After urlTransform, props.src is the API path.
110- // We destructure it out to prevent it from being passed down and overriding the blob src.
111159 const { src, ...rest } = props ;
112160 if ( src && src . startsWith ( '/assets/' ) ) {
113161 return < AuthenticatedImage apiUrl = { src } { ...rest } /> ;
@@ -120,10 +168,11 @@ export default function MarkdownViewer({ content, assetContext, assetContextId }
120168
121169 // After urlTransform, href is the API path for assets
122170 if ( href && href . startsWith ( '/assets/' ) ) {
171+ // Pass the original href to help determine the filename
123172 return < AuthenticatedLink apiUrl = { href } href = { node . properties . href } { ...rest } > { children } </ AuthenticatedLink > ;
124173 }
125174
126- // Let Next.js handle internal links. Use the original href to avoid issues .
175+ // Let Next.js handle internal links. Use the original href.
127176 const originalHref = node . properties . href ;
128177 if ( originalHref && ! / ^ ( h t t p s ? | m a i l t o | t e l ) : / . test ( originalHref ) ) {
129178 return < Link href = { originalHref } { ...rest } > { children } </ Link > ;
0 commit comments