feat: a share dialog that shows what is being shared

A copy button tells you nothing about what you are about to send. The
dialog names the session, counts its questions and shows the stem it
opens on, then offers the link with Copy and the places people actually
send one — email, WhatsApp, Telegram.

Sharing is the administrator's to allow. GET /quizzes/share-policy is
asked before the dialog offers to make a link, so a switch that has been
thrown reads as "not offered" rather than as a button that fails when
pressed. A link already issued keeps working either way.

Removes the second, lesser share block that sat inside the save panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 19:24:45 +02:00
parent 2267f53b55
commit 5f61422daf
5 changed files with 332 additions and 21 deletions

View file

@ -396,6 +396,18 @@ def list_quiz_sessions(
rows.sort(key=lambda r: (r["last_activity"] or ""), reverse=True)
return rows
@router.get("/share-policy")
def share_policy(current_user: User = Depends(get_current_user)):
"""Whether this site lets a learner hand a session to somebody.
Asked before the share dialog offers to make a link, so a switch an
administrator has thrown reads as "not offered" rather than as a button
that fails when pressed.
"""
return {"enabled": site_settings.get_flag("sharing_enabled")}
@router.get("/", response_model=list[QuizResponse])
def list_quizzes(
db: Session = Depends(get_db),

View file

@ -0,0 +1,142 @@
.sh-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(15, 23, 42, 0.55);
}
.sh {
width: min(480px, 100%);
max-height: calc(100dvh - 40px);
overflow-y: auto;
padding: 20px;
border-radius: 14px;
background: var(--surface, #fff);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.28);
}
.sh-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sh-head h2 {
margin: 0;
font-size: 1.05rem;
}
.sh-head button {
border: 0;
background: none;
padding: 4px 8px;
font-size: 1rem;
line-height: 1;
cursor: pointer;
color: var(--text-muted, #64748b);
}
.sh-lead {
margin: 6px 0 14px;
font-size: 0.85rem;
color: var(--text-muted, #64748b);
}
/* The session itself, so what is being handed over is visible first. */
.sh-preview {
padding: 12px 14px;
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
background: var(--surface-alt, #f8fafc);
}
.sh-preview-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.sh-preview-head span {
flex: none;
font-size: 0.78rem;
color: var(--text-muted, #64748b);
}
.sh-preview-stem {
margin: 8px 0 0;
font-size: 0.82rem;
line-height: 1.5;
color: var(--text-muted, #64748b);
}
.sh-link {
display: flex;
gap: 8px;
margin-top: 14px;
}
.sh-link input {
flex: 1;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border, #e2e8f0);
border-radius: 8px;
font-size: 0.82rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--surface-alt, #f8fafc);
}
.sh-targets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.sh-target {
flex: 1 1 auto;
min-width: 96px;
padding: 9px 12px;
border: 1px solid var(--border, #e2e8f0);
border-radius: 8px;
text-align: center;
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
color: var(--text, #0f172a);
background: var(--surface, #fff);
}
.sh-target:hover { border-color: var(--primary, #2563eb); color: var(--primary, #2563eb); }
.sh-revoke { margin-top: 14px; }
.sh-none {
margin: 14px 0 0;
font-size: 0.85rem;
color: var(--text-muted, #64748b);
}
.sh-error {
margin: 12px 0 0;
font-size: 0.82rem;
color: var(--danger, #dc2626);
}
/* On a phone the dialog is the page rather than a card floating on it. */
@media (max-width: 520px) {
.sh-overlay { padding: 0; align-items: flex-end; }
.sh {
width: 100%;
max-height: 88dvh;
border-radius: 14px 14px 0 0;
padding-bottom: max(20px, env(safe-area-inset-bottom));
}
.sh-link { flex-direction: column; }
}

View file

@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
import './ShareSession.css'
const apiError = (err, fallback) => {
const detail = err?.response?.data?.detail
return typeof detail === 'string' ? detail : fallback
}
/**
* Handing a session to someone else.
*
* A copy button on its own tells you nothing about what you are about to send.
* This shows the session and the question it opens on, so the thing being
* shared is visible before it is passed on and offers the places people
* actually send a link, rather than only the clipboard.
*
* An administrator can turn sharing off for the whole site. A link already
* issued keeps working; this simply refuses to make a new one, and says so.
*/
export default function ShareSession({ quiz, firstQuestion, onShareChanged, canManage }) {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [copied, setCopied] = useState(false)
// Null until asked. Treated as allowed while unknown so a slow answer never
// hides a link that already exists.
const [allowed, setAllowed] = useState(null)
const token = quiz?.share_token
const url = token ? `${window.location.origin}/share/${token}` : ''
useEffect(() => {
if (!open) return undefined
setError('')
let live = true
api.get('/quizzes/share-policy')
.then(res => { if (live) setAllowed(res.data?.enabled !== false) })
.catch(() => { if (live) setAllowed(true) })
const onKey = e => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', onKey)
return () => {
live = false
document.removeEventListener('keydown', onKey)
}
}, [open])
const enable = async () => {
setBusy(true); setError('')
try {
const res = await api.post(`/quizzes/${quiz.id}/share-link`)
onShareChanged?.(res.data.token)
} catch (err) { setError(apiError(err, 'Could not create a link')) }
finally { setBusy(false) }
}
const revoke = async () => {
setBusy(true); setError('')
try {
await api.delete(`/quizzes/${quiz.id}/share-link`)
onShareChanged?.(null)
} catch (err) { setError(apiError(err, 'Could not revoke sharing')) }
finally { setBusy(false) }
}
const copy = () => {
navigator.clipboard?.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const text = encodeURIComponent(`Try this: ${quiz?.title || 'a question session'}`)
const link = encodeURIComponent(url)
const targets = [
{ key: 'email', label: 'Email', href: `mailto:?subject=${text}&body=${link}` },
{ key: 'whatsapp', label: 'WhatsApp', href: `https://wa.me/?text=${text}%20${link}` },
{ key: 'telegram', label: 'Telegram', href: `https://t.me/share/url?url=${link}&text=${text}` },
]
return (
<>
<button type="button" className="quiz-more-item" onClick={() => setOpen(true)}>
Share this session
</button>
{open && (
<div className="sh-overlay" onClick={e => e.target === e.currentTarget && setOpen(false)}>
<div className="sh" role="dialog" aria-modal="true" aria-labelledby="sh-heading">
<div className="sh-head">
<h2 id="sh-heading">Share this session</h2>
<button type="button" onClick={() => setOpen(false)} aria-label="Close"></button>
</div>
<p className="sh-lead">
Anyone with the link can sit these questions. They do not need an account.
</p>
{/* What is actually being handed over. */}
<div className="sh-preview">
<div className="sh-preview-head">
<strong>{quiz?.title}</strong>
<span>{quiz?.questions_count || 0} questions</span>
</div>
{firstQuestion && <p className="sh-preview-stem">{firstQuestion.slice(0, 180)}</p>}
</div>
{token ? (
<>
<div className="sh-link">
<input readOnly value={url} aria-label="Share link"
onFocus={e => e.target.select()} />
<button type="button" className="btn btn-primary" onClick={copy}>
{copied ? '✓ Copied' : 'Copy'}
</button>
</div>
<div className="sh-targets">
{targets.map(target => (
<a key={target.key} className="sh-target" href={target.href}
target="_blank" rel="noopener noreferrer">{target.label}</a>
))}
</div>
{canManage && (
<button type="button" className="btn btn-secondary btn-sm sh-revoke"
disabled={busy} onClick={revoke}>
Stop sharing
</button>
)}
</>
) : canManage && allowed !== false ? (
<button type="button" className="btn btn-primary" disabled={busy} onClick={enable}>
{busy ? 'Creating…' : 'Create a share link'}
</button>
) : allowed === false ? (
<p className="sh-none">Sharing sessions is turned off for this site.</p>
) : (
<p className="sh-none">This session has not been shared.</p>
)}
{error && <p className="sh-error" role="alert">{error}</p>}
</div>
</div>
)}
</>
)
}

View file

@ -9,6 +9,7 @@ import api from '../api/client'
import useMediaQuery from '../hooks/useMediaQuery'
import FigureStrip from '../components/FigureStrip'
import FeedbackForm from '../components/FeedbackForm'
import ShareSession from '../components/ShareSession'
import '../components/Feedback.css'
import QuizTools, { QuizDialog } from '../components/QuizTools'
import './QuizPlayer.css'
@ -448,7 +449,6 @@ export default function QuizPage() {
const [note, setNote] = useState('')
const [noteSaved, setNoteSaved] = useState(true)
const [folders, setFolders] = useState([])
const [shareUrl, setShareUrl] = useState('')
const [showReview, setShowReview] = useState(false)
const [responseStats, setResponseStats] = useState(null)
const [statsError, setStatsError] = useState('')
@ -856,10 +856,6 @@ const timerStarted = timeLeft !== null
api.get('/collections/').then(res => setFolders(res.data || [])).catch(() => setFolders([]))
}, [])
useEffect(() => {
setShareUrl(quiz?.share_token ? `${window.location.origin}/share/${quiz.share_token}` : '')
}, [quiz?.share_token])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
// Reset one question rather than the whole attempt: a misclick should cost
@ -1351,7 +1347,8 @@ const timerStarted = timeLeft !== null
onClick={() => setPanel(p => (p === 'save' ? null : 'save'))}>
Save to a folder
</button>
<ShareLinkBadge quiz={quiz}
<ShareSession quiz={quiz} firstQuestion={questions[0]?.question_text}
canManage={!!user && (user.is_moderator || quiz.user_id === user.id)}
onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
<details className="quiz-more-feedback">
<summary> Give feedback</summary>
@ -1444,15 +1441,6 @@ const timerStarted = timeLeft !== null
})}
</ul>
)}
{shareUrl && (
<div className="quiz-share">
<p className="quiz-panel-title">Share this session</p>
<input readOnly value={shareUrl} aria-label="Share link"
onFocus={e => e.target.select()} />
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => navigator.clipboard?.writeText(shareUrl)}>Copy</button>
</div>
)}
</div>
)}

View file

@ -439,7 +439,7 @@ describe('quiz player', () => {
}))
})
it('makes a quiz shareable and copies the link without showing it', async () => {
it('shows what is being shared, then makes the link', async () => {
const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }
Object.defineProperty(navigator, 'clipboard', { value: clipboard, configurable: true })
const originalPost = api.post.getMockImplementation()
@ -453,12 +453,36 @@ describe('quiz player', () => {
// Sharing folded away behind the question bar's more-menu, alongside
// saving and giving feedback occasional actions, not part of every read.
await userEvent.click(await screen.findByRole('button', { name: 'More options' }))
await userEvent.click(screen.getByRole('button', { name: 'Make shareable' }))
await screen.findByRole('button', { name: 'Copy share link' })
await userEvent.click(screen.getByRole('button', { name: /Share this session/ }))
// The dialog names the session and shows the question it opens on, so the
// thing being handed over is visible before it is handed over.
const dialog = await screen.findByRole('dialog', { name: 'Share this session' })
expect(within(dialog).getByText('Personal test')).toBeInTheDocument()
expect(within(dialog).getByText(/Full first clinical question/)).toBeInTheDocument()
await userEvent.click(within(dialog).getByRole('button', { name: 'Create a share link' }))
await within(dialog).findByRole('button', { name: 'Copy' })
expect(api.post).toHaveBeenCalledWith('/quizzes/10/share-link')
await userEvent.click(screen.getByRole('button', { name: 'Copy share link' }))
// The link is shown, not hidden: you should be able to read what you send.
expect(within(dialog).getByLabelText('Share link').value).toContain('/share/tok-1')
await userEvent.click(within(dialog).getByRole('button', { name: 'Copy' }))
expect(clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('/share/tok-1'))
expect(screen.getByText('✓ Link copied')).toBeInTheDocument()
expect(screen.queryByText(/share\/tok-1/)).not.toBeInTheDocument()
expect(within(dialog).getByRole('link', { name: 'Email' })).toBeInTheDocument()
})
it('offers no new link when an administrator has turned sharing off', async () => {
const originalGet = api.get.getMockImplementation()
api.get.mockImplementation((url, ...args) => {
if (url === '/quizzes/share-policy') return Promise.resolve({ data: { enabled: false } })
return originalGet(url, ...args)
})
mount()
await userEvent.click(await screen.findByRole('button', { name: 'Start session' }))
await findStem('Full first clinical question.')
await userEvent.click(await screen.findByRole('button', { name: 'More options' }))
await userEvent.click(screen.getByRole('button', { name: /Share this session/ }))
const dialog = await screen.findByRole('dialog', { name: 'Share this session' })
expect(await within(dialog).findByText(/turned off for this site/)).toBeInTheDocument()
expect(within(dialog).queryByRole('button', { name: 'Create a share link' })).toBeNull()
})
})