Public Access
feat: add collections management and study coach features
- 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:
1 parent
7755e77039
commit
1ec1633dd9
26 files changed
+2557
-127
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
Reference in new issue
Block a user