e.stopPropagation()}>
Explanation: {current.explanation}>}
{current.explanation_image_path && (
)}
@@ -1318,7 +1319,7 @@ const timerStarted = timeLeft !== null
{/* AI tutor — only in study mode, lazy-loaded */}
{isStudy && current && (
-
+
)}
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx
index ab3333b..f0c89e0 100644
--- a/frontend/src/pages/QuizPage.test.jsx
+++ b/frontend/src/pages/QuizPage.test.jsx
@@ -8,7 +8,7 @@ import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, name: 'Learner', role: 'user' } }) }))
vi.mock('../components/MyNote', () => ({ default: () => null }))
-vi.mock('../components/TeachChat', () => ({ default: () =>
@@ -234,7 +235,7 @@ export default function ResultsPage() {
{ans.explanation &&
{ans.explanation}
}
{ans.explanation_image_path && (
-

e.currentTarget.style.display = 'none'} />
diff --git a/frontend/src/utils/token.js b/frontend/src/utils/token.js
new file mode 100644
index 0000000..0b00924
--- /dev/null
+++ b/frontend/src/utils/token.js
@@ -0,0 +1,10 @@
+// Media-only mirror of the existing JS-readable token, NOT HttpOnly/XSS hardening.
+export function setToken(token) {
+ if (token) localStorage.setItem('token', token)
+ else localStorage.removeItem('token')
+ document.cookie = `pedshub_media=${token ? encodeURIComponent(token) : ''}; Path=/uploads; SameSite=Strict${window.location.protocol === 'https:' ? '; Secure' : ''}${token ? '' : '; Max-Age=0'}`
+}
+
+export function syncMediaToken() {
+ setToken(localStorage.getItem('token'))
+}
diff --git a/frontend/src/utils/uploads.js b/frontend/src/utils/uploads.js
new file mode 100644
index 0000000..41211ad
--- /dev/null
+++ b/frontend/src/utils/uploads.js
@@ -0,0 +1,21 @@
+// Keep the editor's relative/absolute URL behavior; attempt context is local only.
+export function uploadUrl(path, attemptId) {
+ if (!path) return ''
+ const value = /^(https?:)?\/\//i.test(path) || path.startsWith('/uploads/') ? path : `/uploads/${path}`
+ let url
+ try { url = new URL(value, window.location.origin) }
+ catch { return value } // Legacy invalid URLs must not crash the question/player.
+ if (attemptId != null && url.origin === window.location.origin && url.pathname.startsWith('/uploads/')) {
+ url.searchParams.set('attempt_id', attemptId)
+ return `${url.pathname}${url.search}${url.hash}`
+ }
+ return value
+}
+
+export function markdownImageUrl(src, attemptId) {
+ if (!src) return src
+ let url
+ try { url = new URL(src, window.location.origin) }
+ catch { return src }
+ return url.origin === window.location.origin && url.pathname.startsWith('/uploads/') ? uploadUrl(src, attemptId) : src
+}
diff --git a/frontend/src/utils/uploads.test.js b/frontend/src/utils/uploads.test.js
new file mode 100644
index 0000000..30639d9
--- /dev/null
+++ b/frontend/src/utils/uploads.test.js
@@ -0,0 +1,24 @@
+import { expect, it } from 'vitest'
+import { uploadUrl, markdownImageUrl } from './uploads'
+
+it('leaves malformed legacy image URLs to normal browser image-error handling', () => {
+ for (const url of ['https://', 'https://host:bad/a.png', 'http://[invalid']) {
+ expect(uploadUrl(url, 12)).toBe(url)
+ expect(markdownImageUrl(url, 12)).toBe(url)
+ }
+})
+
+it('uses the existing upload format and adds attempt context only to same-origin uploads', () => {
+ expect(uploadUrl('questions/test.png')).toBe('/uploads/questions/test.png')
+ expect(uploadUrl('/uploads/questions/test.png', 12)).toBe('/uploads/questions/test.png?attempt_id=12')
+ expect(uploadUrl('questions/test.png?size=1#view', 12)).toBe('/uploads/questions/test.png?size=1&attempt_id=12#view')
+ expect(uploadUrl(`${window.location.origin}/uploads/test.png?attempt_id=1`, 12)).toBe('/uploads/test.png?attempt_id=12')
+ for (const url of ['https://external.example/uploads/test.png', '//external.example/uploads/test.png']) {
+ expect(uploadUrl(url, 12)).toBe(url)
+ expect(markdownImageUrl(url, 12)).toBe(url)
+ }
+ expect(markdownImageUrl('/uploads/test.png', 12)).toBe('/uploads/test.png?attempt_id=12')
+ expect(markdownImageUrl('/other/test.png', 12)).toBe('/other/test.png')
+ expect(markdownImageUrl('https://external.example/test.png', 12)).toBe('https://external.example/test.png')
+ expect(uploadUrl(null, 12)).toBe('')
+})