refactor(client): drive the sidebar sections from one registry

Describe the six entity sections once in useSidebarSections and render them with one v-for in
both the expanded list and the rail, instead of twelve hand-written copies. Move data loading to
useSidebarDataSync, scan and import progress to useSidebarLibraryActivity, and the footer to
SidebarFooterBar. The rendered sidebar is unchanged.
This commit is contained in:
neon
2026-10-16 06:22:57 +00:00
parent fdbdc8c990
commit be9b103120
11 changed files with 1036 additions and 610 deletions
+59 -591
View File
@@ -1,139 +1,42 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { Aperture, BookCopy, CircleArrowUp, FolderOpen, Heart, Orbit, Podcast } from '@lucide/vue'
import { APP_FEATURES, Permission, type Library, type LibraryType, type MediaType } from '@bookorbit/types'
import { formatCompactNumber, formatNumber } from '@/i18n/formatters'
import { entityCount } from '@/lib/entity-count'
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail, SidebarSeparator, useSidebar } from '@/components/ui/sidebar'
import { Orbit } from '@lucide/vue'
import type { Library, LibraryType } from '@bookorbit/types'
import { formatCompactNumber } from '@/i18n/formatters'
import { Sidebar, SidebarContent, SidebarHeader, SidebarRail, SidebarSeparator, useSidebar } from '@/components/ui/sidebar'
import SidebarZone from '@/components/sidebar/SidebarZone.vue'
import SidebarNavItem from '@/components/sidebar/SidebarNavItem.vue'
import SidebarBadge from '@/components/sidebar/SidebarBadge.vue'
import SidebarEntitySection from '@/components/sidebar/SidebarEntitySection.vue'
import SidebarModeSwitch from '@/components/sidebar/SidebarModeSwitch.vue'
import SidebarSectionPopover from '@/components/sidebar/SidebarSectionPopover.vue'
import SidebarGithubStar from '@/components/sidebar/SidebarGithubStar.vue'
import SidebarAppLinks from '@/components/sidebar/SidebarAppLinks.vue'
import { buildSidebarVersionUi } from '@/components/sidebar/versionUi'
import { mergedMediaOrder, ownedInOrder, type DisplayOrderEntry } from '@/components/sidebar/sidebar-order'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarFooterBar from '@/components/sidebar/SidebarFooterBar.vue'
import { useSidebarDataSync } from '@/composables/useSidebarDataSync'
import { useSidebarNav } from '@/composables/useSidebarNav'
import { useBrowseCounts } from '@/composables/useBrowseCounts'
import { useSidebarSections, type SidebarListKind, type SidebarSection } from '@/composables/useSidebarSections'
import { useSidebarLibraryActivity } from '@/composables/useSidebarLibraryActivity'
import { mediaModeHome, useMediaMode } from '@/composables/useMediaMode'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryScanRefresh } from '@/features/library/composables/useLibraryScanRefresh'
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
import { useCollections } from '@/features/collection/composables/useCollections'
import { canMutateCollection } from '@/features/collection/lib/collection-access'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
import { usePodcastImportProgress } from '@/features/podcast/composables/usePodcastImportProgress'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary'
import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect'
import CreateSmartScopeDialog from '@/features/smart-scope/components/CreateSmartScopeDialog.vue'
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import SettingsSidebar from '@/features/settings/components/SettingsSidebar.vue'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
import { useBookRequestSummary } from '@/features/book-requests/composables/useBookRequestSummary'
import { useBookRequestProgress } from '@/features/book-requests/composables/useBookRequestProgress'
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { state, isMobile, setOpenMobile } = useSidebar()
const { mode, setMode, syncModeFromRoute } = useMediaMode()
const { libraries, fetchLibraries, refreshLibraries, reorderLibraries } = useLibraries()
const { bookScopes, podcastScopes, fetchSmartScopes, reorderSmartScopes } = useSmartScopes()
const { bookCollections, podcastCollections, fetchCollections, reorderCollections } = useCollections()
const { hasPermission } = usePermissions()
const { showBookRequests } = useBookRequestVisibility()
const { subscribeLibrary, getProgress } = useScanProgress()
const podcastImportProgress = APP_FEATURES.podcasts ? usePodcastImportProgress() : null
const { handleLibraryCreated } = useLibraryCreationRedirect()
const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
const { fetchSummary: fetchBookDockSummary, subscribe: subscribeBookDockSummary } = useBookDockSummary()
const { fetchCounts: fetchBrowseCounts, refreshCounts: refreshBrowseCounts } = useBrowseCounts()
const { summary: bookRequestSummary, fetchSummary: fetchBookRequestSummary, refreshSummary: refreshBookRequestSummary } = useBookRequestSummary()
const outstandingRequestTotal = computed(() =>
hasPermission(Permission.ManageBookRequests) ? (bookRequestSummary.value?.active ?? 0) : (bookRequestSummary.value?.mine ?? 0),
)
const { outstandingRequestTotal } = useSidebarDataSync()
const { zones } = useSidebarNav(() => outstandingRequestTotal.value)
const requestProgress = hasPermission(Permission.BookRequestAccess) ? useBookRequestProgress() : null
useLibraryScanRefresh()
const SUPPORT_URL = 'https://ko-fi.com/neonbookorbit'
// Shared scopes belong to their owner, so their display order is not this user's to persist.
const ownedBookScopes = computed(() => bookScopes.value.filter((scope) => scope.isOwner))
const ownedPodcastScopes = computed(() => podcastScopes.value.filter((scope) => scope.isOwner))
async function persistSmartScopeOrder(order: DisplayOrderEntry[]) {
const books = ownedInOrder(order, ownedBookScopes.value)
if (books.length === 0) return
await reorderSmartScopes(mergedMediaOrder(books, ownedPodcastScopes.value))
}
async function persistPodcastScopeOrder(order: DisplayOrderEntry[]) {
const podcasts = ownedInOrder(order, ownedPodcastScopes.value)
if (podcasts.length === 0) return
await reorderSmartScopes(mergedMediaOrder(ownedBookScopes.value, podcasts))
}
const ownedBookCollections = computed(() => bookCollections.value.filter(canMutateCollection))
const ownedPodcastCollections = computed(() => podcastCollections.value.filter(canMutateCollection))
async function persistCollectionOrder(order: DisplayOrderEntry[]) {
const books = ownedInOrder(order, ownedBookCollections.value)
if (books.length === 0) return
await reorderCollections(mergedMediaOrder(books, ownedPodcastCollections.value))
}
async function persistPodcastCollectionOrder(order: DisplayOrderEntry[]) {
const podcasts = ownedInOrder(order, ownedPodcastCollections.value)
if (podcasts.length === 0) return
await reorderCollections(mergedMediaOrder(ownedBookCollections.value, podcasts))
}
const createSmartScopeOpen = ref(false)
const createCollectionOpen = ref(false)
const createLibraryOpen = ref(false)
const createLibraryType = ref<LibraryType>('books')
const createScopeMediaType = ref<MediaType>('books')
const createCollectionMediaType = ref<MediaType>('books')
/** A podcast scope belongs to one library; the first is the sensible default target. */
const defaultPodcastLibraryId = computed(() => podcastLibraries.value[0]?.id ?? null)
const { sections, podcastLibraries, podcastsAvailable } = useSidebarSections()
const { subscribe: subscribeLibraryActivity } = useSidebarLibraryActivity()
const { handleLibraryCreated } = useLibraryCreationRedirect()
const isRail = computed(() => state.value === 'collapsed' && !isMobile.value)
const isSettingsRoute = computed(() => typeof route.name === 'string' && route.name.startsWith('settings-'))
const versionUi = computed(() => buildSidebarVersionUi(version.value, updateAvailable.value, latestVersion.value))
function activeIdFor(...routeNames: string[]): number | null {
const id = route.params.id
return routeNames.some((routeName) => route.name === routeName) && id ? Number(id) : null
}
const activeLibraryId = computed(() => activeIdFor('library'))
const activePodcastLibraryId = computed(() => activeIdFor('podcast-library'))
const activeSmartScopeId = computed(() => activeIdFor('smartScope'))
const activeCollectionId = computed(() => activeIdFor('collection'))
const activePodcastScopeId = computed(() => activeIdFor('podcast-playlist'))
const activePodcastCollectionId = computed(() => activeIdFor('podcast-collection'))
const canManageLibraries = computed(() => hasPermission('manage_libraries'))
const bookLibraries = computed(() => libraries.value.filter((library) => library.type !== 'podcasts'))
const podcastLibraries = computed(() => libraries.value.filter((library) => library.type === 'podcasts'))
/** Books-only users never see the switcher; the creator is the entry point into podcasts. */
const showModeSwitch = computed(() => APP_FEATURES.podcasts && podcastLibraries.value.length > 0)
const isBooksMode = computed(() => mode.value === 'books')
watch(
() => route.name,
@@ -141,8 +44,8 @@ watch(
{ immediate: true },
)
watch(showModeSwitch, (visible) => {
if (!visible && mode.value === 'podcasts') setMode('books')
watch(podcastsAvailable, (available) => {
if (!available && mode.value === 'podcasts') setMode('books')
})
async function handleModeSwitch(target: LibraryType) {
@@ -151,166 +54,46 @@ async function handleModeSwitch(target: LibraryType) {
handleNavigate()
}
async function persistLibraryOrder(order: DisplayOrderEntry[]) {
await reorderLibraries(mergedMediaOrder(order, podcastLibraries.value))
}
async function persistPodcastLibraryOrder(order: DisplayOrderEntry[]) {
await reorderLibraries(mergedMediaOrder(bookLibraries.value, order))
}
function handleNavigate() {
if (isMobile.value) setOpenMobile(false)
}
function openCreateLibrary() {
createLibraryType.value = 'books'
createLibraryOpen.value = true
const openCreateDialog = ref<SidebarListKind | null>(null)
/** Kept per dialog and never reset, so a closing dialog does not swap to the other medium's copy mid-animation. */
const createMediaType = reactive<Record<SidebarListKind, LibraryType>>({ library: 'books', smartScope: 'books', collection: 'books' })
/** A podcast scope belongs to one library; the first is the sensible default target. */
const defaultPodcastLibraryId = computed(() => podcastLibraries.value[0]?.id ?? null)
function handleAdd(section: SidebarSection) {
createMediaType[section.kind] = section.medium
openCreateDialog.value = section.kind
}
function openCreatePodcastLibrary() {
createLibraryType.value = 'podcasts'
createLibraryOpen.value = true
}
function openCreatePodcastScope() {
createScopeMediaType.value = 'podcasts'
createSmartScopeOpen.value = true
}
function openCreatePodcastCollection() {
createCollectionMediaType.value = 'podcasts'
createCollectionOpen.value = true
}
function openCreateSmartScope() {
createScopeMediaType.value = 'books'
createSmartScopeOpen.value = true
}
function openCreateCollection() {
createCollectionMediaType.value = 'books'
createCollectionOpen.value = true
}
function closeCreateLibrary() {
createLibraryOpen.value = false
}
function closeCreateSmartScope() {
createSmartScopeOpen.value = false
}
function closeCreateCollection() {
createCollectionOpen.value = false
}
function isScanning(libraryId: number): boolean {
return getProgress(libraryId)?.status === 'running'
}
function scanPct(libraryId: number): number {
const progress = getProgress(libraryId)
if (!progress || progress.total === 0) return 0
return Math.floor((progress.processed / progress.total) * 100)
}
function scanProgressLabel(libraryId: number): string {
const progress = getProgress(libraryId)
if (!progress) return ''
return t('components.sidebar.scanProgress', {
processed: formatNumber(progress.processed),
total: formatNumber(progress.total),
})
}
function scanBarWidth(libraryId: number): string {
const progress = getProgress(libraryId)
return progress && progress.total > 0 ? `${scanPct(libraryId)}%` : '30%'
}
function podcastImportLabel(libraryId: number): string {
const state = podcastImportProgress?.getImport(libraryId)
if (!state) return ''
return state.total === null
? t('components.sidebar.podcastImportCounting')
: t('components.sidebar.podcastImportProgress', { processed: formatNumber(state.processed), total: formatNumber(state.total) })
}
/** Podcast libraries watch the same id on their own channel, so both subscriptions are needed. */
function subscribeLibraryProgress(library: { id: number; type?: string }): void {
subscribeLibrary(library.id)
if (library.type === 'podcasts') podcastImportProgress?.subscribeLibrary(library.id)
}
function isImportingPodcasts(libraryId: number): boolean {
return podcastImportProgress?.isImporting(libraryId) ?? false
}
function podcastImportPct(libraryId: number): number {
return podcastImportProgress?.importPct(libraryId) ?? 0
}
function podcastImportBarWidth(libraryId: number): string {
return podcastImportProgress?.importBarWidth(libraryId) ?? '0%'
function closeCreateDialog() {
openCreateDialog.value = null
}
async function onLibrarySaved(library: Library) {
createLibraryOpen.value = false
subscribeLibraryProgress(library)
closeCreateDialog()
subscribeLibraryActivity(library)
await handleLibraryCreated(library)
}
onMounted(async () => {
getSocket()
await fetchLibraries()
for (const library of libraries.value) {
subscribeLibraryProgress(library)
}
void fetchSmartScopes()
void fetchCollections()
void fetchBrowseCounts()
if (showBookRequests.value && hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary()
void loadAppInfo()
if (hasPermission('book_dock_access')) {
void fetchBookDockSummary()
subscribeBookDockSummary()
}
})
requestProgress?.onRequestsChanged(() => {
if (showBookRequests.value) void refreshBookRequestSummary()
})
if (requestProgress) {
watch(requestProgress.connected, (connected) => {
if (connected && showBookRequests.value) void refreshBookRequestSummary()
})
}
watch(showBookRequests, (visible) => {
if (visible && hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary()
})
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount === 0) return
void refreshLibraries()
void refreshBrowseCounts()
})
onUnmounted(() => stopLibraryUploadListener())
</script>
<template>
<CreateSmartScopeDialog
:open="createSmartScopeOpen"
:media-type="createScopeMediaType"
:open="openCreateDialog === 'smartScope'"
:media-type="createMediaType.smartScope"
:library-id="defaultPodcastLibraryId"
@close="closeCreateSmartScope"
@close="closeCreateDialog"
/>
<CreateCollectionDialog :open="openCreateDialog === 'collection'" :media-type="createMediaType.collection" @close="closeCreateDialog" />
<LibraryCreatorModal
v-if="openCreateDialog === 'library'"
:initial-type="createMediaType.library"
@close="closeCreateDialog"
@saved="onLibrarySaved"
/>
<CreateCollectionDialog :open="createCollectionOpen" :media-type="createCollectionMediaType" @close="closeCreateCollection" />
<LibraryCreatorModal v-if="createLibraryOpen" :initial-type="createLibraryType" @close="closeCreateLibrary" @saved="onLibrarySaved" />
<Sidebar variant="floating" collapsible="icon">
<!-- px-4 puts the logo on the same left edge as the nav row icons (group px-2 + row px-2). -->
@@ -331,7 +114,7 @@ onUnmounted(() => stopLibraryUploadListener())
Book<span class="text-primary"> Orbit</span>
</span>
</RouterLink>
<SidebarModeSwitch v-if="showModeSwitch" :mode="mode" :rail="isRail" class="mt-1.5" @switch="handleModeSwitch" />
<SidebarModeSwitch v-if="podcastsAvailable" :mode="mode" :rail="isRail" class="mt-1.5" @switch="handleModeSwitch" />
</SidebarHeader>
<SidebarContent>
@@ -369,350 +152,35 @@ onUnmounted(() => stopLibraryUploadListener())
<SidebarSeparator />
<template v-if="isRail">
<div v-if="isBooksMode" class="flex flex-col items-center gap-1 px-2">
<SidebarSectionPopover :label="t('components.sidebar.libraries')" :icon="BookCopy" :count="bookLibraries.length">
<SidebarEntitySection
section-id="libraries"
always-open
:label="t('components.sidebar.libraries')"
:items="bookLibraries"
route-name="library"
index-route-name="libraries"
:active-id="activeLibraryId"
fallback-icon="BookCopy"
:empty-text="t('components.sidebar.noLibraries')"
:filter-label="t('components.sidebar.filterLibraries')"
:filter-placeholder="t('components.sidebar.filterLibrariesPlaceholder')"
:see-all-label="t('components.sidebar.seeAllLibraries', { count: formatNumber(bookLibraries.length) })"
:can-add="canManageLibraries"
:add-label="t('components.sidebar.newLibrary')"
@add="openCreateLibrary"
@navigate="handleNavigate"
/>
</SidebarSectionPopover>
<SidebarSectionPopover :label="t('components.sidebar.smartScopes')" :icon="Aperture" :count="bookScopes.length">
<SidebarEntitySection
section-id="smartScopes"
always-open
:label="t('components.sidebar.smartScopes')"
:items="bookScopes"
route-name="smartScope"
index-route-name="smart-scopes"
:active-id="activeSmartScopeId"
fallback-icon="Aperture"
:empty-text="t('components.sidebar.noSmartScopes')"
:filter-label="t('components.sidebar.filterSmartScopes')"
:filter-placeholder="t('components.sidebar.filterSmartScopesPlaceholder')"
:see-all-label="t('components.sidebar.seeAllSmartScopes', { count: formatNumber(bookScopes.length) })"
can-add
:add-label="t('components.sidebar.newSmartScope')"
@add="openCreateSmartScope"
@navigate="handleNavigate"
/>
</SidebarSectionPopover>
<SidebarSectionPopover :label="t('components.sidebar.collections')" :icon="FolderOpen" :count="bookCollections.length">
<SidebarEntitySection
section-id="collections"
always-open
:label="t('components.sidebar.collections')"
:items="bookCollections"
route-name="collection"
index-route-name="collections"
:active-id="activeCollectionId"
fallback-icon="FolderOpen"
:empty-text="t('components.sidebar.noCollections')"
:filter-label="t('components.sidebar.filterCollections')"
:filter-placeholder="t('components.sidebar.filterCollectionsPlaceholder')"
:see-all-label="t('components.sidebar.seeAllCollections', { count: formatNumber(bookCollections.length) })"
can-add
:add-label="t('components.sidebar.newCollection')"
@add="openCreateCollection"
@navigate="handleNavigate"
/>
</SidebarSectionPopover>
</div>
<div v-else class="flex flex-col items-center gap-1 px-2">
<SidebarSectionPopover :label="t('components.sidebar.podcasts')" :icon="Podcast" :count="podcastLibraries.length">
<SidebarEntitySection
section-id="podcasts"
always-open
:label="t('components.sidebar.podcasts')"
:items="podcastLibraries"
route-name="podcast-library"
index-route-name="podcast-libraries"
:active-id="activePodcastLibraryId"
fallback-icon="Podcast"
:empty-text="t('components.sidebar.noPodcastLibraries')"
:filter-label="t('components.sidebar.filterPodcastLibraries')"
:filter-placeholder="t('components.sidebar.filterPodcastLibrariesPlaceholder')"
:see-all-label="t('components.sidebar.seeAllPodcastLibraries', { count: formatNumber(podcastLibraries.length) })"
:can-add="canManageLibraries"
:add-label="t('components.sidebar.newPodcastLibrary')"
@add="openCreatePodcastLibrary"
@navigate="handleNavigate"
/>
</SidebarSectionPopover>
<SidebarSectionPopover :label="t('components.sidebar.playlists')" :icon="Aperture" :count="podcastScopes.length">
<SidebarEntitySection
section-id="podcastScopes"
always-open
:label="t('components.sidebar.playlists')"
:items="podcastScopes"
route-name="podcast-playlist"
:active-id="activePodcastScopeId"
fallback-icon="Aperture"
:empty-text="t('components.sidebar.noPlaylists')"
:filter-label="t('components.sidebar.filterPlaylists')"
:filter-placeholder="t('components.sidebar.filterPlaylistsPlaceholder')"
:see-all-label="t('components.sidebar.seeAllPlaylists', { count: formatNumber(podcastScopes.length) })"
:can-add="showModeSwitch"
:add-label="t('components.sidebar.newPlaylist')"
@add="openCreatePodcastScope"
@navigate="handleNavigate"
/>
</SidebarSectionPopover>
<SidebarSectionPopover :label="t('components.sidebar.collections')" :icon="FolderOpen" :count="podcastCollections.length">
<SidebarEntitySection
section-id="podcastCollections"
always-open
:label="t('components.sidebar.collections')"
:items="podcastCollections"
route-name="podcast-collection"
:active-id="activePodcastCollectionId"
fallback-icon="FolderOpen"
:empty-text="t('components.sidebar.noPodcastCollections')"
:filter-label="t('components.sidebar.filterPodcastCollections')"
:filter-placeholder="t('components.sidebar.filterPodcastCollectionsPlaceholder')"
:see-all-label="t('components.sidebar.seeAllPodcastCollections', { count: formatNumber(podcastCollections.length) })"
:can-add="showModeSwitch"
:add-label="t('components.sidebar.newPodcastCollection')"
@add="openCreatePodcastCollection"
@navigate="handleNavigate"
/>
</SidebarSectionPopover>
</div>
</template>
<template v-else-if="isBooksMode">
<SidebarEntitySection
section-id="libraries"
tour-id="sidebar-libraries"
:label="t('components.sidebar.libraries')"
:items="bookLibraries"
route-name="library"
index-route-name="libraries"
:active-id="activeLibraryId"
fallback-icon="BookCopy"
:empty-text="t('components.sidebar.noLibraries')"
:filter-label="t('components.sidebar.filterLibraries')"
:filter-placeholder="t('components.sidebar.filterLibrariesPlaceholder')"
:see-all-label="t('components.sidebar.seeAllLibraries', { count: formatNumber(bookLibraries.length) })"
:can-add="canManageLibraries"
:add-label="t('components.sidebar.newLibrary')"
:can-reorder="canManageLibraries"
:persist-order="persistLibraryOrder"
@add="openCreateLibrary"
@navigate="handleNavigate"
<div v-if="isRail" class="flex flex-col items-center gap-1 px-2">
<SidebarSectionPopover
v-for="section in sections"
:key="section.id"
:label="section.props.label"
:icon="section.icon"
:count="section.props.items.length"
>
<template #itemBadge="{ item }">
<SidebarBadge v-if="isScanning(item.id)" variant="progress">{{ scanPct(item.id) }}%</SidebarBadge>
<SidebarBadge v-else-if="entityCount(item) !== null">{{ formatCompactNumber(entityCount(item) ?? 0) }}</SidebarBadge>
</template>
<template #itemExtra="{ item }">
<div v-if="isScanning(item.id)" class="px-2 pb-1.5 group-data-[collapsible=icon]:hidden">
<div class="h-0.5 w-full overflow-hidden rounded-full bg-(--shell-accent-tint)">
<div class="h-full rounded-full bg-primary" :style="{ width: scanBarWidth(item.id) }" />
</div>
<p class="mt-0.5 text-[13px] text-muted-foreground">{{ scanProgressLabel(item.id) }}</p>
</div>
</template>
</SidebarEntitySection>
<SidebarEntitySection
section-id="smartScopes"
tour-id="sidebar-smartScopes"
:label="t('components.sidebar.smartScopes')"
:items="bookScopes"
route-name="smartScope"
index-route-name="smart-scopes"
:active-id="activeSmartScopeId"
fallback-icon="Aperture"
:empty-text="t('components.sidebar.noSmartScopes')"
:filter-label="t('components.sidebar.filterSmartScopes')"
:filter-placeholder="t('components.sidebar.filterSmartScopesPlaceholder')"
:see-all-label="t('components.sidebar.seeAllSmartScopes', { count: formatNumber(bookScopes.length) })"
can-add
:add-label="t('components.sidebar.newSmartScope')"
can-reorder
:persist-order="persistSmartScopeOrder"
@add="openCreateSmartScope"
@navigate="handleNavigate"
/>
<SidebarEntitySection
section-id="collections"
tour-id="sidebar-collections"
:label="t('components.sidebar.collections')"
:items="bookCollections"
route-name="collection"
index-route-name="collections"
:active-id="activeCollectionId"
fallback-icon="FolderOpen"
:empty-text="t('components.sidebar.noCollections')"
:filter-label="t('components.sidebar.filterCollections')"
:filter-placeholder="t('components.sidebar.filterCollectionsPlaceholder')"
:see-all-label="t('components.sidebar.seeAllCollections', { count: formatNumber(bookCollections.length) })"
can-add
:add-label="t('components.sidebar.newCollection')"
can-reorder
:persist-order="persistCollectionOrder"
@add="openCreateCollection"
@navigate="handleNavigate"
/>
</template>
<SidebarEntitySection v-bind="section.props" always-open @add="handleAdd(section)" @navigate="handleNavigate" />
</SidebarSectionPopover>
</div>
<template v-else>
<SidebarEntitySection
section-id="podcasts"
:label="t('components.sidebar.podcasts')"
:items="podcastLibraries"
route-name="podcast-library"
index-route-name="podcast-libraries"
:active-id="activePodcastLibraryId"
fallback-icon="Podcast"
:empty-text="t('components.sidebar.noPodcastLibraries')"
:filter-label="t('components.sidebar.filterPodcastLibraries')"
:filter-placeholder="t('components.sidebar.filterPodcastLibrariesPlaceholder')"
:see-all-label="t('components.sidebar.seeAllPodcastLibraries', { count: formatNumber(podcastLibraries.length) })"
:can-add="canManageLibraries"
:add-label="t('components.sidebar.newPodcastLibrary')"
:can-reorder="canManageLibraries"
:persist-order="persistPodcastLibraryOrder"
@add="openCreatePodcastLibrary"
@navigate="handleNavigate"
>
<template #itemBadge="{ item }">
<SidebarBadge v-if="isImportingPodcasts(item.id)" variant="progress">{{ podcastImportPct(item.id) }}%</SidebarBadge>
<SidebarBadge v-else-if="entityCount(item) !== null">{{ formatCompactNumber(entityCount(item) ?? 0) }}</SidebarBadge>
</template>
<template #itemExtra="{ item }">
<div v-if="isImportingPodcasts(item.id)" class="px-2 pb-1.5 group-data-[collapsible=icon]:hidden">
<div class="h-0.5 w-full overflow-hidden rounded-full bg-(--shell-accent-tint)">
<div class="h-full rounded-full bg-primary" :style="{ width: podcastImportBarWidth(item.id) }" />
</div>
<p class="mt-0.5 text-[13px] text-muted-foreground">{{ podcastImportLabel(item.id) }}</p>
</div>
</template>
</SidebarEntitySection>
<SidebarEntitySection
section-id="podcastScopes"
:label="t('components.sidebar.playlists')"
:items="podcastScopes"
route-name="podcast-playlist"
:active-id="activePodcastScopeId"
fallback-icon="Aperture"
:empty-text="t('components.sidebar.noPlaylists')"
:filter-label="t('components.sidebar.filterPlaylists')"
:filter-placeholder="t('components.sidebar.filterPlaylistsPlaceholder')"
:see-all-label="t('components.sidebar.seeAllPlaylists', { count: formatNumber(podcastScopes.length) })"
:can-add="showModeSwitch"
:add-label="t('components.sidebar.newPlaylist')"
can-reorder
:persist-order="persistPodcastScopeOrder"
@add="openCreatePodcastScope"
@navigate="handleNavigate"
/>
<SidebarEntitySection
section-id="podcastCollections"
:label="t('components.sidebar.collections')"
:items="podcastCollections"
route-name="podcast-collection"
:active-id="activePodcastCollectionId"
fallback-icon="FolderOpen"
:empty-text="t('components.sidebar.noPodcastCollections')"
:filter-label="t('components.sidebar.filterPodcastCollections')"
:filter-placeholder="t('components.sidebar.filterPodcastCollectionsPlaceholder')"
:see-all-label="t('components.sidebar.seeAllPodcastCollections', { count: formatNumber(podcastCollections.length) })"
:can-add="showModeSwitch"
:add-label="t('components.sidebar.newPodcastCollection')"
can-reorder
:persist-order="persistPodcastCollectionOrder"
@add="openCreatePodcastCollection"
v-for="section in sections"
:key="section.id"
v-bind="section.props"
:tour-id="section.tourId"
:can-reorder="section.canReorder"
:persist-order="section.persistOrder"
:activity="section.activity"
@add="handleAdd(section)"
@navigate="handleNavigate"
/>
</template>
</template>
</SidebarContent>
<SidebarFooter v-if="!isSettingsRoute" class="border-t border-sidebar-border px-4 py-2 group-data-[collapsible=icon]:px-2">
<div
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 group-data-[collapsible=icon]:grid-cols-1 group-data-[collapsible=icon]:justify-items-center group-data-[collapsible=icon]:gap-1"
>
<div
class="flex items-center justify-self-start group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:gap-1 group-data-[collapsible=icon]:justify-self-center"
>
<SidebarGithubStar :is-rail="isRail" />
<Tooltip>
<TooltipTrigger as-child>
<a
:href="SUPPORT_URL"
target="_blank"
rel="noopener noreferrer"
:aria-label="t('components.sidebar.supportAria')"
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-destructive outline-hidden transition-colors duration-150 hover:bg-(--shell-accent-wash) focus-visible:ring-2 focus-visible:ring-sidebar-ring"
>
<Heart :size="16" class="fill-current" aria-hidden="true" />
</a>
</TooltipTrigger>
<TooltipContent :side="isRail ? 'right' : 'top'">{{ t('components.sidebar.support') }}</TooltipContent>
</Tooltip>
</div>
<div class="flex min-w-0 items-center justify-center gap-1 group-data-[collapsible=icon]:hidden">
<RouterLink
v-if="versionUi.currentLabel"
to="/whats-new"
class="inline-flex min-w-0 items-center gap-1.5 rounded-md text-[13px] font-medium text-muted-foreground outline-hidden transition-colors duration-150 hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
@click="handleNavigate"
>
<span class="truncate">{{ versionUi.currentLabel }}</span>
<span
v-if="hasUnseenWhatsNew"
class="h-1.5 w-1.5 flex-none rounded-full bg-primary"
:aria-label="t('components.sidebar.newReleaseNotes')"
/>
</RouterLink>
<Tooltip v-if="versionUi.showUpdate">
<TooltipTrigger as-child>
<a
:href="versionUi.updateHref"
target="_blank"
rel="noopener noreferrer"
:aria-label="t('components.sidebar.openUpdateRelease', { version: versionUi.updateVersionLabel })"
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-success outline-hidden transition-colors duration-150 hover:bg-(--shell-accent-wash) focus-visible:ring-2 focus-visible:ring-sidebar-ring"
>
<CircleArrowUp :size="18" aria-hidden="true" />
</a>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.sidebar.updateTooltip', { version: versionUi.updateVersionLabel }) }}</TooltipContent>
</Tooltip>
</div>
<div class="justify-self-end group-data-[collapsible=icon]:justify-self-center">
<SidebarAppLinks :is-rail="isRail" />
</div>
</div>
</SidebarFooter>
<SidebarFooterBar v-if="!isSettingsRoute" :is-rail="isRail" @navigate="handleNavigate" />
<SidebarRail />
</Sidebar>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, toRef, useId, watch } from 'vue'
import { computed, ref, toRef, useId, watch, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowRight, GripVertical, Search, X } from '@lucide/vue'
import { VueDraggable } from 'vue-draggable-plus'
@@ -13,6 +13,7 @@ import { formatCompactNumber, formatNumber } from '@/i18n/formatters'
import { entityCount } from '@/lib/entity-count'
import { useDraggableOrder } from '@/composables/useDraggableOrder'
import { useSidebarPrefs } from '@/composables/useSidebarPrefs'
import type { SidebarItemActivity } from '@/composables/useSidebarLibraryActivity'
export interface SidebarEntity {
id: number
@@ -36,7 +37,7 @@ const props = withDefaults(
/** Omit when the section has no index page; the see-all link is then suppressed. */
indexRouteName?: string
activeId: number | null
fallbackIcon: string
fallbackIcon: Component | string
emptyText: string
filterLabel: string
filterPlaceholder: string
@@ -48,6 +49,8 @@ const props = withDefaults(
tourId?: string
/** Rail popovers render the body regardless of the stored open state. */
alwaysOpen?: boolean
/** A run in progress on the row, shown in place of its count. */
activity?: (item: SidebarEntity) => SidebarItemActivity | null
}>(),
{
indexRouteName: undefined,
@@ -57,6 +60,7 @@ const props = withDefaults(
persistOrder: undefined,
tourId: undefined,
alwaysOpen: false,
activity: undefined,
},
)
@@ -160,6 +164,10 @@ function handleFilterKeydown(event: KeyboardEvent) {
}
}
function activityOf(item: SidebarEntity): SidebarItemActivity | null {
return props.activity?.(item) ?? null
}
function itemRoute(item: SidebarEntity) {
return { name: props.routeName, params: { id: item.id } }
}
@@ -246,9 +254,8 @@ function onGripBlur(id: number) {
@navigate="handleNavigate"
>
<template #badge>
<slot name="itemBadge" :item="item">
<SidebarBadge v-if="entityCount(item) !== null">{{ formatCompactNumber(entityCount(item) ?? 0) }}</SidebarBadge>
</slot>
<SidebarBadge v-if="activityOf(item)" variant="progress">{{ activityOf(item)?.pct }}%</SidebarBadge>
<SidebarBadge v-else-if="entityCount(item) !== null">{{ formatCompactNumber(entityCount(item) ?? 0) }}</SidebarBadge>
</template>
<template v-if="dragEnabled" #trailing>
<button
@@ -264,7 +271,12 @@ function onGripBlur(id: number) {
</button>
</template>
<template #extra>
<slot name="itemExtra" :item="item" />
<div v-if="activityOf(item)" class="px-2 pb-1.5 group-data-[collapsible=icon]:hidden">
<div class="h-0.5 w-full overflow-hidden rounded-full bg-(--shell-accent-tint)">
<div class="h-full rounded-full bg-primary" :style="{ width: activityOf(item)?.barWidth }" />
</div>
<p class="mt-0.5 text-[13px] text-muted-foreground">{{ activityOf(item)?.label }}</p>
</div>
</template>
</SidebarNavItem>
</VueDraggable>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { CircleArrowUp, Heart } from '@lucide/vue'
import { SidebarFooter } from '@/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarGithubStar from '@/components/sidebar/SidebarGithubStar.vue'
import SidebarAppLinks from '@/components/sidebar/SidebarAppLinks.vue'
import { buildSidebarVersionUi } from '@/components/sidebar/versionUi'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
defineProps<{ isRail: boolean }>()
const emit = defineEmits<{ navigate: [] }>()
const { t } = useI18n()
const { version, updateAvailable, latestVersion } = useAppInfo()
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
const SUPPORT_URL = 'https://ko-fi.com/neonbookorbit'
const versionUi = computed(() => buildSidebarVersionUi(version.value, updateAvailable.value, latestVersion.value))
function handleNavigate() {
emit('navigate')
}
</script>
<template>
<SidebarFooter class="border-t border-sidebar-border px-4 py-2 group-data-[collapsible=icon]:px-2">
<div
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 group-data-[collapsible=icon]:grid-cols-1 group-data-[collapsible=icon]:justify-items-center group-data-[collapsible=icon]:gap-1"
>
<div
class="flex items-center justify-self-start group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:gap-1 group-data-[collapsible=icon]:justify-self-center"
>
<SidebarGithubStar :is-rail="isRail" />
<Tooltip>
<TooltipTrigger as-child>
<a
:href="SUPPORT_URL"
target="_blank"
rel="noopener noreferrer"
:aria-label="t('components.sidebar.supportAria')"
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-destructive outline-hidden transition-colors duration-150 hover:bg-(--shell-accent-wash) focus-visible:ring-2 focus-visible:ring-sidebar-ring"
>
<Heart :size="16" class="fill-current" aria-hidden="true" />
</a>
</TooltipTrigger>
<TooltipContent :side="isRail ? 'right' : 'top'">{{ t('components.sidebar.support') }}</TooltipContent>
</Tooltip>
</div>
<div class="flex min-w-0 items-center justify-center gap-1 group-data-[collapsible=icon]:hidden">
<RouterLink
v-if="versionUi.currentLabel"
to="/whats-new"
class="inline-flex min-w-0 items-center gap-1.5 rounded-md text-[13px] font-medium text-muted-foreground outline-hidden transition-colors duration-150 hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
@click="handleNavigate"
>
<span class="truncate">{{ versionUi.currentLabel }}</span>
<span
v-if="hasUnseenWhatsNew"
class="h-1.5 w-1.5 flex-none rounded-full bg-primary"
:aria-label="t('components.sidebar.newReleaseNotes')"
/>
</RouterLink>
<Tooltip v-if="versionUi.showUpdate">
<TooltipTrigger as-child>
<a
:href="versionUi.updateHref"
target="_blank"
rel="noopener noreferrer"
:aria-label="t('components.sidebar.openUpdateRelease', { version: versionUi.updateVersionLabel })"
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-success outline-hidden transition-colors duration-150 hover:bg-(--shell-accent-wash) focus-visible:ring-2 focus-visible:ring-sidebar-ring"
>
<CircleArrowUp :size="18" aria-hidden="true" />
</a>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.sidebar.updateTooltip', { version: versionUi.updateVersionLabel }) }}</TooltipContent>
</Tooltip>
</div>
<div class="justify-self-end group-data-[collapsible=icon]:justify-self-center">
<SidebarAppLinks :is-rail="isRail" />
</div>
</div>
</SidebarFooter>
</template>
@@ -142,6 +142,27 @@ describe('SidebarEntitySection', () => {
})
})
describe('activity', () => {
const scanning = { pct: 42, barWidth: '42%', label: '42 of 100 files' }
const libraries = [
{ id: 1, displayOrder: 0, name: 'Novels', icon: null, type: 'books', bookCount: 7 },
{ id: 2, displayOrder: 1, name: 'Comics', icon: null, type: 'books', bookCount: 3 },
]
it('replaces the count with the run percentage and adds its progress line', () => {
const wrapper = mountSection(0, { items: libraries.slice(0, 1), activity: () => scanning })
expect(rowNames(wrapper)).toEqual(['Novels42%42 of 100 files'])
expect(wrapper.find('li [style]').attributes('style')).toContain('width: 42%')
})
it('keeps the count on rows with nothing running', () => {
const wrapper = mountSection(0, { items: libraries, activity: (item: { id: number }) => (item.id === 1 ? scanning : null) })
expect(rowNames(wrapper)).toEqual(['Novels42%42 of 100 files', 'Comics3'])
})
})
describe('see-all link', () => {
it('stays hidden while the total fits inside the cap', () => {
const wrapper = mountSection(8)
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { mergedMediaOrder, ownedInOrder } from '../sidebar-order'
import { describe, expect, it, vi } from 'vitest'
import { mediaOrderPersisters, mergedMediaOrder, ownedInOrder, type DisplayOrderEntry } from '../sidebar-order'
/**
* The sidebar's reorder persistence shipped two real bugs this covers: it sent scopes owned by
@@ -67,3 +67,73 @@ describe('mergedMediaOrder', () => {
expect(merged.map((entry) => entry.id)).toEqual([2, 1, 11, 10])
})
})
describe('mediaOrderPersisters', () => {
function setup(books = [{ id: 1 }, { id: 2 }], podcasts = [{ id: 10 }, { id: 11 }]) {
const reorder = vi.fn<(order: DisplayOrderEntry[]) => Promise<void>>().mockResolvedValue(undefined)
const persist = mediaOrderPersisters({ books: () => books, podcasts: () => podcasts, reorder })
return { reorder, persist }
}
it('rewrites the whole run with the reordered books ahead of the podcasts', async () => {
const { reorder, persist } = setup()
await persist.books([
{ id: 2, displayOrder: 0 },
{ id: 1, displayOrder: 1 },
])
expect(reorder).toHaveBeenCalledWith([
{ id: 2, displayOrder: 0 },
{ id: 1, displayOrder: 1 },
{ id: 10, displayOrder: 2 },
{ id: 11, displayOrder: 3 },
])
})
it('keeps the books in place when the podcasts are reordered', async () => {
const { reorder, persist } = setup()
await persist.podcasts([
{ id: 11, displayOrder: 0 },
{ id: 10, displayOrder: 1 },
])
expect(reorder).toHaveBeenCalledWith([
{ id: 1, displayOrder: 0 },
{ id: 2, displayOrder: 1 },
{ id: 11, displayOrder: 2 },
{ id: 10, displayOrder: 3 },
])
})
it('leaves out rows the user may not reorder', async () => {
const { reorder, persist } = setup([{ id: 1 }, { id: 2 }])
await persist.books([
{ id: 3, displayOrder: 0 },
{ id: 2, displayOrder: 1 },
{ id: 1, displayOrder: 2 },
])
expect(reorder.mock.calls[0]?.[0].map((entry) => entry.id)).toEqual([2, 1, 10, 11])
})
it("sends nothing when none of the reordered rows are the user's", async () => {
const { reorder, persist } = setup([{ id: 1 }])
await persist.books([{ id: 9, displayOrder: 0 }])
expect(reorder).not.toHaveBeenCalled()
})
it('reads both lists when persisting, so a list that changed since setup is not overwritten', async () => {
const podcasts = [{ id: 10 }]
const { reorder, persist } = setup([{ id: 1 }], podcasts)
podcasts.push({ id: 12 })
await persist.books([{ id: 1, displayOrder: 0 }])
expect(reorder.mock.calls[0]?.[0].map((entry) => entry.id)).toEqual([1, 10, 12])
})
})
+13 -11
View File
@@ -31,26 +31,28 @@ export function ownedInOrder(order: DisplayOrderEntry[], owned: Identified[]): I
export type PersistOrder = (order: DisplayOrderEntry[]) => Promise<void>
/**
* One persist function per medium for a list whose books and podcasts share a displayOrder run.
* `books` and `podcasts` return the rows the user may reorder; each persist keeps the other
* medium's current order and rewrites the whole run in one request.
*/
export function mediaOrderPersisters(options: {
interface MediaOrderSources {
/** The rows the user may reorder in each medium, read at persist time. */
books: () => Identified[]
podcasts: () => Identified[]
reorder: PersistOrder
}): { books: PersistOrder; podcasts: PersistOrder } {
}
/**
* One persist function per medium for a list whose books and podcasts share a displayOrder run.
* Each keeps the other medium's current order and rewrites the whole run in one request.
*/
export function mediaOrderPersisters(sources: MediaOrderSources): { books: PersistOrder; podcasts: PersistOrder } {
return {
async books(order) {
const books = ownedInOrder(order, options.books())
const books = ownedInOrder(order, sources.books())
if (books.length === 0) return
await options.reorder(mergedMediaOrder(books, options.podcasts()))
await sources.reorder(mergedMediaOrder(books, sources.podcasts()))
},
async podcasts(order) {
const podcasts = ownedInOrder(order, options.podcasts())
const podcasts = ownedInOrder(order, sources.podcasts())
if (podcasts.length === 0) return
await options.reorder(mergedMediaOrder(options.books(), podcasts))
await sources.reorder(mergedMediaOrder(sources.books(), podcasts))
},
}
}
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { ScanProgressEvent } from '@bookorbit/types'
import type { PodcastImportState } from '@/features/podcast/composables/usePodcastImportProgress'
import { useSidebarLibraryActivity } from '../useSidebarLibraryActivity'
interface ImportLane {
subscribeLibrary: (libraryId: number) => void
getImport: (libraryId: number) => PodcastImportState | null
importPct: (libraryId: number) => number
importBarWidth: (libraryId: number) => string
}
const mocks = vi.hoisted(() => ({
features: { podcasts: true },
scans: new Map<number, Partial<ScanProgressEvent>>(),
imports: new Map<number, PodcastImportState>(),
subscribeScan: undefined as unknown as Mock<(libraryId: number) => void>,
subscribeImport: undefined as unknown as Mock<(libraryId: number) => void>,
usePodcastImportProgress: undefined as unknown as Mock<() => ImportLane>,
}))
vi.mock('@bookorbit/types', async (importOriginal) => {
const actual = await importOriginal<typeof import('@bookorbit/types')>()
return { ...actual, APP_FEATURES: mocks.features }
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key) }),
}))
vi.mock('@/features/scanner/composables/useScanProgress', () => ({
useScanProgress: () => ({
subscribeLibrary: mocks.subscribeScan,
getProgress: (libraryId: number) => mocks.scans.get(libraryId),
}),
}))
vi.mock('@/features/podcast/composables/usePodcastImportProgress', () => ({
usePodcastImportProgress: () => mocks.usePodcastImportProgress(),
}))
function importState(libraryId: number, overrides: Partial<PodcastImportState> = {}): PodcastImportState {
return { libraryId, processed: 0, total: null, failed: 0, active: true, ...overrides }
}
describe('useSidebarLibraryActivity', () => {
beforeEach(() => {
mocks.features.podcasts = true
mocks.scans.clear()
mocks.imports.clear()
mocks.subscribeScan = vi.fn<(libraryId: number) => void>()
mocks.subscribeImport = vi.fn<(libraryId: number) => void>()
mocks.usePodcastImportProgress = vi.fn<() => ImportLane>(() => ({
subscribeLibrary: mocks.subscribeImport,
getImport: (libraryId: number) => mocks.imports.get(libraryId) ?? null,
importPct: () => 25,
importBarWidth: () => '25%',
}))
})
describe('book libraries', () => {
const library = { id: 1, type: 'books' }
it('report nothing unless a scan is running', () => {
const { activityFor } = useSidebarLibraryActivity()
expect(activityFor(library)).toBeNull()
mocks.scans.set(1, { status: 'completed', processed: 10, total: 10 })
expect(activityFor(library)).toBeNull()
})
it('report a running scan as a floored percentage of its total', () => {
mocks.scans.set(1, { status: 'running', processed: 1, total: 3 })
expect(useSidebarLibraryActivity().activityFor(library)).toEqual({
pct: 33,
barWidth: '33%',
label: 'components.sidebar.scanProgress {"processed":"1","total":"3"}',
})
})
it('show a fixed sliver while the scan has no total yet', () => {
mocks.scans.set(1, { status: 'running', processed: 0, total: 0 })
expect(useSidebarLibraryActivity().activityFor(library)).toMatchObject({ pct: 0, barWidth: '30%' })
})
})
describe('podcast libraries', () => {
const library = { id: 20, type: 'podcasts' }
it('read the import lane, never the book scan', () => {
mocks.scans.set(20, { status: 'running', processed: 5, total: 10 })
const { activityFor } = useSidebarLibraryActivity()
expect(activityFor(library)).toBeNull()
mocks.imports.set(20, importState(20, { processed: 1, total: 4 }))
expect(activityFor(library)).toEqual({
pct: 25,
barWidth: '25%',
label: 'components.sidebar.podcastImportProgress {"processed":"1","total":"4"}',
})
})
it('say the import is still counting until it knows its total', () => {
mocks.imports.set(20, importState(20, { total: null }))
expect(useSidebarLibraryActivity().activityFor(library)?.label).toBe('components.sidebar.podcastImportCounting')
})
it('drop the readout once the import settles', () => {
mocks.imports.set(20, importState(20, { processed: 4, total: 4, active: false }))
expect(useSidebarLibraryActivity().activityFor(library)).toBeNull()
})
it('report nothing, and never open the import lane, while the podcasts feature is off', () => {
mocks.features.podcasts = false
mocks.imports.set(20, importState(20, { processed: 1, total: 4 }))
expect(useSidebarLibraryActivity().activityFor(library)).toBeNull()
expect(mocks.usePodcastImportProgress).not.toHaveBeenCalled()
})
})
describe('subscribe', () => {
it('watches only the scan channel for a book library', () => {
useSidebarLibraryActivity().subscribe({ id: 1, type: 'books' })
expect(mocks.subscribeScan).toHaveBeenCalledWith(1)
expect(mocks.subscribeImport).not.toHaveBeenCalled()
})
it('watches both channels for a podcast library', () => {
useSidebarLibraryActivity().subscribe({ id: 20, type: 'podcasts' })
expect(mocks.subscribeScan).toHaveBeenCalledWith(20)
expect(mocks.subscribeImport).toHaveBeenCalledWith(20)
})
})
})
@@ -0,0 +1,222 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { computed, reactive, ref } from 'vue'
import type { LibraryType } from '@bookorbit/types'
import { useSidebarSections } from '../useSidebarSections'
type Reorder = (order: { id: number; displayOrder: number }[]) => Promise<void>
interface Row {
id: number
displayOrder: number
name: string
type?: string
mediaType?: 'books' | 'podcasts'
isOwner?: boolean
}
const mocks = vi.hoisted(() => ({
features: { podcasts: true },
permissions: new Set<string>(),
activityFor: () => null,
reorderLibraries: undefined as unknown as Mock<Reorder>,
reorderSmartScopes: undefined as unknown as Mock<Reorder>,
reorderCollections: undefined as unknown as Mock<Reorder>,
}))
const route = reactive<{ name: string; params: Record<string, string> }>({ name: 'dashboard', params: {} })
const mode = ref<LibraryType>('books')
const libraries = ref<Row[]>([])
const scopes = ref<Row[]>([])
const collections = ref<Row[]>([])
vi.mock('@bookorbit/types', async (importOriginal) => {
const actual = await importOriginal<typeof import('@bookorbit/types')>()
return { ...actual, APP_FEATURES: mocks.features }
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key) }),
}))
vi.mock('vue-router', () => ({ useRoute: () => route }))
vi.mock('@/composables/useMediaMode', () => ({ useMediaMode: () => ({ mode }) }))
vi.mock('@/composables/useSidebarLibraryActivity', () => ({ useSidebarLibraryActivity: () => ({ activityFor: mocks.activityFor }) }))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: (name: string) => mocks.permissions.has(name) }),
}))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries, reorderLibraries: mocks.reorderLibraries }),
}))
vi.mock('@/features/smart-scope/composables/useSmartScopes', () => ({
useSmartScopes: () => ({
bookScopes: computed(() => scopes.value.filter((scope) => scope.mediaType !== 'podcasts')),
podcastScopes: computed(() => scopes.value.filter((scope) => scope.mediaType === 'podcasts')),
reorderSmartScopes: mocks.reorderSmartScopes,
}),
}))
vi.mock('@/features/collection/composables/useCollections', () => ({
useCollections: () => ({
bookCollections: computed(() => collections.value.filter((collection) => collection.mediaType !== 'podcasts')),
podcastCollections: computed(() => collections.value.filter((collection) => collection.mediaType === 'podcasts')),
reorderCollections: mocks.reorderCollections,
}),
}))
function row(id: number, overrides: Partial<Row> = {}): Row {
return { id, displayOrder: id, name: `Row ${id}`, ...overrides }
}
function section(id: string) {
const { sections } = useSidebarSections()
const found = sections.value.find((candidate) => candidate.id === id)
if (!found) throw new Error(`Expected a ${id} section`)
return found
}
describe('useSidebarSections', () => {
beforeEach(() => {
mocks.features.podcasts = true
mocks.permissions.clear()
mocks.reorderLibraries = vi.fn<Reorder>().mockResolvedValue(undefined)
mocks.reorderSmartScopes = vi.fn<Reorder>().mockResolvedValue(undefined)
mocks.reorderCollections = vi.fn<Reorder>().mockResolvedValue(undefined)
route.name = 'dashboard'
route.params = {}
mode.value = 'books'
libraries.value = [row(1, { type: 'books' }), row(2, { type: 'books' }), row(20, { type: 'podcasts' })]
scopes.value = []
collections.value = []
})
it('lists libraries, smart scopes and collections in books mode, in that order', () => {
const { sections } = useSidebarSections()
expect(sections.value.map((candidate) => candidate.id)).toEqual(['libraries', 'smartScopes', 'collections'])
})
it('lists podcast libraries, playlists and collections in podcasts mode', () => {
mode.value = 'podcasts'
const { sections } = useSidebarSections()
expect(sections.value.map((candidate) => candidate.id)).toEqual(['podcasts', 'podcastScopes', 'podcastCollections'])
})
it('splits libraries by medium', () => {
expect(section('libraries').props.items.map((item) => item.id)).toEqual([1, 2])
mode.value = 'podcasts'
expect(section('podcasts').props.items.map((item) => item.id)).toEqual([20])
})
it('lights the row the current route names, and only in its own section', () => {
route.name = 'smartScope'
route.params = { id: '7' }
const { sections } = useSidebarSections()
expect(sections.value.map((candidate) => candidate.props.activeId)).toEqual([null, 7, null])
})
it('counts every row in the see-all label, not just the visible ones', () => {
expect(section('libraries').props.seeAllLabel).toBe('components.sidebar.seeAllLibraries {"count":"2"}')
})
it('gates adding and reordering libraries on manage_libraries, but not scopes or collections', () => {
const scopesSection = section('smartScopes')
expect([section('libraries').props.canAdd, section('libraries').canReorder]).toEqual([false, false])
expect([scopesSection.props.canAdd, scopesSection.canReorder]).toEqual([true, true])
mocks.permissions.add('manage_libraries')
expect([section('libraries').props.canAdd, section('libraries').canReorder]).toEqual([true, true])
})
it('offers new podcast playlists and collections only while podcasts are available', () => {
mode.value = 'podcasts'
expect([section('podcastScopes').props.canAdd, section('podcastCollections').props.canAdd]).toEqual([true, true])
libraries.value = [row(1, { type: 'books' })]
expect([section('podcastScopes').props.canAdd, section('podcastCollections').props.canAdd]).toEqual([false, false])
libraries.value = [row(20, { type: 'podcasts' })]
mocks.features.podcasts = false
expect(section('podcastScopes').props.canAdd).toBe(false)
})
it('reports podcasts as available only with the feature on and a podcast library present', () => {
expect(useSidebarSections().podcastsAvailable.value).toBe(true)
mocks.features.podcasts = false
expect(useSidebarSections().podcastsAvailable.value).toBe(false)
})
it('tracks run progress on library rows only', () => {
expect(section('libraries').activity).toBe(mocks.activityFor)
expect(section('smartScopes').activity).toBeUndefined()
expect(section('collections').activity).toBeUndefined()
})
it('names the dialog and medium its add button opens', () => {
mode.value = 'podcasts'
const { sections } = useSidebarSections()
expect(sections.value.map(({ kind, medium }) => [kind, medium])).toEqual([
['library', 'podcasts'],
['smartScope', 'podcasts'],
['collection', 'podcasts'],
])
})
it('persists only the scopes the user owns, keeping the podcast run behind them', async () => {
scopes.value = [
row(1, { isOwner: true }),
row(2, { isOwner: false }),
row(3, { isOwner: true }),
row(10, { mediaType: 'podcasts', isOwner: true }),
]
await section('smartScopes').persistOrder([
{ id: 3, displayOrder: 0 },
{ id: 2, displayOrder: 1 },
{ id: 1, displayOrder: 2 },
])
expect(mocks.reorderSmartScopes).toHaveBeenCalledWith([
{ id: 3, displayOrder: 0 },
{ id: 1, displayOrder: 1 },
{ id: 10, displayOrder: 2 },
])
})
it('persists only the collections the user can change', async () => {
collections.value = [row(1, { isOwner: true }), row(2, { isOwner: false }), row(10, { mediaType: 'podcasts', isOwner: true })]
mode.value = 'podcasts'
await section('podcastCollections').persistOrder([{ id: 10, displayOrder: 0 }])
expect(mocks.reorderCollections).toHaveBeenCalledWith([
{ id: 1, displayOrder: 0 },
{ id: 10, displayOrder: 1 },
])
})
it('persists a library reorder across both media in one request', async () => {
mocks.permissions.add('manage_libraries')
await section('libraries').persistOrder([
{ id: 2, displayOrder: 0 },
{ id: 1, displayOrder: 1 },
])
expect(mocks.reorderLibraries).toHaveBeenCalledWith([
{ id: 2, displayOrder: 0 },
{ id: 1, displayOrder: 1 },
{ id: 20, displayOrder: 2 },
])
})
})
@@ -0,0 +1,81 @@
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { Permission } from '@bookorbit/types'
import { useBrowseCounts } from '@/composables/useBrowseCounts'
import { useSidebarLibraryActivity } from '@/composables/useSidebarLibraryActivity'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary'
import { useBookRequestProgress } from '@/features/book-requests/composables/useBookRequestProgress'
import { useBookRequestSummary } from '@/features/book-requests/composables/useBookRequestSummary'
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
import { useCollections } from '@/features/collection/composables/useCollections'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryScanRefresh } from '@/features/library/composables/useLibraryScanRefresh'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { getSocket } from '@/features/scanner/composables/useScanProgress'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
/**
* Loads what the app sidebar lists and badges, and keeps it current while the shell is mounted.
* Call once, from the sidebar: the scan refresh and live listeners it registers are app-wide.
*/
export function useSidebarDataSync() {
const { hasPermission } = usePermissions()
const { showBookRequests } = useBookRequestVisibility()
const { libraries, fetchLibraries, refreshLibraries } = useLibraries()
const { fetchSmartScopes } = useSmartScopes()
const { fetchCollections } = useCollections()
const { fetchCounts: fetchBrowseCounts, refreshCounts: refreshBrowseCounts } = useBrowseCounts()
const { loadAppInfo } = useAppInfo()
const { fetchSummary: fetchBookDockSummary, subscribe: subscribeBookDockSummary } = useBookDockSummary()
const { summary: bookRequestSummary, fetchSummary: fetchBookRequestSummary, refreshSummary: refreshBookRequestSummary } = useBookRequestSummary()
const { subscribe: subscribeLibraryActivity } = useSidebarLibraryActivity()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const requestProgress = hasPermission(Permission.BookRequestAccess) ? useBookRequestProgress() : null
useLibraryScanRefresh()
const outstandingRequestTotal = computed(() =>
hasPermission(Permission.ManageBookRequests) ? (bookRequestSummary.value?.active ?? 0) : (bookRequestSummary.value?.mine ?? 0),
)
onMounted(async () => {
getSocket()
await fetchLibraries()
for (const library of libraries.value) {
subscribeLibraryActivity(library)
}
void fetchSmartScopes()
void fetchCollections()
void fetchBrowseCounts()
if (showBookRequests.value && hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary()
void loadAppInfo()
if (hasPermission('book_dock_access')) {
void fetchBookDockSummary()
subscribeBookDockSummary()
}
})
requestProgress?.onRequestsChanged(() => {
if (showBookRequests.value) void refreshBookRequestSummary()
})
if (requestProgress) {
watch(requestProgress.connected, (connected) => {
if (connected && showBookRequests.value) void refreshBookRequestSummary()
})
}
watch(showBookRequests, (visible) => {
if (visible && hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary()
})
const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount === 0) return
void refreshLibraries()
void refreshBrowseCounts()
})
onUnmounted(() => stopLibraryUploadListener())
return { outstandingRequestTotal }
}
@@ -0,0 +1,64 @@
import { useI18n } from 'vue-i18n'
import { APP_FEATURES } from '@bookorbit/types'
import { formatNumber } from '@/i18n/formatters'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
import { usePodcastImportProgress } from '@/features/podcast/composables/usePodcastImportProgress'
/** A run in progress on a library row: a book scan, or a podcast library's local-file import. */
export interface SidebarItemActivity {
pct: number
barWidth: string
label: string
}
interface ActivityTarget {
id: number
type?: string
}
const INDETERMINATE_SCAN_WIDTH = '30%'
export function useSidebarLibraryActivity() {
const { t } = useI18n()
const { subscribeLibrary: subscribeScan, getProgress } = useScanProgress()
const podcastImport = APP_FEATURES.podcasts ? usePodcastImportProgress() : null
function scanActivity(libraryId: number): SidebarItemActivity | null {
const progress = getProgress(libraryId)
if (progress?.status !== 'running') return null
const pct = progress.total > 0 ? Math.floor((progress.processed / progress.total) * 100) : 0
return {
pct,
barWidth: progress.total > 0 ? `${pct}%` : INDETERMINATE_SCAN_WIDTH,
label: t('components.sidebar.scanProgress', {
processed: formatNumber(progress.processed),
total: formatNumber(progress.total),
}),
}
}
function podcastImportActivity(libraryId: number): SidebarItemActivity | null {
const state = podcastImport?.getImport(libraryId)
if (!podcastImport || !state?.active) return null
return {
pct: podcastImport.importPct(libraryId),
barWidth: podcastImport.importBarWidth(libraryId),
label:
state.total === null
? t('components.sidebar.podcastImportCounting')
: t('components.sidebar.podcastImportProgress', { processed: formatNumber(state.processed), total: formatNumber(state.total) }),
}
}
function activityFor(library: ActivityTarget): SidebarItemActivity | null {
return library.type === 'podcasts' ? podcastImportActivity(library.id) : scanActivity(library.id)
}
/** Podcast libraries watch the same id on their own channel, so both subscriptions are needed. */
function subscribe(library: ActivityTarget): void {
subscribeScan(library.id)
if (library.type === 'podcasts') podcastImport?.subscribeLibrary(library.id)
}
return { activityFor, subscribe }
}
@@ -0,0 +1,253 @@
import { computed, type Component, type ComputedRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { Aperture, BookCopy, FolderOpen, Podcast } from '@lucide/vue'
import { APP_FEATURES, type LibraryType, type SidebarCappedSectionId } from '@bookorbit/types'
import type { SidebarEntity } from '@/components/sidebar/SidebarEntitySection.vue'
import { mediaOrderPersisters, type PersistOrder } from '@/components/sidebar/sidebar-order'
import { formatNumber } from '@/i18n/formatters'
import { useMediaMode } from '@/composables/useMediaMode'
import { useSidebarLibraryActivity, type SidebarItemActivity } from '@/composables/useSidebarLibraryActivity'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
import { useCollections } from '@/features/collection/composables/useCollections'
import { canMutateCollection } from '@/features/collection/lib/collection-access'
export type SidebarListKind = 'library' | 'smartScope' | 'collection'
interface SidebarSectionDefinition {
id: SidebarCappedSectionId
kind: SidebarListKind
/** The media mode the section belongs to. */
medium: LibraryType
icon: Component
routeName: string
/** Omit when the section has no index page; the see-all link is then suppressed. */
indexRouteName?: string
tourId?: string
copy: {
label: string
empty: string
filter: string
filterPlaceholder: string
seeAll: string
add: string
}
}
export const SIDEBAR_SECTIONS: readonly SidebarSectionDefinition[] = [
{
id: 'libraries',
kind: 'library',
medium: 'books',
icon: BookCopy,
routeName: 'library',
indexRouteName: 'libraries',
tourId: 'sidebar-libraries',
copy: {
label: 'components.sidebar.libraries',
empty: 'components.sidebar.noLibraries',
filter: 'components.sidebar.filterLibraries',
filterPlaceholder: 'components.sidebar.filterLibrariesPlaceholder',
seeAll: 'components.sidebar.seeAllLibraries',
add: 'components.sidebar.newLibrary',
},
},
{
id: 'smartScopes',
kind: 'smartScope',
medium: 'books',
icon: Aperture,
routeName: 'smartScope',
indexRouteName: 'smart-scopes',
tourId: 'sidebar-smartScopes',
copy: {
label: 'components.sidebar.smartScopes',
empty: 'components.sidebar.noSmartScopes',
filter: 'components.sidebar.filterSmartScopes',
filterPlaceholder: 'components.sidebar.filterSmartScopesPlaceholder',
seeAll: 'components.sidebar.seeAllSmartScopes',
add: 'components.sidebar.newSmartScope',
},
},
{
id: 'collections',
kind: 'collection',
medium: 'books',
icon: FolderOpen,
routeName: 'collection',
indexRouteName: 'collections',
tourId: 'sidebar-collections',
copy: {
label: 'components.sidebar.collections',
empty: 'components.sidebar.noCollections',
filter: 'components.sidebar.filterCollections',
filterPlaceholder: 'components.sidebar.filterCollectionsPlaceholder',
seeAll: 'components.sidebar.seeAllCollections',
add: 'components.sidebar.newCollection',
},
},
{
id: 'podcasts',
kind: 'library',
medium: 'podcasts',
icon: Podcast,
routeName: 'podcast-library',
indexRouteName: 'podcast-libraries',
copy: {
label: 'components.sidebar.podcasts',
empty: 'components.sidebar.noPodcastLibraries',
filter: 'components.sidebar.filterPodcastLibraries',
filterPlaceholder: 'components.sidebar.filterPodcastLibrariesPlaceholder',
seeAll: 'components.sidebar.seeAllPodcastLibraries',
add: 'components.sidebar.newPodcastLibrary',
},
},
{
id: 'podcastScopes',
kind: 'smartScope',
medium: 'podcasts',
icon: Aperture,
routeName: 'podcast-playlist',
copy: {
label: 'components.sidebar.playlists',
empty: 'components.sidebar.noPlaylists',
filter: 'components.sidebar.filterPlaylists',
filterPlaceholder: 'components.sidebar.filterPlaylistsPlaceholder',
seeAll: 'components.sidebar.seeAllPlaylists',
add: 'components.sidebar.newPlaylist',
},
},
{
id: 'podcastCollections',
kind: 'collection',
medium: 'podcasts',
icon: FolderOpen,
routeName: 'podcast-collection',
copy: {
label: 'components.sidebar.collections',
empty: 'components.sidebar.noPodcastCollections',
filter: 'components.sidebar.filterPodcastCollections',
filterPlaceholder: 'components.sidebar.filterPodcastCollectionsPlaceholder',
seeAll: 'components.sidebar.seeAllPodcastCollections',
add: 'components.sidebar.newPodcastCollection',
},
},
]
/** What a section passes to SidebarEntitySection in both the expanded list and the rail popover. */
export interface SidebarSectionProps {
sectionId: SidebarCappedSectionId
label: string
items: SidebarEntity[]
routeName: string
indexRouteName?: string
activeId: number | null
fallbackIcon: Component
emptyText: string
filterLabel: string
filterPlaceholder: string
seeAllLabel: string
canAdd: boolean
addLabel: string
}
export interface SidebarSection {
id: SidebarCappedSectionId
kind: SidebarListKind
medium: LibraryType
icon: Component
props: SidebarSectionProps
/** The rest is expanded-list only: rail popovers neither reorder, anchor the tour, nor show progress. */
tourId?: string
canReorder: boolean
persistOrder: PersistOrder
activity?: (item: SidebarEntity) => SidebarItemActivity | null
}
export function useSidebarSections() {
const { t } = useI18n()
const route = useRoute()
const { mode } = useMediaMode()
const { hasPermission } = usePermissions()
const { libraries, reorderLibraries } = useLibraries()
const { bookScopes, podcastScopes, reorderSmartScopes } = useSmartScopes()
const { bookCollections, podcastCollections, reorderCollections } = useCollections()
const { activityFor } = useSidebarLibraryActivity()
const bookLibraries = computed(() => libraries.value.filter((library) => library.type !== 'podcasts'))
const podcastLibraries = computed(() => libraries.value.filter((library) => library.type === 'podcasts'))
/** Books-only users never see the mode switcher; the library creator is their entry point into podcasts. */
const podcastsAvailable = computed(() => APP_FEATURES.podcasts && podcastLibraries.value.length > 0)
const canManageLibraries = computed(() => hasPermission('manage_libraries'))
const lists: Record<SidebarListKind, Record<LibraryType, ComputedRef<SidebarEntity[]>>> = {
library: { books: bookLibraries, podcasts: podcastLibraries },
smartScope: { books: bookScopes, podcasts: podcastScopes },
collection: { books: bookCollections, podcasts: podcastCollections },
}
// Shared scopes and collections belong to their owner, so their display order is not this user's to persist.
const persistOrder: Record<SidebarListKind, Record<LibraryType, PersistOrder>> = {
library: mediaOrderPersisters({
books: () => bookLibraries.value,
podcasts: () => podcastLibraries.value,
reorder: reorderLibraries,
}),
smartScope: mediaOrderPersisters({
books: () => bookScopes.value.filter((scope) => scope.isOwner),
podcasts: () => podcastScopes.value.filter((scope) => scope.isOwner),
reorder: reorderSmartScopes,
}),
collection: mediaOrderPersisters({
books: () => bookCollections.value.filter(canMutateCollection),
podcasts: () => podcastCollections.value.filter(canMutateCollection),
reorder: reorderCollections,
}),
}
function activeIdFor(routeName: string): number | null {
const id = route.params.id
return route.name === routeName && id ? Number(id) : null
}
function canAdd(definition: SidebarSectionDefinition): boolean {
if (definition.kind === 'library') return canManageLibraries.value
return definition.medium === 'books' || podcastsAvailable.value
}
function resolve(definition: SidebarSectionDefinition): SidebarSection {
const items = lists[definition.kind][definition.medium].value
const isLibrary = definition.kind === 'library'
return {
id: definition.id,
kind: definition.kind,
medium: definition.medium,
icon: definition.icon,
props: {
sectionId: definition.id,
label: t(definition.copy.label),
items,
routeName: definition.routeName,
indexRouteName: definition.indexRouteName,
activeId: activeIdFor(definition.routeName),
fallbackIcon: definition.icon,
emptyText: t(definition.copy.empty),
filterLabel: t(definition.copy.filter),
filterPlaceholder: t(definition.copy.filterPlaceholder),
seeAllLabel: t(definition.copy.seeAll, { count: formatNumber(items.length) }),
canAdd: canAdd(definition),
addLabel: t(definition.copy.add),
},
tourId: definition.tourId,
canReorder: isLibrary ? canManageLibraries.value : true,
persistOrder: persistOrder[definition.kind][definition.medium],
activity: isLibrary ? activityFor : undefined,
}
}
const sections = computed(() => SIDEBAR_SECTIONS.filter((definition) => definition.medium === mode.value).map(resolve))
return { sections, podcastLibraries, podcastsAvailable }
}