feat: add collections management and study coach features
Test / backend (push) Failing after 13s
Test / frontend (push) Successful in 25s

- Implemented collection repository with CRUD operations for user collections.
- Created routes for managing collections, including listing, creating, updating, and deleting collections.
- Added sharing functionality for exams, allowing users to share and clone exams.
- Developed a study coach service that analyzes exam results and provides study tips based on weak areas.
- Introduced frontend components for managing collections, sharing exams, and displaying study coach insights.
- Enhanced user experience with loading states and error handling in shared components.
This commit is contained in:
LaurensBartelds committed 2026-05-02 16:55:08 +02:00
1 parent 7755e77039
commit 1ec1633dd9
26 files changed
+2557 -127

No files matched your search

+78 -5
View File
@@ -58,6 +58,7 @@ export default function ExamEditor() {
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(isNew)
const [tags, setTags] = useState<string[]>([])
// Load existing exam
useEffect(() => {
@@ -66,6 +67,7 @@ export default function ExamEditor() {
examApi.getExam(examId!)
.then((data) => {
setExam(data)
setTags(data.tags ?? [])
setHasChanges(false)
})
.catch((e) => setError((e as Error).message))
@@ -113,12 +115,12 @@ export default function ExamEditor() {
setError(null)
try {
if (isNew) {
const data = await examApi.createExam(exam)
const data = await examApi.createExam(exam, tags)
setSaveMsg(t('editor.examCreated'))
setHasChanges(false)
navigate(`/exam-editor/${data.id}`, { replace: true })
} else {
await examApi.updateExam(examId!, exam)
await examApi.updateExam(examId!, exam, tags)
setSaveMsg(t('editor.examSaved'))
setHasChanges(false)
}
@@ -165,6 +167,12 @@ export default function ExamEditor() {
</div>
)}
{!loading && (
<div style={{ padding: '8px 16px', borderBottom: '1px solid var(--line-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
<TagsEditor tags={tags} onChange={(newTags) => { setTags(newTags); setHasChanges(true) }} />
</div>
)}
{loading ? (
<div className="exam-editor-loading">{t('common.loading')}</div>
) : (
@@ -234,6 +242,73 @@ export default function ExamEditor() {
)
}
/* ─── Tags Editor ─── */
function TagsEditor({ tags, onChange }: { tags: string[]; onChange: (tags: string[]) => void }) {
const { t } = useTranslation()
const [input, setInput] = useState('')
const addTag = () => {
const raw = input.trim().toLowerCase().replace(/[^a-z0-9\-_]/g, '')
if (!raw) return
if (tags.includes(raw)) return
onChange([...tags, raw])
setInput('')
}
const removeTag = (tag: string) => {
onChange(tags.filter((t) => t !== tag))
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', flex: 1 }}>
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{t('editor.tagsLabel')}</span>
{tags.map((tag) => (
<span
key={tag}
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
background: 'var(--paper)',
color: 'var(--ink-3)',
border: '1px solid var(--line-2)',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
{tag}
<button
onClick={() => removeTag(tag)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 11, color: 'var(--wrong)' }}
>
✕
</button>
</span>
))}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
className="input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}}
placeholder={t('editor.tagsPlaceholder')}
style={{ width: 140, fontSize: 12 }}
/>
<button className="btn btn-sm" onClick={addTag} style={{ fontSize: 11 }}>
{t('editor.addTag')}
</button>
</div>
</div>
)
}
/* ─── Question Editor ─── */
interface QEProps {
@@ -332,13 +407,12 @@ function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps[
const setAnswer = (a: string) => onChange((q) => { q.answer = a })
const addChoice = () => {
const nextId = String.fromCharCode(97 + choices.length) // a, b, c...
const nextId = String.fromCharCode(97 + choices.length)
setChoices([...choices, { id: nextId, text: '' }])
}
const removeChoice = (idx: number) => {
const next = choices.filter((_, i) => i !== idx)
// Renumber
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
setChoices(renumbered)
if (q.answer === choices[idx]?.id) setAnswer(renumbered[0]?.id ?? '')
@@ -752,7 +826,6 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
<p className="faint" style={{ fontSize: 12, marginTop: 4 }}>
{t('editor.correctOrderHint')}
</p>
{/* Sync answer to current item order */}
<button
className="btn btn-sm"
style={{ marginTop: 8 }}
+719 -78
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as examApi from '../api/examApi'
import * as collectionApi from '../api/collectionApi'
import type { Exam } from '../types'
interface SavedExam {
@@ -11,27 +12,57 @@ interface SavedExam {
question_count: number
created_at: number
updated_at: number
share_token: string | null
is_favorite: number
tags: string
collection_id: number | null
}
interface Props {
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
export default function MyExams({ onLoadExam }: Props) {
type CollectionFilter = number | null | 'all' | 'uncategorized'
export default function MyExams({ onLoadExam }: Readonly<Props>) {
const { t } = useTranslation()
const navigate = useNavigate()
const [exams, setExams] = useState<SavedExam[]>([])
const [collections, setCollections] = useState<collectionApi.Collection[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const [startingId, setStartingId] = useState<number | null>(null)
const [shareOpenId, setShareOpenId] = useState<number | null>(null)
const [sharingId, setSharingId] = useState<number | null>(null)
const [duplicatingId, setDuplicatingId] = useState<number | null>(null)
const [exportingId, setExportingId] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [activeFilter, setActiveFilter] = useState<CollectionFilter>('all')
const [showNewCollection, setShowNewCollection] = useState(false)
const [newCollectionName, setNewCollectionName] = useState('')
const [creatingCollection, setCreatingCollection] = useState(false)
const [moveMenuExamId, setMoveMenuExamId] = useState<number | null>(null)
const [movingExamId, setMovingExamId] = useState<number | null>(null)
const [editingCollectionId, setEditingCollectionId] = useState<number | null>(null)
const [editingCollectionName, setEditingCollectionName] = useState('')
const [deleteCollectionId, setDeleteCollectionId] = useState<number | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [batchMoveOpen, setBatchMoveOpen] = useState(false)
const [batchDeleting, setBatchDeleting] = useState(false)
const [batchExporting, setBatchExporting] = useState(false)
const [batchMoving, setBatchMoving] = useState(false)
const loadExams = useCallback(async () => {
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await examApi.listExams()
setExams(data)
const [examData, collectionData] = await Promise.all([
examApi.listExams(),
collectionApi.listCollections(),
])
setExams(examData)
setCollections(collectionData)
} catch (e) {
setError((e as Error).message)
} finally {
@@ -40,14 +71,14 @@ export default function MyExams({ onLoadExam }: Props) {
}, [])
useEffect(() => {
void loadExams()
}, [loadExams])
void loadData()
}, [loadData])
const deleteExam = async (id: number) => {
try {
await examApi.deleteExam(id)
setDeleteConfirm(null)
void loadExams()
void loadData()
} catch (e) {
setError((e as Error).message)
}
@@ -68,97 +99,707 @@ export default function MyExams({ onLoadExam }: Props) {
}
}
const handleShare = async (id: number) => {
setSharingId(id)
try {
const res = await examApi.shareExam(id)
const url = `${window.location.origin}/share/${res.shareToken}`
await navigator.clipboard.writeText(url)
setShareOpenId(null)
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: res.shareToken } : e)))
} catch (e) {
setError((e as Error).message)
} finally {
setSharingId(null)
}
}
const handleUnshare = async (id: number) => {
try {
await examApi.unshareExam(id)
setShareOpenId(null)
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: null } : e)))
} catch (e) {
setError((e as Error).message)
}
}
const handleFavorite = async (id: number, isFavorite: boolean) => {
try {
await examApi.setFavorite(id, isFavorite)
setExams((prev) => prev.map((e) => {
if (e.id !== id) return e
const updated = { ...e, is_favorite: isFavorite ? 1 : 0 }
return updated
}).sort((a, b) => (b.is_favorite - a.is_favorite) || (b.updated_at - a.updated_at)))
} catch (e) {
setError((e as Error).message)
}
}
const handleExport = async (id: number, title: string) => {
setExportingId(id)
try {
const blob = await examApi.exportExam(id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
setError((e as Error).message)
} finally {
setExportingId(null)
}
}
const handleDuplicate = async (id: number) => {
setDuplicatingId(id)
try {
await examApi.duplicateExam(id)
void loadData()
} catch (e) {
setError((e as Error).message)
} finally {
setDuplicatingId(null)
}
}
const handleCreateCollection = async () => {
const name = newCollectionName.trim()
if (!name) return
setCreatingCollection(true)
try {
await collectionApi.createCollection(name)
setNewCollectionName('')
setShowNewCollection(false)
void loadData()
} catch (e) {
setError((e as Error).message)
} finally {
setCreatingCollection(false)
}
}
const handleMoveExam = async (examId: number, collectionId: number | null) => {
setMovingExamId(examId)
try {
await collectionApi.moveExamToCollection(examId, collectionId)
setMoveMenuExamId(null)
setExams((prev) => prev.map((e) => e.id === examId ? { ...e, collection_id: collectionId } : e))
setCollections((prev) => prev.map((c) => {
if (collectionId !== null && c.id === collectionId) return { ...c, exam_count: c.exam_count + 1 }
const oldExam = exams.find((e) => e.id === examId)
if (oldExam?.collection_id !== null && c.id === oldExam?.collection_id) return { ...c, exam_count: Math.max(0, c.exam_count - 1) }
return c
}))
} catch (e) {
setError((e as Error).message)
} finally {
setMovingExamId(null)
}
}
const handleRenameCollection = async (id: number) => {
const name = editingCollectionName.trim()
if (!name) {
setEditingCollectionId(null)
return
}
try {
await collectionApi.updateCollection(id, name)
setCollections((prev) => prev.map((c) => c.id === id ? { ...c, name } : c))
setEditingCollectionId(null)
} catch (e) {
setError((e as Error).message)
}
}
const handleDeleteCollection = async (id: number) => {
try {
await collectionApi.deleteCollection(id)
setDeleteCollectionId(null)
setExams((prev) => prev.map((e) => e.collection_id === id ? { ...e, collection_id: null } : e))
setCollections((prev) => prev.filter((c) => c.id !== id))
} catch (e) {
setError((e as Error).message)
}
}
const toggleSelect = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const selectAllFiltered = () => {
setSelectedIds(new Set(filteredExams.map((e) => e.id)))
}
const clearSelection = () => {
setSelectedIds(new Set())
}
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return
if (!window.confirm(t('workspace.batchDeleteConfirm'))) return
setBatchDeleting(true)
try {
await examApi.batchDelete(Array.from(selectedIds))
setSelectedIds(new Set())
void loadData()
} catch (e) {
setError((e as Error).message)
} finally {
setBatchDeleting(false)
}
}
const handleBatchExport = async () => {
if (selectedIds.size === 0) return
setBatchExporting(true)
try {
for (const id of selectedIds) {
const exam = exams.find((e) => e.id === id)
if (exam) {
const blob = await examApi.exportExam(id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${exam.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
} catch (e) {
setError((e as Error).message)
} finally {
setBatchExporting(false)
}
}
const handleBatchMove = async (collectionId: number | null) => {
if (selectedIds.size === 0) return
setBatchMoving(true)
try {
for (const id of selectedIds) {
await collectionApi.moveExamToCollection(id, collectionId)
}
setBatchMoveOpen(false)
setSelectedIds(new Set())
void loadData()
} catch (e) {
setError((e as Error).message)
} finally {
setBatchMoving(false)
}
}
const formatDate = (ts: number) => {
const d = new Date(ts * 1000)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const parseTags = (tagsJson: string): string[] => {
try {
const parsed = JSON.parse(tagsJson)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
const filteredExams = exams.filter((exam) => {
if (!searchQuery.trim()) return true
const q = searchQuery.toLowerCase()
const tags = parseTags(exam.tags)
return (
exam.title.toLowerCase().includes(q) ||
(exam.subtitle?.toLowerCase().includes(q) ?? false) ||
tags.some((t) => t.toLowerCase().includes(q))
)
}).filter((exam) => {
if (activeFilter === 'all') return true
if (activeFilter === 'uncategorized') return exam.collection_id === null
return exam.collection_id === activeFilter
})
const uncategorizedCount = exams.filter((e) => e.collection_id === null).length
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
</div>
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
+ {t('workspace.newExam')}
</button>
</div>
{error && (
<div className="workspace-alert workspace-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="workspace-empty">{t('common.loading')}</div>
) : exams.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">{t('workspace.noSavedExams')}</div>
<div className="workspace-empty-desc">
{t('workspace.noSavedExamsDesc')}
</div>
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
{t('workspace.createFirstExam')}
<div className="workspace-section fade-in" style={{ display: 'flex', gap: 20 }}>
{/* Collections Sidebar */}
<div style={{ width: 220, flexShrink: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--ink-3)', marginBottom: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{t('workspace.collections')}
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
onClick={() => setShowNewCollection(true)}
title={t('workspace.newCollection')}
>
+
</button>
</div>
) : (
<div className="workspace-list">
{exams.map((exam) => (
<div key={exam.id} className="workspace-card">
<div className="workspace-card-body">
<div className="workspace-card-title">{exam.title}</div>
{exam.subtitle && (
<div className="workspace-card-sub">{exam.subtitle}</div>
)}
<div className="workspace-card-meta">
<span>{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
<span>·</span>
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
{showNewCollection && (
<div style={{ marginBottom: 8, display: 'flex', gap: 4 }}>
<input
type="text"
className="input"
placeholder={t('workspace.collectionNamePlaceholder')}
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void handleCreateCollection() }}
style={{ fontSize: 12, padding: '4px 8px', flex: 1 }}
autoFocus
/>
<button className="btn btn-sm btn-primary" onClick={() => void handleCreateCollection()} disabled={creatingCollection} style={{ fontSize: 11, padding: '4px 8px' }}>
{t('workspace.create')}
</button>
<button className="btn btn-sm btn-ghost" onClick={() => { setShowNewCollection(false); setNewCollectionName('') }} style={{ fontSize: 11, padding: '4px 8px' }}>
{t('common.cancel')}
</button>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<button
className={`btn btn-ghost btn-sm${activeFilter === 'all' ? ' active' : ''}`}
onClick={() => setActiveFilter('all')}
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'all' ? 600 : 400 }}
>
<span>{t('workspace.allExams')}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{exams.length}</span>
</button>
<button
className={`btn btn-ghost btn-sm${activeFilter === 'uncategorized' ? ' active' : ''}`}
onClick={() => setActiveFilter('uncategorized')}
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'uncategorized' ? 600 : 400 }}
>
<span>{t('workspace.uncategorized')}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{uncategorizedCount}</span>
</button>
{collections.map((col) => (
<div key={col.id} style={{ position: 'relative' }}>
{editingCollectionId === col.id ? (
<div style={{ display: 'flex', gap: 4, padding: '2px 0' }}>
<input
type="text"
className="input"
value={editingCollectionName}
onChange={(e) => setEditingCollectionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleRenameCollection(col.id)
if (e.key === 'Escape') setEditingCollectionId(null)
}}
onBlur={() => void handleRenameCollection(col.id)}
style={{ fontSize: 12, padding: '3px 6px', flex: 1 }}
autoFocus
/>
</div>
</div>
<div className="workspace-card-actions">
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => void startExam(exam.id)}
disabled={startingId === exam.id}
className={`btn btn-ghost btn-sm${activeFilter === col.id ? ' active' : ''}`}
onClick={() => setActiveFilter(col.id)}
onContextMenu={(e) => {
e.preventDefault()
setEditingCollectionName(col.name)
setEditingCollectionId(col.id)
}}
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === col.id ? 600 : 400 }}
>
{startingId === exam.id ? t('common.loading') : t('workspace.takeExam')}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{col.name}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)', flexShrink: 0 }}>{col.exam_count}</span>
</button>
<button
className="btn btn-sm"
onClick={() => navigate(`/exam-editor/${exam.id}`)}
disabled={startingId === exam.id}
>
{t('common.edit')}
</button>
{deleteConfirm === exam.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
)}
{activeFilter === col.id && editingCollectionId !== col.id && (
<div style={{ display: 'flex', gap: 4, padding: '0 8px 4px', marginTop: -2 }}>
<button className="btn btn-ghost btn-sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => { setEditingCollectionName(col.name); setEditingCollectionId(col.id) }}>
{t('workspace.rename')}
</button>
<button className="btn btn-ghost btn-sm admin-btn-danger-ghost" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => setDeleteCollectionId(col.id)}>
{t('common.delete')}
</button>
</div>
)}
{deleteCollectionId === col.id && (
<div style={{ padding: '4px 8px', fontSize: 11, background: 'var(--wrong-weak)', borderRadius: 6, margin: '2px 0' }}>
<div style={{ marginBottom: 4 }}>{t('workspace.deleteCollectionConfirm')}</div>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-sm admin-btn-danger" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => void handleDeleteCollection(col.id)}>
{t('common.confirm')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
<button className="btn btn-sm btn-ghost" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => setDeleteCollectionId(null)}>
{t('common.cancel')}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(exam.id)}
title={t('common.delete')}
disabled={startingId === exam.id}
>
✕
</button>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Exams List */}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
</div>
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
+ {t('workspace.newExam')}
</button>
</div>
{/* Search */}
<div style={{ marginBottom: 16 }}>
<input
type="text"
className="input"
placeholder={t('workspace.searchExams')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ maxWidth: 400 }}
/>
</div>
{/* Batch toolbar */}
{!loading && filteredExams.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
checked={selectedIds.size > 0 && selectedIds.size === filteredExams.length}
onChange={() => {
if (selectedIds.size === filteredExams.length) clearSelection()
else selectAllFiltered()
}}
ref={(el) => {
if (el) el.indeterminate = selectedIds.size > 0 && selectedIds.size < filteredExams.length
}}
/>
{selectedIds.size === 0 ? t('workspace.batchSelectAll') : t('workspace.batchSelected', { count: selectedIds.size })}
</label>
{selectedIds.size > 0 && (
<>
<div style={{ width: 1, height: 16, background: 'var(--line-2)' }} />
<button
className="btn btn-sm admin-btn-danger-ghost"
onClick={() => void handleBatchDelete()}
disabled={batchDeleting}
style={{ fontSize: 12 }}
>
{batchDeleting ? t('common.loading') : `${t('common.delete')} (${selectedIds.size})`}
</button>
<button
className="btn btn-sm btn-ghost"
onClick={() => void handleBatchExport()}
disabled={batchExporting}
style={{ fontSize: 12 }}
>
{batchExporting ? t('common.loading') : `${t('workspace.export')} (${selectedIds.size})`}
</button>
<div style={{ position: 'relative' }}>
<button
className="btn btn-sm btn-ghost"
onClick={() => setBatchMoveOpen((v) => !v)}
disabled={batchMoving}
style={{ fontSize: 12 }}
>
{batchMoving ? t('common.loading') : `${t('workspace.moveTo')} (${selectedIds.size})`}
</button>
{batchMoveOpen && (
<div
style={{
position: 'absolute',
left: 0,
top: 'calc(100% + 4px)',
zIndex: 10,
minWidth: 180,
background: 'var(--paper)',
border: '1px solid var(--line-2)',
borderRadius: 8,
boxShadow: 'var(--shadow-md)',
padding: '6px 0',
}}
>
<button
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleBatchMove(null)}
>
{t('workspace.uncategorized')}
</button>
{collections.map((col) => (
<button
key={col.id}
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleBatchMove(col.id)}
>
{col.name}
</button>
))}
</div>
)}
</div>
</>
)}
</div>
)}
{error && (
<div className="workspace-alert workspace-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="workspace-empty">{t('common.loading')}</div>
) : filteredExams.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResults') : t('workspace.noSavedExams')}
</div>
<div className="workspace-empty-desc">
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResultsDesc') : t('workspace.noSavedExamsDesc')}
</div>
{!searchQuery && activeFilter === 'all' && (
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
{t('workspace.createFirstExam')}
</button>
)}
</div>
) : (
<div className="workspace-list">
{filteredExams.map((exam) => {
const tags = parseTags(exam.tags)
return (
<div key={exam.id} className={`workspace-card${exam.is_favorite ? ' workspace-card-favorite' : ''}`}>
<div className="workspace-card-body">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={selectedIds.has(exam.id)}
onChange={() => toggleSelect(exam.id)}
style={{ cursor: 'pointer' }}
/>
<button
className="btn btn-ghost btn-sm"
onClick={() => void handleFavorite(exam.id, !exam.is_favorite)}
title={exam.is_favorite ? t('workspace.unpin') : t('workspace.pin')}
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
>
{exam.is_favorite ? '★' : '☆'}
</button>
<div className="workspace-card-title">{exam.title}</div>
</div>
{exam.subtitle && (
<div className="workspace-card-sub">{exam.subtitle}</div>
)}
<div className="workspace-card-meta">
<span>{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
<span>·</span>
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
</div>
{tags.length > 0 && (
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
{tags.map((tag) => (
<span
key={tag}
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
background: 'var(--paper)',
color: 'var(--ink-3)',
border: '1px solid var(--line-2)',
}}
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="workspace-card-actions">
<button
className="btn btn-primary btn-sm"
onClick={() => void startExam(exam.id)}
disabled={startingId === exam.id}
>
{startingId === exam.id ? t('common.loading') : t('workspace.takeExam')}
</button>
<button
className="btn btn-sm"
onClick={() => navigate(`/exam-editor/${exam.id}`)}
disabled={startingId === exam.id}
>
{t('common.edit')}
</button>
<button
className="btn btn-sm"
onClick={() => void handleDuplicate(exam.id)}
disabled={duplicatingId === exam.id}
title={t('workspace.duplicate')}
>
{duplicatingId === exam.id ? t('common.loading') : t('workspace.duplicate')}
</button>
<button
className="btn btn-sm"
onClick={() => void handleExport(exam.id, exam.title)}
disabled={exportingId === exam.id}
title={t('workspace.export')}
>
{exportingId === exam.id ? t('common.loading') : '↓'}
</button>
{/* Move to collection dropdown */}
<div style={{ position: 'relative' }}>
<button
className="btn btn-sm"
onClick={() => setMoveMenuExamId((id) => (id === exam.id ? null : exam.id))}
disabled={movingExamId === exam.id}
title={t('workspace.moveTo')}
>
{movingExamId === exam.id ? t('common.loading') : '▤'}
</button>
{moveMenuExamId === exam.id && (
<div
style={{
position: 'absolute',
right: 0,
top: 'calc(100% + 4px)',
zIndex: 10,
minWidth: 180,
background: 'var(--paper)',
border: '1px solid var(--line-2)',
borderRadius: 8,
boxShadow: 'var(--shadow-md)',
padding: '6px 0',
}}
>
{exam.collection_id !== null && (
<button
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleMoveExam(exam.id, null)}
>
{t('workspace.removeFromCollection')}
</button>
)}
{collections.map((col) => (
col.id !== exam.collection_id && (
<button
key={col.id}
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleMoveExam(exam.id, col.id)}
>
{col.name}
</button>
)
))}
{collections.length === 0 && exam.collection_id === null && (
<div className="user-menu-item" style={{ fontSize: 13, color: 'var(--ink-3)', cursor: 'default' }}>
{t('workspace.noSavedExams')}
</div>
)}
</div>
)}
</div>
<div style={{ position: 'relative' }}>
<button
className="btn btn-sm"
onClick={() => setShareOpenId((id) => (id === exam.id ? null : exam.id))}
disabled={startingId === exam.id || sharingId === exam.id}
title={exam.share_token ? t('share.shared') : t('share.share')}
>
{sharingId === exam.id ? t('common.loading') : (exam.share_token ? t('share.shared') : t('share.share'))}
</button>
{shareOpenId === exam.id && (
<div
style={{
position: 'absolute',
right: 0,
top: 'calc(100% + 4px)',
zIndex: 10,
minWidth: 180,
background: 'var(--paper)',
border: '1px solid var(--line-2)',
borderRadius: 8,
boxShadow: 'var(--shadow-md)',
padding: '6px 0',
}}
>
{exam.share_token ? (
<>
<button
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => {
const url = `${window.location.origin}/share/${exam.share_token}`
void navigator.clipboard.writeText(url)
setShareOpenId(null)
}}
>
{t('share.copyLink')}
</button>
<button
className="user-menu-item"
style={{ fontSize: 13, color: 'var(--wrong)' }}
onClick={() => void handleUnshare(exam.id)}
>
{t('share.revokeLink')}
</button>
</>
) : (
<button
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleShare(exam.id)}
>
{t('share.generateLink')}
</button>
)}
</div>
)}
</div>
{deleteConfirm === exam.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
{t('common.confirm')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
{t('common.cancel')}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(exam.id)}
title={t('common.delete')}
disabled={startingId === exam.id}
>
✕
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
+336
View File
@@ -0,0 +1,336 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import * as resultApi from '../api/resultApi'
import * as examApi from '../api/examApi'
import { loadState } from '../utils/storage'
import { validateExam, parseExam } from '../utils/validation'
import type { Exam } from '../types'
import StudyCoach from './StudyCoach'
interface Props {
examCount: number
resultCount: number
recentResults: resultApi.ResultListItem[]
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
export default function Overview({ examCount, resultCount, recentResults, onLoadExam }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const { user } = useAuth()
const [dragOver, setDragOver] = useState(false)
const [uploadErr, setUploadErr] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const [trends, setTrends] = useState<resultApi.ScoreTrend[]>([])
const [trendsLoading, setTrendsLoading] = useState(true)
useEffect(() => {
resultApi.getScoreTrends()
.then((data) => setTrends(data))
.catch(() => {/* ignore */})
.finally(() => setTrendsLoading(false))
}, [])
const handleFile = async (file: File) => {
setUploadErr(null)
try {
const text = await file.text()
const data = JSON.parse(text) as unknown
const msg = validateExam(data)
if (msg) throw new Error(msg)
onLoadExam(parseExam(data), true)
} catch (e) {
setUploadErr(t('landing.loadError', { message: (e as Error).message }))
}
}
const [importUrl, setImportUrl] = useState('')
const [importing, setImporting] = useState(false)
const handleImportUrl = async () => {
if (!importUrl.trim()) return
setUploadErr(null)
setImporting(true)
try {
const res = await fetch(importUrl.trim())
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() as unknown
const msg = validateExam(data)
if (msg) throw new Error(msg)
onLoadExam(parseExam(data), true)
setImportUrl('')
} catch (e) {
setUploadErr(t('landing.loadError', { message: (e as Error).message }))
} finally {
setImporting(false)
}
}
const handleRetakeWeakest = async (examId: number) => {
try {
const exam = await examApi.getExam(examId)
onLoadExam(exam, false, examId)
} catch {
setUploadErr(t('landing.loadError', { message: 'Could not load exam' }))
}
}
const resumable = loadState()
const totalQuestions = recentResults.reduce((sum, r) => sum + (r.graded_count || 0), 0)
const avgScore = recentResults.length > 0
? Math.round(recentResults.reduce((sum, r) => sum + (r.total_points ? (r.score_points / r.total_points) * 100 : 0), 0) / recentResults.length)
: 0
// SVG chart dimensions
const margin = { top: 10, right: 20, bottom: 30, left: 40 }
const viewW = 520
const viewH = 200
const cw = viewW - margin.left - margin.right
const ch = viewH - margin.top - margin.bottom
const chartData = trends.slice(-20)
const hasChartData = chartData.length >= 2
const xMin = chartData.length ? chartData[0].completed_at : 0
const xMax = chartData.length ? chartData[chartData.length - 1].completed_at : 0
const xRange = xMax - xMin || 1
const xScale = (v: number) => margin.left + ((v - xMin) / xRange) * cw
const yScale = (v: number) => margin.top + ch - (v / 100) * ch
const linePoints = chartData.map((d, i) => {
const x = xScale(d.completed_at)
const y = yScale(d.score_pct)
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`
}).join(' ')
const gridLines = [0, 25, 50, 75, 100].map((pct) => ({
y: yScale(pct),
label: `${pct}%`,
}))
const xLabelIndices = [...new Set([0, Math.floor(chartData.length / 2), chartData.length - 1])]
return (
<div className="workspace-section fade-in">
{/* Dashboard Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, marginBottom: 24 }}>
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{examCount}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.myExams')}</div>
</div>
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{resultCount}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.totalAttempts')}</div>
</div>
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{avgScore}%</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.avgScore')}</div>
</div>
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{totalQuestions}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.questionsAnswered')}</div>
</div>
</div>
{/* Quick Actions */}
<div style={{ display: 'flex', gap: 12, marginBottom: 24, flexWrap: 'wrap' }}>
<button
type="button"
className={`dropzone${dragOver ? ' drag-over' : ''}`}
style={{ flex: 1, minWidth: 220, padding: 16 }}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault(); setDragOver(false)
const f = e.dataTransfer.files?.[0]
if (f) void handleFile(f)
}}
>
<div className="dropzone-icon" style={{ marginBottom: 6 }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
</svg>
</div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.uploadExam')}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{t('workspace.uploadDropzone')}</div>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
style={{ display: 'none' }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleFile(f) }}
/>
</button>
<div
className="workspace-card"
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}
>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.importFromUrl')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
className="input"
placeholder={t('workspace.importUrlPlaceholder')}
value={importUrl}
onChange={(e) => setImportUrl(e.target.value)}
style={{ fontSize: 12, padding: '4px 8px', flex: 1 }}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleImportUrl()}
disabled={importing || !importUrl.trim()}
style={{ fontSize: 12 }}
>
{importing ? t('workspace.importing') : t('workspace.import')}
</button>
</div>
</div>
<button
className="workspace-card"
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: 'var(--paper-2)' }}
onClick={() => navigate('/exam-editor/new')}
>
<div style={{ fontSize: 20, marginBottom: 6 }}>+</div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.newExam')}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{t('workspace.createFirstExam')}</div>
</button>
{resumable && (
<button
className="workspace-card"
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: 'var(--paper-2)', borderColor: 'var(--accent)' }}
onClick={() => onLoadExam(resumable.exam, false, null)}
>
<div style={{ fontSize: 20, marginBottom: 6 }}>▶</div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('landing.resumeTitle')}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
{resumable.exam.title}
</div>
</button>
)}
</div>
{uploadErr && (
<div style={{ marginBottom: 16, padding: '10px 14px', border: '1px solid var(--wrong)', borderRadius: 8, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>
{uploadErr}
</div>
)}
{/* Study Coach */}
<StudyCoach onRetakeWeakest={handleRetakeWeakest} />
{/* Performance Trends */}
{hasChartData && !trendsLoading && (
<div className="workspace-card" style={{ marginBottom: 24 }}>
<div style={{ padding: '16px 20px 4px' }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>{t('workspace.performanceTrends')}</h3>
<svg viewBox={`0 0 ${viewW} ${viewH}`} style={{ width: '100%', height: 'auto', overflow: 'visible' }}>
{/* Grid lines */}
{gridLines.map((g) => (
<g key={g.label}>
<line
x1={margin.left}
x2={margin.left + cw}
y1={g.y}
y2={g.y}
stroke="var(--line-2)"
strokeDasharray="2,2"
strokeWidth={1}
/>
<text x={margin.left - 6} y={g.y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)">
{g.label}
</text>
</g>
))}
{/* Trend line */}
<path
d={linePoints}
fill="none"
stroke="var(--accent)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Data points */}
{chartData.map((d, i) => {
const x = xScale(d.completed_at)
const y = yScale(d.score_pct)
const date = new Date(d.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
return (
<g key={i}>
<circle
cx={x}
cy={y}
r={4}
fill="var(--accent)"
stroke="var(--paper-2)"
strokeWidth={2}
>
<title>{`${d.title} — ${d.score_pct}% (${date})`}</title>
</circle>
</g>
)
})}
{/* X-axis labels */}
{xLabelIndices.map((idx) => {
const d = chartData[idx]
if (!d) return null
const x = xScale(d.completed_at)
const date = new Date(d.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
return (
<text key={idx} x={x} y={viewH - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)">
{date}
</text>
)
})}
</svg>
</div>
</div>
)}
{/* Recent Results */}
{recentResults.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{t('workspace.recentResults')}</h3>
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/results')}>
{t('workspace.viewAll')} →
</button>
</div>
<div className="workspace-list">
{recentResults.map((r) => {
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
const passed = pct >= (r.passing_score ?? 60)
const d = new Date(r.completed_at * 1000)
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
return (
<div key={r.id} className="workspace-card" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{r.title}</div>
<div className="muted" style={{ fontSize: 11 }}>{dateStr}</div>
</div>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`} style={{ fontSize: 11 }}>{pct}%</div>
</div>
)
})}
</div>
</div>
)}
{!user && (
<div className="workspace-card" style={{ padding: 20, textAlign: 'center' }}>
<div style={{ fontSize: 14, marginBottom: 8 }}>{t('workspace.signInPrompt')}</div>
<button className="btn btn-primary" onClick={() => { /* handled by parent */ }}>
{t('auth.signIn')}
</button>
</div>
)}
</div>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import * as examApi from '../api/examApi'
import type { Exam } from '../types'
export default function ShareView({ onLoadExam }: { onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void }) {
const { t } = useTranslation()
const { token } = useParams<{ token: string }>()
const navigate = useNavigate()
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<{ title: string; subtitle: string | null; questionCount: number; exam: Exam; shareMode: string } | null>(null)
const [cloning, setCloning] = useState(false)
useEffect(() => {
if (!token) {
setError('Invalid link')
setLoading(false)
return
}
examApi.getSharedExam(token)
.then((res) => {
setData({
title: res.title,
subtitle: res.subtitle,
questionCount: res.questionCount,
exam: res.exam as Exam,
shareMode: res.shareMode ?? 'clone',
})
setLoading(false)
})
.catch((e) => {
setError((e as Error).message)
setLoading(false)
})
}, [token])
const handleTake = () => {
if (!data) return
onLoadExam(data.exam, false, null)
}
const handleClone = async () => {
if (!token || !data) return
if (!user) {
navigate('/')
return
}
setCloning(true)
try {
await examApi.cloneSharedExam(token)
navigate('/workspace/exams')
} catch (e) {
setError((e as Error).message)
setCloning(false)
}
}
if (loading) {
return (
<div className="shell">
<div className="workspace-empty">{t('common.loading')}</div>
</div>
)
}
if (error || !data) {
return (
<div className="shell">
<div className="workspace-empty">
<div className="workspace-empty-icon">!</div>
<div className="workspace-empty-title">{error || 'Not found'}</div>
<button className="btn btn-primary" onClick={() => navigate('/')} style={{ marginTop: 16 }}>
{t('results.backToHome')}
</button>
</div>
</div>
)
}
return (
<div className="shell">
<div className="landing" style={{ maxWidth: 640, margin: '0 auto', paddingTop: 40 }}>
<div className="landing-eyebrow">{t('common.appName')}</div>
<h1 className="landing-title">{data.title}</h1>
{data.subtitle && <p className="landing-sub">{data.subtitle}</p>}
<div style={{ marginTop: 24, padding: 20, border: '1px solid var(--line-2)', borderRadius: 12, background: 'var(--paper-2)' }}>
<div style={{ fontSize: 14, marginBottom: 8 }}>
<span className="mono" style={{ color: 'var(--ink-3)' }}>{data.questionCount}</span> {data.questionCount === 1 ? t('common.question') : t('common.questions')}
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
<button className="btn btn-primary" onClick={handleTake}>
{t('share.takeExam')}
</button>
{user && data.shareMode !== 'view' ? (
<button className="btn btn-ghost" onClick={() => void handleClone()} disabled={cloning}>
{cloning ? t('common.loading') : t('share.saveToWorkspace')}
</button>
) : user ? (
<span className="muted" style={{ fontSize: 12, alignSelf: 'center' }}>
{t('workspace.shareModeView')}
</span>
) : (
<button className="btn btn-ghost" onClick={() => navigate('/')}>
{t('auth.signIn')} →
</button>
)}
</div>
</div>
</div>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as examApi from '../api/examApi'
import type { Exam } from '../types'
interface Props {
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
export default function SharedWithMe({ onLoadExam }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const [exams, setExams] = useState<examApi.ClonedExamItem[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await examApi.listClonedExams()
setExams(data)
} catch {
setExams([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
if (loading) {
return (
<div className="workspace-section">
<div className="workspace-empty" style={{ padding: 40 }}>{t('common.loading')}</div>
</div>
)
}
if (exams.length === 0) {
return (
<div className="workspace-section fade-in">
<h2 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 600 }}>{t('workspace.sharedWithMe')}</h2>
<div className="workspace-empty" style={{ padding: 40 }}>
<div className="workspace-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</div>
<div className="workspace-empty-title">{t('workspace.noSharedExams')}</div>
<div className="workspace-empty-desc">{t('workspace.noSharedExamsDesc')}</div>
</div>
</div>
)
}
return (
<div className="workspace-section fade-in">
<h2 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 600 }}>{t('workspace.sharedWithMe')}</h2>
<div className="workspace-list" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{exams.map((exam) => (
<div
key={exam.id}
className="workspace-card"
style={{ padding: 16, display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}
onClick={() => {
void examApi.getExam(exam.id).then((e) => onLoadExam(e, false, exam.id))
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{exam.title}</div>
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}
</div>
</div>
<button
className="btn btn-sm"
onClick={(ev) => {
ev.stopPropagation()
navigate(`/exam-editor/${exam.id}`)
}}
>
{t('common.edit')}
</button>
</div>
))}
</div>
</div>
)
}
+106
View File
@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import * as resultApi from '../api/resultApi'
interface Props {
onRetakeWeakest?: (examId: number) => void
}
export default function StudyCoach({ onRetakeWeakest }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const [data, setData] = useState<resultApi.StudyCoachResult | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
resultApi.analyzeResults()
.then((res) => setData(res))
.catch(() => {/* ignore */})
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="workspace-card" style={{ marginBottom: 24 }}>
<div className="workspace-empty" style={{ padding: 24 }}>{t('common.loading')}</div>
</div>
)
}
if (!data || (data.weakAreas.length === 0 && data.studyTips.length === 0 && !data.weakestExamId)) {
return null
}
return (
<div className="workspace-card" style={{ marginBottom: 24, borderLeft: '3px solid var(--accent)' }}>
<div style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 18 }}>🤖</span>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{t('workspace.studyCoachTitle')}</h3>
{data.streakDays > 0 && (
<span className="mono" style={{ fontSize: 12, color: 'var(--ink-3)', marginLeft: 'auto' }}>
🔥 {data.streakDays} {data.streakDays === 1 ? t('workspace.streakDay') : t('workspace.streakDays')}
</span>
)}
</div>
{data.weakestExamId && onRetakeWeakest && (
<div style={{ marginBottom: 12 }}>
<button
className="btn btn-sm"
style={{ background: 'var(--wrong)', color: 'white', borderColor: 'var(--wrong)' }}
onClick={() => onRetakeWeakest(data.weakestExamId!)}
>
🔄 {t('workspace.retakeWeakest')}
</button>
</div>
)}
{data.weakAreas.length > 0 && (
<div style={{ marginBottom: 12 }}>
{data.weakAreas.slice(0, 3).map((area) => (
<div key={area.category} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<div style={{ flex: 1, height: 6, background: 'var(--line-2)', borderRadius: 3, overflow: 'hidden' }}>
<div
style={{
width: `${area.avgScore}%`,
height: '100%',
background: area.avgScore >= 60 ? 'var(--ok)' : 'var(--wrong)',
borderRadius: 3,
}}
/>
</div>
<span className="mono" style={{ fontSize: 12, minWidth: 40, textAlign: 'right' }}>
{area.avgScore}%
</span>
<span style={{ fontSize: 13, minWidth: 120 }}>{area.category}</span>
</div>
))}
</div>
)}
{data.studyTips.length > 0 && (
<div style={{ padding: 12, background: 'var(--paper)', borderRadius: 8 }}>
<div className="mono" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--ink-3)', marginBottom: 8 }}>
{t('workspace.studyTips')}
</div>
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 13, lineHeight: 1.7, color: 'var(--ink-2)' }}>
{data.studyTips.map((tip, i) => (
<li key={i}>{tip}</li>
))}
</ul>
</div>
)}
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: 12 }}
onClick={() => navigate('/workspace/results')}
>
{t('workspace.viewAllResults')} →
</button>
</div>
</div>
)
}
+30 -6
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import type { ReactNode } from 'react'
import { useNavigate, useParams, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
@@ -9,11 +10,24 @@ import MyExams from './MyExams'
import MyResults from './MyResults'
import Settings from './Settings'
import StorageManager from './StorageManager'
import Overview from './Overview'
import SharedWithMe from './SharedWithMe'
type TabKey = 'exams' | 'results' | 'storage' | 'settings'
type TabKey = 'overview' | 'exams' | 'shared' | 'results' | 'storage' | 'settings'
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
const TAB_KEYS: { key: TabKey; labelKey: string; icon: ReactNode }[] = [
{ key: 'overview', labelKey: 'workspace.overview', icon: '◈' },
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
{
key: 'shared',
labelKey: 'workspace.sharedWithMe',
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
),
},
{ key: 'results', labelKey: 'workspace.results', icon: '✦' },
{ key: 'storage', labelKey: 'workspace.storage', icon: '◉' },
{ key: 'settings', labelKey: 'workspace.settings', icon: '⚙' },
@@ -28,9 +42,10 @@ export default function Workspace({ onLoadExam }: Props) {
const { user, loading: authLoading } = useAuth()
const navigate = useNavigate()
const { tab } = useParams<{ tab: string }>()
const activeTab = (tab as TabKey) ?? 'exams'
const activeTab = (tab as TabKey) ?? 'overview'
const [examCount, setExamCount] = useState(0)
const [resultCount, setResultCount] = useState(0)
const [recentResults, setRecentResults] = useState<resultApi.ResultListItem[]>([])
const loadCounts = useCallback(async () => {
try {
@@ -38,6 +53,7 @@ export default function Workspace({ onLoadExam }: Props) {
setExamCount(exams.length)
const results = await resultApi.listResults()
setResultCount(results.total)
setRecentResults(results.results.slice(0, 5))
} catch {
// silently ignore
}
@@ -47,11 +63,11 @@ export default function Workspace({ onLoadExam }: Props) {
if (user) void loadCounts()
}, [user, loadCounts])
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
if (authLoading) return null
if (!user) return <Navigate to="/" replace />
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
return (
<div className="workspace-shell">
<aside className="workspace-sidebar">
@@ -60,7 +76,6 @@ export default function Workspace({ onLoadExam }: Props) {
{user.displayName.charAt(0).toUpperCase()}
</div>
<div className="workspace-name">{user.displayName}</div>
<div className="workspace-email">{user.email}</div>
</div>
<nav className="workspace-nav">
@@ -84,7 +99,16 @@ export default function Workspace({ onLoadExam }: Props) {
</aside>
<main className="workspace-main">
{activeTab === 'overview' && (
<Overview
examCount={examCount}
resultCount={resultCount}
recentResults={recentResults}
onLoadExam={onLoadExam}
/>
)}
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
{activeTab === 'shared' && <SharedWithMe onLoadExam={onLoadExam} />}
{activeTab === 'results' && <MyResults onLoadExam={onLoadExam} />}
{activeTab === 'storage' && <StorageManager />}
{activeTab === 'settings' && <Settings />}