feat(client): add staging and statistics nav buttons to header

This commit is contained in:
neon
2026-03-20 03:19:38 -06:00
parent 72f587859b
commit 7d3a88e23d
7 changed files with 260 additions and 129 deletions
+145 -69
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ArrowLeft, Search, Palette, Upload, X, KeyRound, Settings, LogOut } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { ArrowLeft, Search, Palette, Upload, X, KeyRound, Settings, LogOut, PackageOpen, BarChart3 } from 'lucide-vue-next'
import { useRouter, useRoute } from 'vue-router'
import { toast } from 'vue-sonner'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator'
@@ -27,12 +27,26 @@ import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import BookUploadModal from '@/features/library/components/BookUploadModal.vue'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useStagingSummary } from '@/features/staging/composables/useStagingSummary'
const router = useRouter()
const route = useRoute()
const { user, logout } = useAuth()
const { open: openChangePassword } = useChangePasswordDialog()
const { hasPermission } = usePermissions()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { summary: stagingSummary, fetchSummary: fetchStagingSummary, subscribe: subscribeStagingSummary } = useStagingSummary()
const isStagingActive = computed(() => route.name === 'staging')
const isStatisticsActive = computed(() => route.name === 'statistics')
function navigateToStaging() {
router.push({ name: 'staging' })
}
function navigateToStatistics() {
router.push({ name: 'statistics' })
}
const uploadOpen = ref(false)
@@ -115,7 +129,13 @@ function handleGlobalKeydown(e: KeyboardEvent) {
}
}
onMounted(() => window.addEventListener('keydown', handleGlobalKeydown))
onMounted(() => {
window.addEventListener('keydown', handleGlobalKeydown)
if (hasPermission('staging_access')) {
fetchStagingSummary()
subscribeStagingSummary()
}
})
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount === 0 && event.failedCount === 0) return
@@ -258,7 +278,7 @@ function formatBadgeClass(fmt: string): string {
<!-- Normal state -->
<template v-else>
<!-- Left: sidebar trigger -->
<SidebarTrigger class="-ml-1 text-muted-foreground hover:text-foreground" />
<SidebarTrigger class="-ml-1 text-foreground/70 hover:text-foreground" />
<Separator orientation="vertical" class="mx-1 h-4" />
<!-- Center: desktop global search -->
@@ -360,14 +380,14 @@ function formatBadgeClass(fmt: string): string {
</div>
<!-- Right -->
<div class="ml-auto flex items-center gap-0.5">
<div class="ml-auto flex items-center gap-3">
<!-- Mobile: search icon -->
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground"
class="md:hidden h-8 w-8 rounded-xl border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors"
@click="mobileSearchOpen = true"
>
<Search :size="15" />
@@ -376,76 +396,132 @@ function formatBadgeClass(fmt: string): string {
<TooltipContent>Search</TooltipContent>
</Tooltip>
<!-- Upload button -->
<Tooltip>
<TooltipTrigger as-child>
<Button
v-if="hasPermission('library_upload')"
variant="ghost"
size="icon"
class="hidden md:flex h-8 w-8 text-muted-foreground hover:text-foreground"
@click="uploadOpen = true"
>
<Upload :size="15" />
</Button>
</TooltipTrigger>
<TooltipContent>Upload books</TooltipContent>
</Tooltip>
<!-- Desktop: appearance settings popover -->
<Tooltip>
<Popover>
<!-- Group 1: Content (Staging, Statistics, Upload) -->
<div class="hidden md:flex items-center gap-2.5">
<!-- Staging button -->
<Tooltip v-if="hasPermission('staging_access')">
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8 text-muted-foreground hover:text-foreground">
<Palette :size="15" />
</Button>
</PopoverTrigger>
<Button
variant="ghost"
size="icon"
class="relative h-8 w-8 rounded-xl border transition-colors"
:class="
isStagingActive
? 'border-primary/80 bg-primary/8 text-primary'
: 'border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground'
"
@click="navigateToStaging"
>
<PackageOpen :size="15" />
<span
v-if="stagingSummary.total > 0"
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground tabular-nums leading-none"
>
{{ stagingSummary.total }}
</span>
</Button>
</TooltipTrigger>
<PopoverContent class="w-72 p-4" align="end">
<div class="space-y-4">
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Appearance</p>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Theme</span>
<ThemePicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Accent</span>
<AccentPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Radius</span>
<RadiusPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Background</span>
<BackgroundPicker />
</div>
</div>
</PopoverContent>
</Popover>
<TooltipContent>Appearance</TooltipContent>
</Tooltip>
<TooltipContent>Staging</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="hidden md:flex h-8 w-8 text-muted-foreground hover:text-foreground"
@click="router.push({ name: 'settings-libraries' })"
>
<Settings :size="15" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<!-- Statistics button -->
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl border transition-colors"
:class="
isStatisticsActive
? 'border-primary/80 bg-primary/8 text-primary'
: 'border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground'
"
@click="navigateToStatistics"
>
<BarChart3 :size="15" />
</Button>
</TooltipTrigger>
<TooltipContent>Statistics</TooltipContent>
</Tooltip>
<!-- User avatar dropdown -->
<!-- Upload button -->
<Tooltip v-if="hasPermission('library_upload')">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors"
@click="uploadOpen = true"
>
<Upload :size="15" />
</Button>
</TooltipTrigger>
<TooltipContent>Upload books</TooltipContent>
</Tooltip>
</div>
<!-- Group 2: Preferences (Appearance, Settings) -->
<div class="hidden md:block h-4 w-px bg-foreground/20" />
<div class="hidden md:flex items-center gap-2.5">
<Tooltip>
<Popover>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Palette :size="15" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent class="w-72 p-4" align="end">
<div class="space-y-4">
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Appearance</p>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Theme</span>
<ThemePicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Accent</span>
<AccentPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Radius</span>
<RadiusPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Background</span>
<BackgroundPicker />
</div>
</div>
</PopoverContent>
</Popover>
<TooltipContent>Appearance</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors"
@click="router.push({ name: 'settings-libraries' })"
>
<Settings :size="15" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</div>
<!-- Group 3: Identity (Avatar) -->
<div class="hidden md:block h-4 w-px bg-foreground/20" />
<DropdownMenu v-if="user">
<DropdownMenuTrigger as-child>
<button
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary text-xs font-semibold hover:bg-primary/25 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-primary/50 bg-primary/10 text-primary text-xs font-bold hover:bg-primary/15 hover:border-primary/70 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{{ user.name.charAt(0).toUpperCase() }}
</button>
+6 -39
View File
@@ -10,7 +10,6 @@ import {
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar'
@@ -21,7 +20,6 @@ import { useLenses } from '@/features/lens/composables/useLenses'
import { useCollections } from '@/features/collection/composables/useCollections'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
import { useStagingSummary } from '@/features/staging/composables/useStagingSummary'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useDraggableOrder } from '@/composables/useDraggableOrder'
import type { Library } from '@projectx/types'
@@ -50,7 +48,6 @@ const { lenses, fetchLenses, reorderLenses } = useLenses()
const { collections, fetchCollections, reorderCollections } = useCollections()
const { hasPermission } = usePermissions()
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
const { summary: stagingSummary, fetchSummary: fetchStagingSummary, subscribe: subscribeStagingSummary } = useStagingSummary()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const createLensOpen = ref(false)
@@ -85,8 +82,6 @@ const {
} = useDraggableOrder({ source: collections, persist: reorderCollections })
const isDashboardActive = computed(() => route.name === 'dashboard')
const isStagingActive = computed(() => route.name === 'staging')
const isStatisticsActive = computed(() => route.name === 'statistics')
const isAuthorsActive = computed(() => route.name === 'authors' || route.name === 'author-detail')
const activeLibraryId = computed(() => {
@@ -135,10 +130,6 @@ onMounted(async () => {
}
fetchLenses()
fetchCollections()
if (hasPermission('staging_access')) {
fetchStagingSummary()
subscribeStagingSummary()
}
})
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
@@ -199,23 +190,6 @@ onUnmounted(() => stopUploadCompletedListener())
label="Dashboard"
@click="router.push({ name: 'dashboard' })"
/>
<SidebarNavItem
v-if="hasPermission('staging_access')"
:is-active="isStagingActive"
tooltip="Staging"
:icon="Icons.PackageOpen"
label="Staging"
@click="router.push({ name: 'staging' })"
>
<template #badge>
<span
v-if="stagingSummary.total > 0"
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/15 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-sidebar-foreground/80 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
>
{{ stagingSummary.total }}
</span>
</template>
</SidebarNavItem>
<SidebarNavItem
:is-active="isAuthorsActive"
tooltip="Authors"
@@ -223,13 +197,6 @@ onUnmounted(() => stopUploadCompletedListener())
label="Authors"
@click="router.push({ name: 'authors' })"
/>
<SidebarNavItem
:is-active="isStatisticsActive"
tooltip="Statistics"
:icon="Icons.BarChart3"
label="Statistics"
@click="router.push({ name: 'statistics' })"
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -367,9 +334,9 @@ onUnmounted(() => stopUploadCompletedListener())
</template>
</SidebarNavItem>
</VueDraggable>
<SidebarMenuItem v-if="localLenses.length === 0">
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/35 group-data-[collapsible=icon]:hidden">No lenses yet</span>
</SidebarMenuItem>
<div v-if="localLenses.length === 0">
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/40 group-data-[collapsible=icon]:hidden">No lenses yet</span>
</div>
</SidebarGroupContent>
</div>
</SidebarGroup>
@@ -426,9 +393,9 @@ onUnmounted(() => stopUploadCompletedListener())
</template>
</SidebarNavItem>
</VueDraggable>
<SidebarMenuItem v-if="localCollections.length === 0">
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/35 group-data-[collapsible=icon]:hidden">No collections yet</span>
</SidebarMenuItem>
<div v-if="localCollections.length === 0">
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/40 group-data-[collapsible=icon]:hidden">No collections yet</span>
</div>
</SidebarGroupContent>
</div>
</SidebarGroup>
@@ -160,10 +160,7 @@ function handleExportAll() {
<!-- Cover -->
<div
class="relative w-full rounded-sm overflow-hidden shadow-md transition-[box-shadow,transform,ring] duration-150 will-change-transform"
:class="[
isMissing ? 'ring-2 ring-amber-500' : selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]',
selected ? 'ring-2 ring-primary' : '',
]"
:class="[isMissing ? 'ring-2 ring-amber-500' : selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]']"
style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle"
>
@@ -238,7 +235,11 @@ function handleExportAll() {
/>
<!-- Selection checkbox overlay -->
<div v-if="selectionMode" class="absolute inset-0 z-30 pointer-events-none" :class="selected ? 'bg-primary/20' : ''">
<div
v-if="selectionMode"
class="absolute inset-0 z-30 pointer-events-none rounded-sm"
:class="selected ? 'bg-primary/20 ring-2 ring-inset ring-primary' : ''"
>
<div
class="absolute top-1.5 left-1.5 h-5 w-5 rounded flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'bg-black/40 border border-white/50'"
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from 'vue'
import { BookOpen, Plus, Users } from 'lucide-vue-next'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
import { useLibraries } from '@/features/library/composables/useLibraries'
defineProps<{ canCreate: boolean }>()
const { refreshLibraries } = useLibraries()
const createOpen = ref(false)
function handleOpenCreate() {
createOpen.value = true
}
function handleClose() {
createOpen.value = false
}
async function handleSaved() {
createOpen.value = false
await refreshLibraries()
}
</script>
<template>
<div class="flex items-center justify-center py-16 px-4">
<div class="relative w-full max-w-md overflow-hidden rounded-2xl border border-primary/40 bg-card/30 shadow-sm backdrop-blur-[1px]">
<!-- Glow backdrop -->
<div
class="pointer-events-none absolute inset-0"
style="
background-image: radial-gradient(ellipse 80% 60% at 50% -10%, color-mix(in oklch, var(--primary) 18%, transparent) 0%, transparent 100%);
"
/>
<div class="relative flex flex-col items-center px-10 py-12 text-center">
<!-- Icon -->
<div class="mb-6 flex h-14 w-14 items-center justify-center rounded-xl border border-border bg-background shadow-sm">
<component :is="canCreate ? BookOpen : Users" :size="26" class="text-foreground/70" />
</div>
<!-- Heading -->
<h2 class="mb-2 text-lg font-bold tracking-tight text-foreground">
{{ canCreate ? 'Your library is empty' : 'No libraries yet' }}
</h2>
<!-- Description -->
<p class="mb-8 max-w-xs text-sm leading-relaxed text-muted-foreground">
<template v-if="canCreate">
Create a library to start organizing and reading your books. Point it to a folder and it will do the rest.
</template>
<template v-else> Your administrator hasn't set up any libraries yet. Reach out to them to get started. </template>
</p>
<!-- CTA -->
<button
v-if="canCreate"
class="inline-flex items-center gap-2 rounded-md bg-primary px-5 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
@click="handleOpenCreate"
>
<Plus :size="15" />
Create your first library
</button>
</div>
</div>
</div>
<LibraryCreatorModal v-if="createOpen" @close="handleClose" @saved="handleSaved" />
</template>
@@ -3,6 +3,7 @@ import { api } from '@/lib/api'
import type { Library } from '@projectx/types'
const libraries = ref<Library[]>([])
const loading = ref(false)
let loaded = false
let fetchPromise: Promise<void> | null = null
@@ -14,6 +15,7 @@ export function useLibraries() {
async function refreshLibraries(): Promise<void> {
if (fetchPromise) return fetchPromise
loading.value = true
fetchPromise = api('/api/v1/libraries')
.then(async (res) => {
if (!res.ok) return
@@ -22,6 +24,7 @@ export function useLibraries() {
})
.finally(() => {
fetchPromise = null
loading.value = false
})
return fetchPromise
}
@@ -35,5 +38,5 @@ export function useLibraries() {
if (!res.ok) throw new Error('Failed to reorder libraries')
}
return { libraries, fetchLibraries, refreshLibraries, reorderLibraries }
return { libraries, loading, fetchLibraries, refreshLibraries, reorderLibraries }
}
@@ -308,7 +308,7 @@ function coverRefreshLabel(libraryId: number): string {
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="flex items-center justify-center w-9 h-9 rounded-lg border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
class="flex items-center justify-center px-2 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<MoreHorizontal :size="16" />
</button>
+28 -14
View File
@@ -1,26 +1,37 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { Settings2 } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAuth } from '@/features/auth/composables/useAuth'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraries } from '@/features/library/composables/useLibraries'
import DashboardScroller from '@/features/dashboard/components/DashboardScroller.vue'
import DashboardSettingsSheet from '@/features/dashboard/components/DashboardSettingsSheet.vue'
import DashboardWelcome from '@/features/dashboard/components/DashboardWelcome.vue'
import { useDashboardConfig } from '@/features/dashboard/composables/useDashboardConfig'
const { user } = useAuth()
const { hasPermission } = usePermissions()
const { libraries, loading: librariesLoading, fetchLibraries } = useLibraries()
const { scrollers } = useDashboardConfig()
const settingsOpen = ref(false)
const enabledScrollers = computed(() => scrollers.value.filter((s) => s.enabled).sort((a, b) => a.order - b.order))
const hasNoLibraries = computed(() => !librariesLoading.value && libraries.value.length === 0)
const greeting = computed(() => {
const hour = new Date().getHours()
const base = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'
const firstName = user.value?.name?.split(' ')[0] || user.value?.username
return firstName ? `${base}, ${firstName}` : base
})
onMounted(() => {
fetchLibraries()
})
</script>
<template>
@@ -44,20 +55,23 @@ const greeting = computed(() => {
</Tooltip>
</div>
<!-- Scrollers -->
<!-- Scrollers / Welcome -->
<div class="space-y-5 pb-8 pt-2 sm:pr-2">
<DashboardScroller
v-for="scroller in enabledScrollers"
:key="`${scroller.id}-${scroller.type}-${scroller.lensId ?? 0}`"
:type="scroller.type"
:title="scroller.label"
:limit="scroller.limit"
:lens-id="scroller.lensId"
/>
<div v-if="enabledScrollers.length === 0" class="px-2 py-12 text-center">
<p class="text-sm text-muted-foreground">All shelves are hidden.</p>
<button class="mt-2 text-sm text-primary hover:underline" @click="settingsOpen = true">Customize dashboard</button>
</div>
<DashboardWelcome v-if="hasNoLibraries" :can-create="hasPermission('manage_libraries')" />
<template v-else>
<DashboardScroller
v-for="scroller in enabledScrollers"
:key="`${scroller.id}-${scroller.type}-${scroller.lensId ?? 0}`"
:type="scroller.type"
:title="scroller.label"
:limit="scroller.limit"
:lens-id="scroller.lensId"
/>
<div v-if="enabledScrollers.length === 0" class="px-2 py-12 text-center">
<p class="text-sm text-muted-foreground">All shelves are hidden.</p>
<button class="mt-2 text-sm text-primary hover:underline" @click="settingsOpen = true">Customize dashboard</button>
</div>
</template>
</div>
</main>