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:
@@ -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 0–100.
|
||||||
|
* 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
|
||||||
|
* 80–95% → 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
|
||||||
|
* 80–95% → 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>
|
||||||
Reference in New Issue
Block a user