mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(client): add staging and statistics nav buttons to header
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user