Skip to content

Commit e6dcb50

Browse files
authored
Merge pull request #4 from eWloYW8/master
fix: correct asset handling
2 parents 48d3c1e + fc823c4 commit e6dcb50

File tree

3 files changed

+196
-10
lines changed

3 files changed

+196
-10
lines changed

app/(main)/contests/page.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
// FILE: app/(main)/contests/page.tsx
2-
31
"use client";
42
import { useSearchParams } from 'next/navigation';
53
import { Suspense, useEffect, useState } from 'react';
@@ -79,7 +77,7 @@ function ContestList() {
7977
}
8078

8179

82-
// --- ProblemCard, ContestProblems, ContestTrend (no changes) ---
80+
// --- ProblemCard, ContestTrend (no changes) ---
8381
function ProblemCard({ problemId }: { problemId: string }) {
8482
const { data: problem, isLoading } = useSWR<Problem>(`/problems/${problemId}`, fetcher);
8583
if (isLoading) return <Skeleton className="h-24 w-full" />;
@@ -106,7 +104,13 @@ function ContestProblems({ contestId }: { contestId: string }) {
106104
<div className="space-y-6">
107105
<Card>
108106
<CardHeader><CardTitle>Contest Description</CardTitle></CardHeader>
109-
<CardContent><MarkdownViewer content={contest.description} /></CardContent>
107+
<CardContent>
108+
<MarkdownViewer
109+
content={contest.description}
110+
assetContext="contest"
111+
assetContextId={contest.id}
112+
/>
113+
</CardContent>
110114
</Card>
111115
<Card>
112116
<CardHeader>

app/(main)/problems/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ function ProblemDetails() {
7676
<CardDescription>Problem ID: {problem.id}</CardDescription>
7777
</CardHeader>
7878
<CardContent>
79-
<MarkdownViewer content={problem.description} />
79+
<MarkdownViewer
80+
content={problem.description}
81+
assetContext="problem"
82+
assetContextId={problem.id}
83+
/>
8084
</CardContent>
8185
</Card>
8286
</div>

components/shared/markdown-viewer.tsx

Lines changed: 183 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,195 @@
11
import ReactMarkdown from 'react-markdown';
22
import remarkGfm from 'remark-gfm';
3+
import { useState, useEffect } from 'react';
4+
import api from '@/lib/api';
5+
import { Skeleton } from '@/components/ui/skeleton';
6+
import Link from 'next/link';
7+
import { cn } from '@/lib/utils';
8+
import { Loader2, AlertCircle } from 'lucide-react';
9+
import { useToast } from '@/hooks/use-toast';
310

411
interface MarkdownViewerProps {
512
content: string;
13+
assetContext?: 'contest' | 'problem';
14+
assetContextId?: string;
615
}
716

8-
// This component uses prose classes from Tailwind Typography for styling
9-
// To use it, you need to install `@tailwindcss/typography`
10-
// and add `require('@tailwindcss/typography')` to your tailwind.config.js plugins.
11-
export default function MarkdownViewer({ content }: MarkdownViewerProps) {
17+
// Custom Hook to fetch a protected asset and return a blob URL (for images on mount)
18+
const useAuthenticatedAsset = (apiUrl: string | null) => {
19+
const [assetUrl, setAssetUrl] = useState<string | null>(null);
20+
const [isLoading, setIsLoading] = useState(true);
21+
const [error, setError] = useState<string | null>(null);
22+
23+
useEffect(() => {
24+
let isMounted = true;
25+
let objectUrl: string | null = null;
26+
27+
const fetchAsset = async () => {
28+
if (!apiUrl) {
29+
setIsLoading(false);
30+
return;
31+
}
32+
33+
setAssetUrl(null);
34+
setError(null);
35+
setIsLoading(true);
36+
37+
try {
38+
const response = await api.get(apiUrl, { responseType: 'blob' });
39+
if (isMounted) {
40+
objectUrl = URL.createObjectURL(response.data);
41+
setAssetUrl(objectUrl);
42+
}
43+
} catch (err: any) {
44+
console.error(`Failed to fetch asset from ${apiUrl}:`, err);
45+
if (isMounted) {
46+
setError(err.message || 'Failed to load resource.');
47+
}
48+
} finally {
49+
if (isMounted) {
50+
setIsLoading(false);
51+
}
52+
}
53+
};
54+
55+
fetchAsset();
56+
57+
return () => {
58+
isMounted = false;
59+
if (objectUrl) {
60+
URL.revokeObjectURL(objectUrl);
61+
}
62+
};
63+
}, [apiUrl]);
64+
65+
return { assetUrl, isLoading, error };
66+
};
67+
68+
// Component to display the authenticated image (Working correctly)
69+
const AuthenticatedImage = ({ apiUrl, ...props }: { apiUrl: string; [key: string]: any }) => {
70+
const { assetUrl, isLoading, error } = useAuthenticatedAsset(apiUrl);
71+
72+
if (isLoading) return <Skeleton className="h-32 w-full my-4" />;
73+
if (error || !assetUrl) return (
74+
<div className="my-4 flex items-center justify-center p-4 bg-muted rounded-md text-destructive">
75+
<AlertCircle className="mr-2 h-4 w-4" />
76+
<span>Failed to load image: {props.alt || apiUrl}</span>
77+
</div>
78+
);
79+
80+
// eslint-disable-next-line @next/next/no-img-element
81+
return <img src={assetUrl} {...props} alt={props.alt || ''} />;
82+
};
83+
84+
const AuthenticatedLink = ({ apiUrl, children, ...props }: { apiUrl: string; children: React.ReactNode; [key: string]: any }) => {
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;
91+
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);
112+
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+
};
131+
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+
);
144+
};
145+
146+
// Main Markdown Viewer component
147+
export default function MarkdownViewer({ content, assetContext, assetContextId }: MarkdownViewerProps) {
148+
// This function transforms relative asset URLs into API paths
149+
const transformUri = (uri: string) => {
150+
if (assetContext && assetContextId && (uri.startsWith('./index.assets/') || uri.startsWith('index.assets/'))) {
151+
const assetPath = uri.replace('./', '');
152+
return `/assets/${assetContext}s/${assetContextId}/${assetPath}`;
153+
}
154+
return uri;
155+
};
156+
157+
const components = {
158+
img: ({ node, ...props }: any) => {
159+
const { src, ...rest } = props;
160+
if (src && src.startsWith('/assets/')) {
161+
return <AuthenticatedImage apiUrl={src} {...rest} />;
162+
}
163+
// eslint-disable-next-line @next/next/no-img-element
164+
return <img src={src} {...rest} alt={props.alt || ''} />;
165+
},
166+
a: ({ node, ...props }: any) => {
167+
const { href, children, ...rest } = props;
168+
169+
// After urlTransform, href is the API path for assets
170+
if (href && href.startsWith('/assets/')) {
171+
// Pass the original href to help determine the filename
172+
return <AuthenticatedLink apiUrl={href} href={node.properties.href} {...rest}>{children}</AuthenticatedLink>;
173+
}
174+
175+
// Let Next.js handle internal links. Use the original href.
176+
const originalHref = node.properties.href;
177+
if (originalHref && !/^(https?|mailto|tel):/.test(originalHref)) {
178+
return <Link href={originalHref} {...rest}>{children}</Link>;
179+
}
180+
181+
// Handle external links
182+
return <a href={originalHref} target="_blank" rel="noopener noreferrer" {...rest}>{children}</a>;
183+
}
184+
};
185+
12186
return (
13187
<div className="prose dark:prose-invert max-w-none">
14-
<ReactMarkdown remarkPlugins={[remarkGfm]}>
188+
<ReactMarkdown
189+
remarkPlugins={[remarkGfm]}
190+
components={components}
191+
urlTransform={transformUri}
192+
>
15193
{content}
16194
</ReactMarkdown>
17195
</div>

0 commit comments

Comments
 (0)