feat: add storage management features including user storage quota and usage tracking
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s

- 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:
LaurensBartelds committed 2026-04-27 15:29:43 +02:00
1 parent c002f42459
commit acbcfde617
15 files changed
+694 -14

No files matched your search

+11 -5
View File
@@ -4,17 +4,23 @@ import path from 'path'
import { config } from '../config.js'
import { runMigrations } from './migrations.js'
const DB_PATH = path.isAbsolute(config.dbPath)
? config.dbPath
: path.join(process.cwd(), config.dbPath)
const DB_PATH = config.dbPath === ':memory:'
? ':memory:'
: path.isAbsolute(config.dbPath)
? config.dbPath
: path.join(process.cwd(), config.dbPath)
let db: Database.Database | null = null
export function getDb(): Database.Database {
if (!db) {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
if (DB_PATH !== ':memory:') {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
}
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
if (DB_PATH !== ':memory:') {
db.pragma('journal_mode = WAL')
}
db.pragma('foreign_keys = ON')
runMigrations(db)
}
+10
View File
@@ -61,6 +61,16 @@ export function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
}
// ── Add storage_quota_bytes if missing ──
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
}
// ── Add plan if missing ──
if (!cols.some((c) => c.name === "plan")) {
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
}
// ── Promote first user to admin if no admins exist ──
const adminCount = db
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest'
// Set env vars before any module that reads them at import time
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-unit-tests-only'
process.env.DB_PATH = ':memory:'
// Dynamic imports ensure env vars are set before module initialisation
const { getDb, closeDb } = await import('../db/database.js')
const userRepo = await import('./userRepo.js')
const storageRepo = await import('./storageRepo.js')
describe('storageRepo', () => {
it('returns zero usage for a new user', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-test@example.com',
passwordHash: 'hash',
displayName: 'Storage Test',
emailVerified: 1,
})
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.usedBytes).toBe(0)
expect(usage.examBytes).toBe(0)
expect(usage.resultBytes).toBe(0)
})
it('calculates exam JSON length correctly', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-exam@example.com',
passwordHash: 'hash',
displayName: 'Exam Storage',
emailVerified: 1,
})
const db = getDb()
const examJson = JSON.stringify({ title: 'Test Exam', questions: [] })
db.prepare(`
INSERT INTO saved_exams (user_id, title, exam_json)
VALUES (?, ?, ?)
`).run(userId, 'Test Exam', examJson)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.examBytes).toBe(examJson.length)
expect(usage.usedBytes).toBe(examJson.length)
})
it('calculates result JSON length correctly', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-result@example.com',
passwordHash: 'hash',
displayName: 'Result Storage',
emailVerified: 1,
})
const db = getDb()
const stateJson = JSON.stringify({ answers: [1, 2, 3] })
const examJson = JSON.stringify({ title: 'Result Exam' })
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result Exam', 10, 20, 5, 10, 60000, stateJson, examJson)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.resultBytes).toBe(stateJson.length + examJson.length)
expect(usage.usedBytes).toBe(stateJson.length + examJson.length)
})
it('aggregates multiple exams and results', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-multi@example.com',
passwordHash: 'hash',
displayName: 'Multi Storage',
emailVerified: 1,
})
const db = getDb()
const examJson1 = JSON.stringify({ title: 'Exam 1' })
const examJson2 = JSON.stringify({ title: 'Exam 2', extra: 'data' })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Exam 1', examJson1)
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Exam 2', examJson2)
const stateJson = JSON.stringify({ answers: [] })
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, examJson1)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.examBytes).toBe(examJson1.length + examJson2.length)
expect(usage.resultBytes).toBe(stateJson.length + examJson1.length)
expect(usage.usedBytes).toBe(examJson1.length + examJson2.length + stateJson.length + examJson1.length)
})
it('lists storage items sorted by size desc', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-items@example.com',
passwordHash: 'hash',
displayName: 'Items Storage',
emailVerified: 1,
})
const db = getDb()
const smallExam = JSON.stringify({ title: 'Small' })
const largeExam = JSON.stringify({ title: 'Large Exam', content: 'x'.repeat(100) })
const stateJson = JSON.stringify({ a: 'y'.repeat(50) })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Small Exam', smallExam)
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Large Exam', largeExam)
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, smallExam)
const items = storageRepo.listStorageItems(userId)
expect(items.length).toBe(3)
expect(items[0].title).toBe('Large Exam')
expect(items[0].type).toBe('exam')
expect(items[1].title).toBe('Result')
expect(items[1].type).toBe('result')
expect(items[2].title).toBe('Small Exam')
expect(items[2].type).toBe('exam')
})
it('does not include other users data', () => {
const { id: userA } = userRepo.createUser({
email: 'user-a@example.com',
passwordHash: 'hash',
displayName: 'User A',
emailVerified: 1,
})
const { id: userB } = userRepo.createUser({
email: 'user-b@example.com',
passwordHash: 'hash',
displayName: 'User B',
emailVerified: 1,
})
const db = getDb()
const examJson = JSON.stringify({ title: 'A Exam' })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userA, 'A Exam', examJson)
const usageA = storageRepo.getUserStorageUsage(userA)
const usageB = storageRepo.getUserStorageUsage(userB)
expect(usageA.usedBytes).toBe(examJson.length)
expect(usageB.usedBytes).toBe(0)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { getDb } from '../db/database.js'
export interface StorageUsage {
usedBytes: number
examBytes: number
resultBytes: number
}
export interface StorageItem {
id: number
title: string
type: 'exam' | 'result'
sizeBytes: number
createdAt: number
}
export function getUserStorageUsage(userId: number): StorageUsage {
const db = getDb()
const examRow = db.prepare(`
SELECT COALESCE(SUM(LENGTH(exam_json)), 0) AS total
FROM saved_exams
WHERE user_id = ?
`).get(userId) as { total: number }
const resultRow = db.prepare(`
SELECT COALESCE(SUM(LENGTH(state_json) + LENGTH(exam_json)), 0) AS total
FROM exam_results
WHERE user_id = ?
`).get(userId) as { total: number }
return {
usedBytes: examRow.total + resultRow.total,
examBytes: examRow.total,
resultBytes: resultRow.total,
}
}
export function listStorageItems(userId: number): StorageItem[] {
const db = getDb()
const exams = db.prepare(`
SELECT id, title, LENGTH(exam_json) AS size_bytes, created_at
FROM saved_exams
WHERE user_id = ?
ORDER BY size_bytes DESC
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
const results = db.prepare(`
SELECT id, title, LENGTH(state_json) + LENGTH(exam_json) AS size_bytes, completed_at AS created_at
FROM exam_results
WHERE user_id = ?
ORDER BY size_bytes DESC
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
const items: StorageItem[] = [
...exams.map((e) => ({ id: e.id, title: e.title, type: 'exam' as const, sizeBytes: e.size_bytes, createdAt: e.created_at })),
...results.map((r) => ({ id: r.id, title: r.title, type: 'result' as const, sizeBytes: r.size_bytes, createdAt: r.created_at })),
]
items.sort((a, b) => b.sizeBytes - a.sizeBytes)
return items
}
+19 -3
View File
@@ -11,6 +11,8 @@ export interface DbUser {
verify_expires: number | null
reset_token: string | null
reset_expires: number | null
storage_quota_bytes: number
plan: string
created_at: number
updated_at: number
}
@@ -23,6 +25,8 @@ export interface CreateUserData {
emailVerified?: number
verifyToken?: string | null
verifyExpires?: number | null
storageQuotaBytes?: number
plan?: string
}
export interface UpdateUserData {
@@ -34,13 +38,15 @@ export interface UpdateUserData {
verifyExpires?: number | null
resetToken?: string | null
resetExpires?: number | null
storageQuotaBytes?: number
plan?: string
}
export function createUser(data: CreateUserData): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires, storage_quota_bytes, plan)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.email.toLowerCase().trim(),
data.passwordHash,
@@ -48,7 +54,9 @@ export function createUser(data: CreateUserData): { id: number } {
data.role ?? 'user',
data.emailVerified ?? 1,
data.verifyToken ?? null,
data.verifyExpires ?? null
data.verifyExpires ?? null,
data.storageQuotaBytes ?? 1073741824,
data.plan ?? 'free'
)
return { id: result.lastInsertRowid as number }
}
@@ -110,6 +118,14 @@ export function updateUser(id: number, data: UpdateUserData): boolean {
fields.push('reset_expires = ?')
values.push(data.resetExpires)
}
if (data.storageQuotaBytes !== undefined) {
fields.push('storage_quota_bytes = ?')
values.push(data.storageQuotaBytes)
}
if (data.plan !== undefined) {
fields.push('plan = ?')
values.push(data.plan)
}
if (fields.length === 0) return false
+9
View File
@@ -3,6 +3,7 @@ import * as userRepo from '../repositories/userRepo.js'
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
import { AppError } from '../middleware/error.js'
import * as storageRepo from '../repositories/storageRepo.js'
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
const SALT_ROUNDS = 10
@@ -93,12 +94,20 @@ export function getMe(userId: number) {
if (!user) {
throw new AppError(404, 'User not found')
}
const usage = storageRepo.getUserStorageUsage(userId)
const usedPercent = user.storage_quota_bytes > 0
? Math.round((usage.usedBytes / user.storage_quota_bytes) * 100)
: 0
return {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
emailVerified: !!user.email_verified,
storageQuotaBytes: user.storage_quota_bytes,
usedBytes: usage.usedBytes,
usedPercent,
plan: user.plan,
}
}
+4
View File
@@ -6,6 +6,10 @@ export interface User {
displayName: string
role: string
emailVerified?: boolean
storageQuotaBytes?: number
usedBytes?: number
usedPercent?: number
plan?: string
}
export interface AuthResponse {
+57
View File
@@ -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>
)
}
+187
View File
@@ -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>
)
}
+4 -1
View File
@@ -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>
+18 -2
View File
@@ -9,6 +9,10 @@ export interface User {
email: string
displayName: string
role: 'user' | 'admin'
storageQuotaBytes?: number
usedBytes?: number
usedPercent?: number
plan?: string
}
interface AuthContextValue {
@@ -47,7 +51,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return
}
const data = await authApi.getMe()
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: (data.role as 'user' | 'admin') ?? 'user' })
setUser({
id: data.id,
email: data.email,
displayName: data.displayName,
role: (data.role as 'user' | 'admin') ?? 'user',
storageQuotaBytes: data.storageQuotaBytes,
usedBytes: data.usedBytes,
usedPercent: data.usedPercent,
plan: data.plan,
})
} catch {
clearToken()
setUser(null)
@@ -63,7 +76,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (email: string, password: string) => {
const data = await authApi.login({ email, password })
setToken(data.token)
setUser({ ...data.user, role: (data.user.role as 'user' | 'admin') ?? 'user' })
setUser({
...data.user,
role: (data.user.role as 'user' | 'admin') ?? 'user',
})
}, [])
const register = useCallback(async (email: string, password: string, displayName: string): Promise<string> => {
+16 -1
View File
@@ -185,7 +185,22 @@
"textSizeDesc": "Adjust the base font size for readability",
"defaultFeedback": "Default Feedback",
"defaultFeedbackLabel": "Feedback mode",
"defaultFeedbackDesc": "Choose when to see answer feedback during exams"
"defaultFeedbackDesc": "Choose when to see answer feedback during exams",
"storage": "Storage",
"storageUsage": "Storage Usage",
"storageOf": "of",
"storageExams": "Saved exams",
"storageResults": "Results",
"showDetails": "Show details",
"hideDetails": "Hide details",
"storageDetails": "Storage Details",
"noStorageItems": "No items using storage.",
"exam": "Exam",
"result": "Result",
"deleteExamConfirm": "Delete this exam? This cannot be undone.",
"deleteResultConfirm": "Delete this result? This cannot be undone.",
"storageWarningHigh": "You are using over 80% of your storage.",
"storageWarningCritical": "You are using over 95% of your storage. Delete some items to free up space."
},
"admin": {
"administration": "Administration",
+16 -1
View File
@@ -185,7 +185,22 @@
"textSizeDesc": "Pas de basislettergrootte aan voor leesbaarheid",
"defaultFeedback": "Standaard Feedback",
"defaultFeedbackLabel": "Feedbackmodus",
"defaultFeedbackDesc": "Kies wanneer je antwoordfeedback ziet tijdens examens"
"defaultFeedbackDesc": "Kies wanneer je antwoordfeedback ziet tijdens examens",
"storage": "Opslag",
"storageUsage": "Opslaggebruik",
"storageOf": "van",
"storageExams": "Opgeslagen examens",
"storageResults": "Resultaten",
"showDetails": "Toon details",
"hideDetails": "Verberg details",
"storageDetails": "Opslagdetails",
"noStorageItems": "Geen items die opslag gebruiken.",
"exam": "Examen",
"result": "Resultaat",
"deleteExamConfirm": "Dit examen verwijderen? Dit kan niet ongedaan worden gemaakt.",
"deleteResultConfirm": "Dit resultaat verwijderen? Dit kan niet ongedaan worden gemaakt.",
"storageWarningHigh": "Je gebruikt meer dan 80% van je opslag.",
"storageWarningCritical": "Je gebruikt meer dan 95% van je opslag. Verwijder enkele items om ruimte vrij te maken."
},
"admin": {
"administration": "Beheer",
+124
View File
@@ -2664,6 +2664,12 @@ select {
border: 1px solid var(--correct);
}
.workspace-alert-warning {
background: var(--warn-weak);
color: var(--warn);
border: 1px solid var(--warn);
}
.workspace-alert-close {
background: none;
border: none;
@@ -2921,6 +2927,113 @@ select {
Workspace / Editor Responsive
============================================================ */
/* ---------- Storage ---------- */
.storage-overview {
margin-bottom: 16px;
}
.storage-bar-track {
height: 8px;
background: var(--paper-3);
border-radius: 999px;
overflow: hidden;
margin-bottom: 8px;
}
.storage-bar-fill {
height: 100%;
border-radius: 999px;
transition: width 300ms ease, background-color 300ms ease;
}
.storage-stats {
font-size: 13.5px;
color: var(--ink-2);
margin-bottom: 4px;
}
.storage-used {
font-weight: 600;
color: var(--ink);
}
.storage-of {
color: var(--ink-3);
}
.storage-total {
font-weight: 500;
}
.storage-percent {
color: var(--ink-3);
font-family: var(--font-mono);
font-size: 12px;
}
.storage-breakdown {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.storage-breakdown-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13.5px;
padding: 8px 12px;
background: var(--paper-2);
border-radius: var(--r-md);
color: var(--ink-2);
}
.storage-detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.storage-detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--paper);
}
.storage-detail-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.storage-detail-type {
font-family: var(--font-mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-4);
background: var(--paper-2);
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.storage-detail-title {
font-size: 14px;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.storage-detail-actions {
flex-shrink: 0;
}
.btn-danger {
background: var(--wrong-weak);
color: var(--wrong);
border-color: var(--wrong);
}
.btn-danger:hover {
background: var(--wrong);
color: var(--paper);
}
.text-muted {
color: var(--ink-3);
font-size: 13.5px;
}
@media (max-width: 900px) {
.workspace-shell {
grid-template-columns: 1fr;
@@ -2992,4 +3105,15 @@ select {
max-width: 100%;
width: 100%;
}
.storage-detail-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.storage-detail-actions {
width: 100%;
}
.storage-detail-actions .btn {
width: 100%;
}
}
@@ -1 +1 @@
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.013800000000003,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":5.808800000000019,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.428100000000029,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.740099999999984,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":7.838999999999999,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":6.458100000000002,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":6.4299000000000035,"failed":false}],[":backend/dist/config.test.js",{"duration":3.8840999999999894,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":8.53079999999997,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.639899999999955,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":7.222399999999993,"failed":false}],[":backend/src/config.test.ts",{"duration":4.612200000000001,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.7660000000000196,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":135.6874,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":5.303899999999999,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":133.43280000000001,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.7246000000000095,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.102000000000004,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.396100000000018,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":5.6451999999999884,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":2.9522999999999797,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.4879999999999995,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":3.3067000000000064,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.663899999999984,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":2.3597999999999786,"failed":false}]]}
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.442099999999982,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":5.80680000000001,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":12.700600000000009,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.035500000000013,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":5.9454000000000065,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":6.9056999999999675,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":7.014700000000005,"failed":false}],[":backend/dist/config.test.js",{"duration":4.3396999999999935,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.116100000000017,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":11.073699999999974,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":6.721499999999992,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0815000000000055,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.222700000000032,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":166.78979999999999,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.239000000000004,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":118.4222,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.551799999999986,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.303799999999995,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.920199999999994,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":6.263800000000003,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":2.775499999999994,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.524900000000002,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.508899999999983,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.431699999999978,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":2.2516999999999996,"failed":false}],[":backend/src/repositories/storageRepo.test.ts",{"duration":10.521900000000016,"failed":false}]]}