Skip to content

Commit fc823c4

Browse files
committed
feat: add download functionality for authenticated assets in MarkdownViewer
1 parent da6816d commit fc823c4

File tree

1 file changed

+61
-12
lines changed

1 file changed

+61
-12
lines changed

components/shared/markdown-viewer.tsx

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { Skeleton } from '@/components/ui/skeleton';
66
import Link from 'next/link';
77
import { cn } from '@/lib/utils';
88
import { Loader2, AlertCircle } from 'lucide-react';
9+
import { useToast } from '@/hooks/use-toast';
910

1011
interface 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)
1718
const 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)
6869
const 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
8484
const 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(/filename="?(.+?)"?$/);
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 && !/^(https?|mailto|tel):/.test(originalHref)) {
129178
return <Link href={originalHref} {...rest}>{children}</Link>;

0 commit comments

Comments
 (0)