mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user