feat(03-05): create QuotaBar.vue; embed in AppSidebar between topics nav and footer

- QuotaBar.vue: new sidebar quota widget — onMounted calls authStore.fetchQuota(); computed pct/barColor/labelColor/label from authStore.quota; color thresholds <80% indigo-500, 80-95% amber-500, >=95% red-500; skeleton loading state (animate-pulse); hides on loadFailed (v-if); role=progressbar with aria-valuenow/aria-valuemin/aria-valuemax/aria-label
- AppSidebar.vue: import QuotaBar; insert <QuotaBar /> between </nav> and settings footer div
- Frontend build exits 0 (verified)
This commit is contained in:
curo1305
2026-05-23 20:49:07 +02:00
parent eb18428d07
commit 23c568ae89
2 changed files with 99 additions and 0 deletions
@@ -54,6 +54,9 @@
</div> </div>
</nav> </nav>
<!-- Quota bar (between topics nav and settings footer, UI-SPEC Phase 3) -->
<QuotaBar />
<!-- Settings + Admin link --> <!-- Settings + Admin link -->
<div class="px-3 py-4 border-t border-gray-100"> <div class="px-3 py-4 border-t border-gray-100">
<!-- Admin link (admin users only) --> <!-- Admin link (admin users only) -->
@@ -108,6 +111,7 @@
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useTopicsStore } from '../../stores/topics.js' import { useTopicsStore } from '../../stores/topics.js'
import { useAuthStore } from '../../stores/auth.js' import { useAuthStore } from '../../stores/auth.js'
import QuotaBar from './QuotaBar.vue'
const topicsStore = useTopicsStore() const topicsStore = useTopicsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -0,0 +1,95 @@
<template>
<div v-if="!loadFailed" class="px-4 py-3 border-t border-gray-100">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-semibold text-gray-500">Storage</span>
<span v-if="ready" class="text-sm" :class="labelColor">{{ label }}</span>
</div>
<!-- Real bar (shown after first successful fetchQuota) -->
<div
v-if="ready"
class="w-full h-2 bg-gray-200 rounded-full overflow-hidden"
>
<div
role="progressbar"
:aria-valuenow="pct"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="`Storage usage: ${label}`"
class="h-2 rounded-full transition-all duration-500"
:class="barColor"
:style="{ width: `${pct}%` }"
></div>
</div>
<!-- Skeleton loading state (before first fetchQuota completes) -->
<div
v-else
class="w-full h-2 bg-gray-100 rounded-full animate-pulse"
aria-label="Loading storage usage"
aria-busy="true"
></div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth.js'
const authStore = useAuthStore()
const ready = ref(false)
const loadFailed = ref(false)
/**
* pct — usage percentage clamped to 0100.
* When limit_bytes is 0 (initial state or server returned 0), returns 0 — bar stays at 0%.
* UI-SPEC: do not hide widget when used_bytes=0.
*/
const pct = computed(() => {
const { used_bytes, limit_bytes } = authStore.quota
if (!limit_bytes || limit_bytes <= 0) return 0
return Math.min(100, Math.round((used_bytes / limit_bytes) * 100))
})
/**
* Bar fill color per UI-SPEC Quota Bar Color Logic:
* < 80% → bg-indigo-500
* 8095% → bg-amber-500
* ≥ 95% → bg-red-500
*/
const barColor = computed(() => {
if (pct.value >= 95) return 'bg-red-500'
if (pct.value >= 80) return 'bg-amber-500'
return 'bg-indigo-500'
})
/**
* Label text color follows bar color thresholds (UI-SPEC):
* < 80% → text-gray-500
* 8095% → text-amber-600
* ≥ 95% → text-red-600
*/
const labelColor = computed(() => {
if (pct.value >= 95) return 'text-red-600'
if (pct.value >= 80) return 'text-amber-600'
return 'text-gray-500'
})
/**
* Label format: "{used} MB of {limit} MB" (1 decimal place, UI-SPEC)
*/
const label = computed(() => {
const used = (authStore.quota.used_bytes / 1048576).toFixed(1)
const limit = (authStore.quota.limit_bytes / 1048576).toFixed(1)
return `${used} MB of ${limit} MB`
})
onMounted(async () => {
try {
await authStore.fetchQuota()
ready.value = true
} catch {
// fetchQuota itself swallows errors; this catch handles any unexpected re-throw.
// On any error: hide widget entirely (loadFailed=true → v-if removes DOM, UI-SPEC)
loadFailed.value = true
}
})
</script>