Public Access
feat: add storage management features including user storage quota and usage tracking
- Updated database schema to include `storage_quota_bytes` and `plan` for users. - Implemented storage usage calculations in `storageRepo` and added tests for storage functionality. - Created `StorageManager` component to display and manage user storage. - Enhanced user settings to show storage details and warnings for high usage. - Added translations for new storage-related strings in English and Dutch. - Updated styles for storage overview and details in CSS.
This commit is contained in:
1 parent
c002f42459
commit
acbcfde617
15 files changed
+694
-14
No files matched your search
@@ -25,6 +25,54 @@ function Seg<K extends string>({ value, options, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function StoragePreview() {
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuth()
|
||||
const used = user?.usedBytes ?? 0
|
||||
const quota = user?.storageQuotaBytes ?? 1073741824
|
||||
const percent = user?.usedPercent ?? 0
|
||||
|
||||
let barColor = 'var(--accent)'
|
||||
let warningText: string | null = null
|
||||
if (percent >= 95) {
|
||||
barColor = 'var(--wrong)'
|
||||
warningText = t('workspace.storageWarningCritical')
|
||||
} else if (percent >= 80) {
|
||||
barColor = 'var(--warn)'
|
||||
warningText = t('workspace.storageWarningHigh')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="storage-overview">
|
||||
<div className="storage-bar-track">
|
||||
<div
|
||||
className="storage-bar-fill"
|
||||
style={{ width: `${Math.min(percent, 100)}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="storage-stats">
|
||||
<span className="storage-used">{formatBytes(used)}</span>
|
||||
<span className="storage-of"> {t('workspace.storageOf')} </span>
|
||||
<span className="storage-total">{formatBytes(quota)}</span>
|
||||
<span className="storage-percent"> ({percent}%)</span>
|
||||
</div>
|
||||
{warningText && (
|
||||
<div className={`workspace-alert ${percent >= 95 ? 'workspace-alert-error' : 'workspace-alert-warning'}`}>
|
||||
{warningText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { user, refreshUser } = useAuth()
|
||||
@@ -288,6 +336,15 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workspace-panel">
|
||||
<div className="workspace-panel-header">
|
||||
<h3>{t('workspace.storage')}</h3>
|
||||
</div>
|
||||
<div className="workspace-panel-body workspace-settings-form">
|
||||
<StoragePreview />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import * as examApi from '../api/examApi'
|
||||
import * as resultApi from '../api/resultApi'
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export default function StorageManager() {
|
||||
const { t } = useTranslation()
|
||||
const { user, refreshUser } = useAuth()
|
||||
const [exams, setExams] = useState<examApi.ExamListItem[]>([])
|
||||
const [results, setResults] = useState<resultApi.ResultListItem[]>([])
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [deleting, setDeleting] = useState<number | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const examList = await examApi.listExams()
|
||||
setExams(examList)
|
||||
const resultList = await resultApi.listResults()
|
||||
setResults(resultList.results)
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const handleDeleteExam = useCallback(async (id: number) => {
|
||||
if (!window.confirm(t('workspace.deleteExamConfirm'))) return
|
||||
setDeleting(id)
|
||||
try {
|
||||
await examApi.deleteExam(id)
|
||||
await loadData()
|
||||
await refreshUser()
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}, [loadData, refreshUser, t])
|
||||
|
||||
const handleDeleteResult = useCallback(async (id: number) => {
|
||||
if (!window.confirm(t('workspace.deleteResultConfirm'))) return
|
||||
setDeleting(id)
|
||||
try {
|
||||
await resultApi.deleteResult(id)
|
||||
await loadData()
|
||||
await refreshUser()
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}, [loadData, refreshUser, t])
|
||||
|
||||
const used = user?.usedBytes ?? 0
|
||||
const quota = user?.storageQuotaBytes ?? 1073741824
|
||||
const percent = user?.usedPercent ?? 0
|
||||
|
||||
let barColor = 'var(--accent)'
|
||||
let warningText: string | null = null
|
||||
if (percent >= 95) {
|
||||
barColor = 'var(--wrong)'
|
||||
warningText = t('workspace.storageWarningCritical')
|
||||
} else if (percent >= 80) {
|
||||
barColor = 'var(--warn)'
|
||||
warningText = t('workspace.storageWarningHigh')
|
||||
}
|
||||
|
||||
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.storage')}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workspace-panel">
|
||||
<div className="workspace-panel-header">
|
||||
<h3>{t('workspace.storageUsage')}</h3>
|
||||
</div>
|
||||
<div className="workspace-panel-body workspace-settings-form">
|
||||
<div className="storage-overview">
|
||||
<div className="storage-bar-track">
|
||||
<div
|
||||
className="storage-bar-fill"
|
||||
style={{ width: `${Math.min(percent, 100)}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="storage-stats">
|
||||
<span className="storage-used">{formatBytes(used)}</span>
|
||||
<span className="storage-of"> {t('workspace.storageOf')} </span>
|
||||
<span className="storage-total">{formatBytes(quota)}</span>
|
||||
<span className="storage-percent"> ({percent}%)</span>
|
||||
</div>
|
||||
{warningText && (
|
||||
<div className={`workspace-alert ${percent >= 95 ? 'workspace-alert-error' : 'workspace-alert-warning'}`}>
|
||||
{warningText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="storage-breakdown">
|
||||
<div className="storage-breakdown-row">
|
||||
<span>{t('workspace.storageExams')}</span>
|
||||
<span>{exams.length}</span>
|
||||
</div>
|
||||
<div className="storage-breakdown-row">
|
||||
<span>{t('workspace.storageResults')}</span>
|
||||
<span>{results.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workspace-settings-footer">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setShowDetails((v) => !v)}
|
||||
>
|
||||
{showDetails ? t('workspace.hideDetails') : t('workspace.showDetails')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="workspace-panel">
|
||||
<div className="workspace-panel-header">
|
||||
<h3>{t('workspace.storageDetails')}</h3>
|
||||
</div>
|
||||
<div className="workspace-panel-body">
|
||||
{exams.length === 0 && results.length === 0 ? (
|
||||
<p className="text-muted">{t('workspace.noStorageItems')}</p>
|
||||
) : (
|
||||
<div className="storage-detail-list">
|
||||
{exams.map((exam) => (
|
||||
<div key={`exam-${exam.id}`} className="storage-detail-item">
|
||||
<div className="storage-detail-info">
|
||||
<span className="storage-detail-type">{t('workspace.exam')}</span>
|
||||
<span className="storage-detail-title">{exam.title}</span>
|
||||
</div>
|
||||
<div className="storage-detail-actions">
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDeleteExam(exam.id)}
|
||||
disabled={deleting === exam.id}
|
||||
>
|
||||
{deleting === exam.id ? t('common.loading') : t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{results.map((result) => (
|
||||
<div key={`result-${result.id}`} className="storage-detail-item">
|
||||
<div className="storage-detail-info">
|
||||
<span className="storage-detail-type">{t('workspace.result')}</span>
|
||||
<span className="storage-detail-title">{result.title}</span>
|
||||
</div>
|
||||
<div className="storage-detail-actions">
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDeleteResult(result.id)}
|
||||
disabled={deleting === result.id}
|
||||
>
|
||||
{deleting === result.id ? t('common.loading') : t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,14 @@ import type { Exam } from '../types'
|
||||
import MyExams from './MyExams'
|
||||
import MyResults from './MyResults'
|
||||
import Settings from './Settings'
|
||||
import StorageManager from './StorageManager'
|
||||
|
||||
type TabKey = 'exams' | 'results' | 'settings'
|
||||
type TabKey = 'exams' | 'results' | 'storage' | 'settings'
|
||||
|
||||
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
|
||||
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
|
||||
{ key: 'results', labelKey: 'workspace.results', icon: '✦' },
|
||||
{ key: 'storage', labelKey: 'workspace.storage', icon: '◉' },
|
||||
{ key: 'settings', labelKey: 'workspace.settings', icon: '⚙' },
|
||||
]
|
||||
|
||||
@@ -84,6 +86,7 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
<main className="workspace-main">
|
||||
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
|
||||
{activeTab === 'results' && <MyResults onLoadExam={onLoadExam} />}
|
||||
{activeTab === 'storage' && <StorageManager />}
|
||||
{activeTab === 'settings' && <Settings />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user