mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(client): let users hide and reorder sidebar rows and groups
Add a Customize sidebar mode, opened from any section menu or Cmd+K, that hides rows and groups and reorders them by drag or keyboard, with reset and undo. The layout saves to a new sidebarLayout setting; a hidden Book Dock or Requests row returns while work is waiting. Also line up the Rows shown menu labels, which sat 3px apart. Closes #1434
This commit is contained in:
@@ -15,8 +15,13 @@ import SidebarSectionPopover from '@/components/sidebar/SidebarSectionPopover.vu
|
|||||||
import SidebarFooterBar from '@/components/sidebar/SidebarFooterBar.vue'
|
import SidebarFooterBar from '@/components/sidebar/SidebarFooterBar.vue'
|
||||||
import SidebarDock from '@/components/sidebar/SidebarDock.vue'
|
import SidebarDock from '@/components/sidebar/SidebarDock.vue'
|
||||||
import SidebarSearchItem from '@/components/sidebar/SidebarSearchItem.vue'
|
import SidebarSearchItem from '@/components/sidebar/SidebarSearchItem.vue'
|
||||||
|
import SidebarCustomizer, { type SidebarCustomizeGroup } from '@/components/sidebar/SidebarCustomizer.vue'
|
||||||
|
import SidebarCustomizeBar from '@/components/sidebar/SidebarCustomizeBar.vue'
|
||||||
|
import { groupRuns } from '@/components/sidebar/sidebar-layout'
|
||||||
import { useSidebarDataSync } from '@/composables/useSidebarDataSync'
|
import { useSidebarDataSync } from '@/composables/useSidebarDataSync'
|
||||||
import { useSidebarNav } from '@/composables/useSidebarNav'
|
import { useSidebarNav, type SidebarNavZone } from '@/composables/useSidebarNav'
|
||||||
|
import { useSidebarLayout } from '@/composables/useSidebarLayout'
|
||||||
|
import { useSidebarCustomizeMode } from '@/composables/useSidebarCustomizeMode'
|
||||||
import { useSidebarSections, type SidebarListKind, type SidebarSection } from '@/composables/useSidebarSections'
|
import { useSidebarSections, type SidebarListKind, type SidebarSection } from '@/composables/useSidebarSections'
|
||||||
import { useSidebarLibraryActivity } from '@/composables/useSidebarLibraryActivity'
|
import { useSidebarLibraryActivity } from '@/composables/useSidebarLibraryActivity'
|
||||||
import { mediaModeHome, useMediaMode } from '@/composables/useMediaMode'
|
import { mediaModeHome, useMediaMode } from '@/composables/useMediaMode'
|
||||||
@@ -29,19 +34,83 @@ import SettingsSidebar from '@/features/settings/components/SettingsSidebar.vue'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { state, isMobile, setOpenMobile } = useSidebar()
|
const { state, isMobile, openMobile, setOpen, setOpenMobile } = useSidebar()
|
||||||
const { mode, setMode, syncModeFromRoute } = useMediaMode()
|
const { mode, setMode, syncModeFromRoute } = useMediaMode()
|
||||||
const { outstandingRequestTotal } = useSidebarDataSync()
|
const { outstandingRequestTotal } = useSidebarDataSync()
|
||||||
const { zones } = useSidebarNav(() => outstandingRequestTotal.value)
|
const { zones } = useSidebarNav(() => outstandingRequestTotal.value)
|
||||||
const { sections, podcastLibraries, podcastsAvailable } = useSidebarSections()
|
const { sections, podcastLibraries, podcastsAvailable } = useSidebarSections()
|
||||||
const { subscribe: subscribeLibraryActivity } = useSidebarLibraryActivity()
|
const { subscribe: subscribeLibraryActivity } = useSidebarLibraryActivity()
|
||||||
const { handleLibraryCreated } = useLibraryCreationRedirect()
|
const { handleLibraryCreated } = useLibraryCreationRedirect()
|
||||||
|
const { isHidden, arrangeRows, visibleRows, arrangeGroups } = useSidebarLayout()
|
||||||
|
const { isCustomizing, stopCustomizing } = useSidebarCustomizeMode()
|
||||||
|
|
||||||
const isRail = computed(() => state.value === 'collapsed' && !isMobile.value)
|
const isRail = computed(() => state.value === 'collapsed' && !isMobile.value)
|
||||||
const isSettingsRoute = computed(() => typeof route.name === 'string' && route.name.startsWith('settings-'))
|
const isSettingsRoute = computed(() => typeof route.name === 'string' && route.name.startsWith('settings-'))
|
||||||
/** Reading sits below the libraries and lists; everything else stays above them. */
|
|
||||||
const topZones = computed(() => zones.value.filter((zone) => zone.id !== 'reading'))
|
type SidebarBlock = { kind: 'nav'; id: string; zone: SidebarNavZone } | { kind: 'list'; id: string; section: SidebarSection }
|
||||||
const bottomZones = computed(() => zones.value.filter((zone) => zone.id === 'reading'))
|
|
||||||
|
/** Fixed destinations stay pinned on top: they are a known height, so nothing the user moves can push them off the first screenful. */
|
||||||
|
const primaryZone = computed(() => zones.value.find((zone) => zone.id === 'primary') ?? null)
|
||||||
|
const primaryEntries = computed(() => (primaryZone.value ? visibleRows('primary', primaryZone.value.entries) : []))
|
||||||
|
|
||||||
|
/** Every movable group in the user's order, hidden ones included. */
|
||||||
|
const arrangedBlocks = computed<SidebarBlock[]>(() =>
|
||||||
|
arrangeGroups<SidebarBlock>([
|
||||||
|
...zones.value.filter((zone) => zone.id !== 'primary').map((zone) => ({ kind: 'nav' as const, id: zone.id, zone })),
|
||||||
|
...sections.value.map((section) => ({ kind: 'list' as const, id: section.id, section })),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runs = computed(() =>
|
||||||
|
groupRuns(
|
||||||
|
arrangedBlocks.value
|
||||||
|
.filter((block) => !isHidden(block.id))
|
||||||
|
.map((block): SidebarBlock =>
|
||||||
|
block.kind === 'nav' ? { ...block, zone: { ...block.zone, entries: visibleRows(block.id, block.zone.entries) } } : block,
|
||||||
|
)
|
||||||
|
.filter((block) => block.kind === 'list' || block.zone.entries.length > 0),
|
||||||
|
).map((run) => ({ ...run, key: run.blocks.map((block) => block.id).join(',') })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const customizePrimaryEntries = computed(() => (primaryZone.value ? arrangeRows('primary', primaryZone.value.entries) : []))
|
||||||
|
const customizeGroups = computed<SidebarCustomizeGroup[]>(() =>
|
||||||
|
arrangedBlocks.value.map((block) =>
|
||||||
|
block.kind === 'nav'
|
||||||
|
? {
|
||||||
|
id: block.id,
|
||||||
|
kind: 'nav',
|
||||||
|
label: block.zone.labelKey ? t(block.zone.labelKey) : '',
|
||||||
|
count: null,
|
||||||
|
entries: arrangeRows(block.id, block.zone.entries),
|
||||||
|
}
|
||||||
|
: { id: block.id, kind: 'list', label: block.section.props.label, count: block.section.props.items.length, entries: [] },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Editing needs the full-width list, so the rail opens for it and folds back afterwards. */
|
||||||
|
let collapseAfterCustomizing = false
|
||||||
|
watch(isCustomizing, (customizing) => {
|
||||||
|
if (customizing) {
|
||||||
|
if (isMobile.value) setOpenMobile(true)
|
||||||
|
else if (state.value === 'collapsed') {
|
||||||
|
collapseAfterCustomizing = true
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (collapseAfterCustomizing) {
|
||||||
|
collapseAfterCustomizing = false
|
||||||
|
if (!isMobile.value) setOpen(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isSettingsRoute, (settings) => {
|
||||||
|
if (settings) stopCustomizing()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(openMobile, (open) => {
|
||||||
|
if (!open && isMobile.value) stopCustomizing()
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.name,
|
() => route.name,
|
||||||
@@ -132,19 +201,13 @@ async function onLibrarySaved(library: Library) {
|
|||||||
<!-- Settings takes over the sidebar rather than adding a second one next to it. -->
|
<!-- Settings takes over the sidebar rather than adding a second one next to it. -->
|
||||||
<SettingsSidebar v-if="isSettingsRoute" :is-rail="isRail" />
|
<SettingsSidebar v-if="isSettingsRoute" :is-rail="isRail" />
|
||||||
|
|
||||||
|
<SidebarCustomizer v-else-if="isCustomizing" :primary-entries="customizePrimaryEntries" :groups="customizeGroups" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Fixed destinations come first: they are a known height, so the variable-length
|
<SidebarZone v-if="primaryZone" :label="null" :always-open="isRail">
|
||||||
entity sections below can never push them out of the first screenful. -->
|
<SidebarSearchItem />
|
||||||
<SidebarZone
|
|
||||||
v-for="zone in topZones"
|
|
||||||
:key="zone.id"
|
|
||||||
:label="zone.labelKey ? t(zone.labelKey) : null"
|
|
||||||
:section-id="zone.sectionId ?? undefined"
|
|
||||||
:always-open="isRail"
|
|
||||||
>
|
|
||||||
<SidebarSearchItem v-if="zone.id === 'primary'" />
|
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
v-for="entry in zone.entries"
|
v-for="entry in primaryEntries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
:is-active="entry.isActive"
|
:is-active="entry.isActive"
|
||||||
:tooltip="entry.badge?.label ?? entry.label"
|
:tooltip="entry.badge?.label ?? entry.label"
|
||||||
@@ -162,66 +225,73 @@ async function onLibrarySaved(library: Library) {
|
|||||||
</SidebarNavItem>
|
</SidebarNavItem>
|
||||||
</SidebarZone>
|
</SidebarZone>
|
||||||
|
|
||||||
<SidebarSeparator />
|
<!-- Groups follow in the user's order. A separator sits wherever nav groups meet list
|
||||||
|
sections; the pinned top group counts as nav. -->
|
||||||
|
<template v-for="(run, runIndex) in runs" :key="run.key">
|
||||||
|
<SidebarSeparator v-if="runIndex > 0 || run.kind === 'list'" />
|
||||||
|
|
||||||
<div v-if="isRail" class="flex flex-col items-center gap-1 px-2">
|
<template v-if="run.kind === 'nav'">
|
||||||
<SidebarSectionPopover
|
<template v-for="block in run.blocks" :key="block.id">
|
||||||
v-for="section in sections"
|
<SidebarZone
|
||||||
:key="section.id"
|
v-if="block.kind === 'nav'"
|
||||||
:label="section.props.label"
|
:label="block.zone.labelKey ? t(block.zone.labelKey) : null"
|
||||||
:icon="section.icon"
|
:section-id="block.zone.sectionId ?? undefined"
|
||||||
:count="section.props.items.length"
|
:always-open="isRail"
|
||||||
>
|
>
|
||||||
<SidebarEntitySection v-bind="section.props" always-open @add="handleAdd(section)" @navigate="handleNavigate" />
|
<SidebarNavItem
|
||||||
|
v-for="entry in block.zone.entries"
|
||||||
|
:key="entry.id"
|
||||||
|
:is-active="entry.isActive"
|
||||||
|
:tooltip="entry.badge?.label ?? entry.label"
|
||||||
|
:to="entry.to"
|
||||||
|
:icon="entry.icon"
|
||||||
|
:label="entry.label"
|
||||||
|
:data-tour="entry.tourId"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
>
|
||||||
|
<template #badge>
|
||||||
|
<SidebarBadge v-if="entry.badge !== null" :tone="entry.badge.tone" :label="entry.badge.label">{{
|
||||||
|
formatCompactNumber(entry.badge.value)
|
||||||
|
}}</SidebarBadge>
|
||||||
|
</template>
|
||||||
|
</SidebarNavItem>
|
||||||
|
</SidebarZone>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else-if="isRail" class="flex flex-col items-center gap-1 px-2">
|
||||||
|
<template v-for="block in run.blocks" :key="block.id">
|
||||||
|
<SidebarSectionPopover
|
||||||
|
v-if="block.kind === 'list'"
|
||||||
|
:label="block.section.props.label"
|
||||||
|
:icon="block.section.icon"
|
||||||
|
:count="block.section.props.items.length"
|
||||||
|
>
|
||||||
|
<SidebarEntitySection v-bind="block.section.props" always-open @add="handleAdd(block.section)" @navigate="handleNavigate" />
|
||||||
</SidebarSectionPopover>
|
</SidebarSectionPopover>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<template v-for="block in run.blocks" :key="block.id">
|
||||||
<SidebarEntitySection
|
<SidebarEntitySection
|
||||||
v-for="section in sections"
|
v-if="block.kind === 'list'"
|
||||||
:key="section.id"
|
v-bind="block.section.props"
|
||||||
v-bind="section.props"
|
:tour-id="block.section.tourId"
|
||||||
:tour-id="section.tourId"
|
:can-reorder="block.section.canReorder"
|
||||||
:can-reorder="section.canReorder"
|
:persist-order="block.section.persistOrder"
|
||||||
:persist-order="section.persistOrder"
|
:activity="block.section.activity"
|
||||||
:activity="section.activity"
|
@add="handleAdd(block.section)"
|
||||||
@add="handleAdd(section)"
|
|
||||||
@navigate="handleNavigate"
|
@navigate="handleNavigate"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="bottomZones.length">
|
|
||||||
<SidebarSeparator />
|
|
||||||
<SidebarZone
|
|
||||||
v-for="zone in bottomZones"
|
|
||||||
:key="zone.id"
|
|
||||||
:label="zone.labelKey ? t(zone.labelKey) : null"
|
|
||||||
:section-id="zone.sectionId ?? undefined"
|
|
||||||
:always-open="isRail"
|
|
||||||
>
|
|
||||||
<SidebarNavItem
|
|
||||||
v-for="entry in zone.entries"
|
|
||||||
:key="entry.id"
|
|
||||||
:is-active="entry.isActive"
|
|
||||||
:tooltip="entry.badge?.label ?? entry.label"
|
|
||||||
:to="entry.to"
|
|
||||||
:icon="entry.icon"
|
|
||||||
:label="entry.label"
|
|
||||||
:data-tour="entry.tourId"
|
|
||||||
@navigate="handleNavigate"
|
|
||||||
>
|
|
||||||
<template #badge>
|
|
||||||
<SidebarBadge v-if="entry.badge !== null" :tone="entry.badge.tone" :label="entry.badge.label">{{
|
|
||||||
formatCompactNumber(entry.badge.value)
|
|
||||||
}}</SidebarBadge>
|
|
||||||
</template>
|
</template>
|
||||||
</SidebarNavItem>
|
|
||||||
</SidebarZone>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarDock :is-rail="isRail" :is-mobile="isMobile" @navigate="handleNavigate" />
|
<SidebarCustomizeBar v-if="isCustomizing && !isSettingsRoute" />
|
||||||
|
<SidebarDock v-else :is-rail="isRail" :is-mobile="isMobile" @navigate="handleNavigate" />
|
||||||
<SidebarFooterBar v-if="!isSettingsRoute" :is-rail="isRail" @navigate="handleNavigate" />
|
<SidebarFooterBar v-if="!isSettingsRoute" :is-rail="isRail" @navigate="handleNavigate" />
|
||||||
|
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { RotateCcw } from '@lucide/vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useSidebarCustomizeMode } from '@/composables/useSidebarCustomizeMode'
|
||||||
|
import { useSidebarLayout } from '@/composables/useSidebarLayout'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { resetLayout, restoreLayout } = useSidebarLayout()
|
||||||
|
const { stopCustomizing } = useSidebarCustomizeMode()
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
const previous = resetLayout()
|
||||||
|
toast.success(t('components.sidebar.customize.resetDone'), {
|
||||||
|
action: { label: t('common.undo'), onClick: () => restoreLayout(previous) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDone() {
|
||||||
|
stopCustomizing()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Takes the dock's place while editing; the footer row below stays. -->
|
||||||
|
<div class="flex shrink-0 items-center gap-1.5 border-t border-sidebar-border px-2 py-[7px]" data-testid="sidebar-customize-bar">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="min-w-0 gap-2 px-2 text-[13px] text-sidebar-foreground hover:bg-(--shell-accent-wash)"
|
||||||
|
@click="handleReset"
|
||||||
|
>
|
||||||
|
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||||
|
<RotateCcw :size="15" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{{ t('components.sidebar.customize.reset') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" class="ms-auto shrink-0 px-4 text-[13px]" @click="handleDone">{{ t('components.sidebar.customize.done') }}</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Eye, EyeOff, GripVertical, Lock } from '@lucide/vue'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string
|
||||||
|
hidden: boolean
|
||||||
|
locked: boolean
|
||||||
|
lifted: boolean
|
||||||
|
/** Rows and groups sort in nested lists, so each needs its own handle selector. */
|
||||||
|
handleClass: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ toggle: []; 'grip-keydown': [KeyboardEvent]; 'grip-blur': [] }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
||||||
|
const lockedLabel = computed(() => t('components.sidebar.customize.alwaysShown', { name: props.name }))
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
emit('toggle')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGripKeydown(event: KeyboardEvent) {
|
||||||
|
emit('grip-keydown', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGripBlur() {
|
||||||
|
emit('grip-blur')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Fixed widths keep the eye and grip in one column down every row and group header. On touch
|
||||||
|
screens each hit area grows sideways into the label and row padding, and by 1px to the 30px
|
||||||
|
row pitch, so a finger gets a 32px eye and a 24px grip without neighbouring rows overlapping. -->
|
||||||
|
<div class="flex shrink-0 items-center gap-px">
|
||||||
|
<template v-if="locked">
|
||||||
|
<span class="flex size-6 items-center justify-center text-muted-foreground" :title="lockedLabel">
|
||||||
|
<Lock :size="13" :stroke-width="2.25" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{{ lockedLabel }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="w-4" aria-hidden="true" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative flex size-6 items-center justify-center outline-hidden transition-colors duration-150 before:absolute before:content-[''] hover:bg-(--shell-accent-wash) focus-visible:ring-2 focus-visible:ring-sidebar-ring pointer-coarse:before:-inset-y-[3px] pointer-coarse:before:-start-2 pointer-coarse:before:end-0"
|
||||||
|
:class="[iconRadiusClass, hidden ? 'text-muted-foreground hover:text-sidebar-foreground' : 'text-sidebar-foreground']"
|
||||||
|
:aria-pressed="!hidden"
|
||||||
|
:aria-label="t('components.sidebar.customize.showInSidebar', { name })"
|
||||||
|
:title="hidden ? t('components.sidebar.customize.show') : t('components.sidebar.customize.hide')"
|
||||||
|
@click="handleToggle"
|
||||||
|
>
|
||||||
|
<EyeOff v-if="hidden" :size="15" aria-hidden="true" />
|
||||||
|
<Eye v-else :size="15" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative flex h-7 w-4 shrink-0 cursor-grab items-center justify-center rounded-sm outline-hidden transition-colors duration-150 before:absolute before:content-[''] hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring pointer-coarse:before:-inset-y-px pointer-coarse:before:start-0 pointer-coarse:before:-end-2"
|
||||||
|
:class="[handleClass, lifted ? 'text-primary' : 'text-muted-foreground']"
|
||||||
|
:aria-label="t('components.sidebar.reorder.gripAria', { name })"
|
||||||
|
:aria-pressed="lifted"
|
||||||
|
@keydown="handleGripKeydown"
|
||||||
|
@blur="handleGripBlur"
|
||||||
|
>
|
||||||
|
<GripVertical :size="13" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import AppIcon from '@/components/AppIcon.vue'
|
||||||
|
import SidebarCustomizeControls from '@/components/sidebar/SidebarCustomizeControls.vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
icon: Component | string
|
||||||
|
label: string
|
||||||
|
hidden?: boolean
|
||||||
|
locked?: boolean
|
||||||
|
lifted?: boolean
|
||||||
|
/** Shown under a hidden row that still comes back on its own. */
|
||||||
|
hiddenHint?: string
|
||||||
|
}>(),
|
||||||
|
{ hidden: false, locked: false, lifted: false, hiddenHint: undefined },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ toggle: []; 'grip-keydown': [KeyboardEvent]; 'grip-blur': [] }>()
|
||||||
|
|
||||||
|
const NAV_ICON_SIZE = 17
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
emit('toggle')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGripKeydown(event: KeyboardEvent) {
|
||||||
|
emit('grip-keydown', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGripBlur() {
|
||||||
|
emit('grip-blur')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Same box as a nav row (28px, px-2, 20px icon slot) so nothing shifts on entering edit mode. -->
|
||||||
|
<li class="list-none rounded-md transition-colors duration-150" :class="lifted ? 'bg-(--shell-accent-wash)' : ''">
|
||||||
|
<div class="flex h-7 min-w-0 items-center gap-2 ps-2 pe-2">
|
||||||
|
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
|
<component
|
||||||
|
:is="icon"
|
||||||
|
v-if="typeof icon !== 'string'"
|
||||||
|
:size="NAV_ICON_SIZE"
|
||||||
|
:class="hidden ? 'text-muted-foreground' : 'text-sidebar-foreground'"
|
||||||
|
/>
|
||||||
|
<AppIcon v-else :icon="icon" :size="NAV_ICON_SIZE" :class="hidden ? 'text-muted-foreground' : 'text-sidebar-foreground'" />
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-[14px]" :class="hidden ? 'text-muted-foreground' : 'text-sidebar-foreground'">{{ label }}</span>
|
||||||
|
<SidebarCustomizeControls
|
||||||
|
:name="label"
|
||||||
|
:hidden="hidden"
|
||||||
|
:locked="locked"
|
||||||
|
:lifted="lifted"
|
||||||
|
handle-class="row-drag-handle"
|
||||||
|
@toggle="handleToggle"
|
||||||
|
@grip-keydown="handleGripKeydown"
|
||||||
|
@grip-blur="handleGripBlur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="hidden && hiddenHint" class="-mt-0.5 ps-9 pe-2 pb-1 text-[13px] leading-snug text-muted-foreground">{{ hiddenHint }}</p>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { VueDraggable } from 'vue-draggable-plus'
|
||||||
|
import SidebarCustomizeRow from '@/components/sidebar/SidebarCustomizeRow.vue'
|
||||||
|
import { formatNumber } from '@/i18n/formatters'
|
||||||
|
import { useListReorder } from '@/composables/useListReorder'
|
||||||
|
import { SIDEBAR_LOCKED_IDS, useSidebarLayout } from '@/composables/useSidebarLayout'
|
||||||
|
import type { ResolvedSidebarNavEntry } from '@/composables/useSidebarNav'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
zoneId: string
|
||||||
|
/** Every row the user may see in this zone, arranged, hidden ones included. */
|
||||||
|
entries: ResolvedSidebarNavEntry[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { isHidden, toggleHidden, moveRows } = useSidebarLayout()
|
||||||
|
|
||||||
|
const lockedEntries = computed(() => props.entries.filter((entry) => SIDEBAR_LOCKED_IDS.has(entry.id)))
|
||||||
|
const movableEntries = computed(() => props.entries.filter((entry) => !SIDEBAR_LOCKED_IDS.has(entry.id)))
|
||||||
|
const entriesById = computed(() => new Map(props.entries.map((entry) => [entry.id, entry])))
|
||||||
|
|
||||||
|
const { items, liftedId, status, onDragStart, onDragEnd, handleGripKeydown, handleGripBlur } = useListReorder(
|
||||||
|
() => movableEntries.value.map((entry) => entry.id),
|
||||||
|
(order) => moveRows(props.zoneId, [...lockedEntries.value.map((entry) => entry.id), ...order]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const announcement = computed(() => {
|
||||||
|
const current = status.value
|
||||||
|
if (!current) return ''
|
||||||
|
return t(`components.sidebar.reorder.${current.kind}`, {
|
||||||
|
name: entriesById.value.get(current.id)?.label ?? '',
|
||||||
|
position: formatNumber(current.position),
|
||||||
|
total: formatNumber(current.total),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function entryOf(id: string): ResolvedSidebarNavEntry {
|
||||||
|
return entriesById.value.get(id) as ResolvedSidebarNavEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenHintOf(id: string): string | undefined {
|
||||||
|
return entryOf(id).returnsWithWork ? t('components.sidebar.customize.returnsWithWork') : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggle(id: string) {
|
||||||
|
toggleHidden(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGripKeydown(event: KeyboardEvent, id: string) {
|
||||||
|
handleGripKeydown(event, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGripBlur(id: string) {
|
||||||
|
handleGripBlur(id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="flex w-full min-w-0 flex-col gap-0.5">
|
||||||
|
<slot name="leading" />
|
||||||
|
<SidebarCustomizeRow v-for="entry in lockedEntries" :key="entry.id" :icon="entry.icon" :label="entry.label" locked />
|
||||||
|
<VueDraggable
|
||||||
|
v-model="items"
|
||||||
|
tag="div"
|
||||||
|
class="contents"
|
||||||
|
handle=".row-drag-handle"
|
||||||
|
:animation="150"
|
||||||
|
:delay="200"
|
||||||
|
:delay-on-touch-only="true"
|
||||||
|
@start="onDragStart"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<SidebarCustomizeRow
|
||||||
|
v-for="id in items"
|
||||||
|
:key="id"
|
||||||
|
:icon="entryOf(id).icon"
|
||||||
|
:label="entryOf(id).label"
|
||||||
|
:hidden="isHidden(id)"
|
||||||
|
:lifted="liftedId === id"
|
||||||
|
:hidden-hint="hiddenHintOf(id)"
|
||||||
|
@toggle="handleToggle(id)"
|
||||||
|
@grip-keydown="onGripKeydown($event, id)"
|
||||||
|
@grip-blur="onGripBlur(id)"
|
||||||
|
/>
|
||||||
|
</VueDraggable>
|
||||||
|
</ul>
|
||||||
|
<p class="sr-only" role="status" aria-live="polite">{{ announcement }}</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, useId } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Search, SlidersHorizontal } from '@lucide/vue'
|
||||||
|
import { VueDraggable } from 'vue-draggable-plus'
|
||||||
|
import { SidebarGroup, SidebarSeparator } from '@/components/ui/sidebar'
|
||||||
|
import SidebarCustomizeControls from '@/components/sidebar/SidebarCustomizeControls.vue'
|
||||||
|
import SidebarCustomizeRow from '@/components/sidebar/SidebarCustomizeRow.vue'
|
||||||
|
import SidebarCustomizeRows from '@/components/sidebar/SidebarCustomizeRows.vue'
|
||||||
|
import type { SidebarBlockKind } from '@/components/sidebar/sidebar-layout'
|
||||||
|
import { formatCompactNumber, formatNumber } from '@/i18n/formatters'
|
||||||
|
import { useListReorder } from '@/composables/useListReorder'
|
||||||
|
import { useSidebarCustomizeMode } from '@/composables/useSidebarCustomizeMode'
|
||||||
|
import { useSidebarLayout } from '@/composables/useSidebarLayout'
|
||||||
|
import type { ResolvedSidebarNavEntry } from '@/composables/useSidebarNav'
|
||||||
|
|
||||||
|
export interface SidebarCustomizeGroup {
|
||||||
|
id: string
|
||||||
|
kind: SidebarBlockKind
|
||||||
|
label: string
|
||||||
|
/** List sections fold to their header while editing and show how many rows they hold. */
|
||||||
|
count: number | null
|
||||||
|
entries: ResolvedSidebarNavEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
primaryEntries: ResolvedSidebarNavEntry[]
|
||||||
|
groups: SidebarCustomizeGroup[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { isHidden, toggleHidden, moveGroups } = useSidebarLayout()
|
||||||
|
const { stopCustomizing } = useSidebarCustomizeMode()
|
||||||
|
|
||||||
|
const titleId = `sidebar-customize-${useId()}`
|
||||||
|
const titleRef = ref<HTMLElement | null>(null)
|
||||||
|
const groupsById = computed(() => new Map(props.groups.map((group) => [group.id, group])))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: groupIds,
|
||||||
|
liftedId,
|
||||||
|
status,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
handleGripKeydown,
|
||||||
|
handleGripBlur,
|
||||||
|
} = useListReorder(
|
||||||
|
() => props.groups.map((group) => group.id),
|
||||||
|
(order) => moveGroups(order),
|
||||||
|
)
|
||||||
|
|
||||||
|
const announcement = computed(() => {
|
||||||
|
const current = status.value
|
||||||
|
if (!current) return ''
|
||||||
|
return t(`components.sidebar.reorder.${current.kind}`, {
|
||||||
|
name: groupsById.value.get(current.id)?.label ?? '',
|
||||||
|
position: formatNumber(current.position),
|
||||||
|
total: formatNumber(current.total),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function groupOf(id: string): SidebarCustomizeGroup {
|
||||||
|
return groupsById.value.get(id) as SidebarCustomizeGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same rule as the live sidebar: a line only where nav groups meet list sections. The pinned top group is nav. */
|
||||||
|
function separatorBefore(index: number): boolean {
|
||||||
|
const previous = index === 0 ? 'nav' : groupOf(groupIds.value[index - 1] as string)?.kind
|
||||||
|
return groupOf(groupIds.value[index] as string)?.kind !== previous
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggle(id: string) {
|
||||||
|
toggleHidden(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGripKeydown(event: KeyboardEvent, id: string) {
|
||||||
|
handleGripKeydown(event, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGripBlur(id: string) {
|
||||||
|
handleGripBlur(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A grip that is carrying a row claims Escape first, so this only fires when nothing is lifted. */
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||||
|
event.preventDefault()
|
||||||
|
stopCustomizing()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleWindowKeydown)
|
||||||
|
titleRef.value?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleWindowKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="flex flex-col" :aria-labelledby="titleId" data-testid="sidebar-customizer">
|
||||||
|
<!-- The 20px icon slot and 8px gap put the title and hint on the same columns as the rows below. -->
|
||||||
|
<div class="mx-2 mt-1.5 mb-0.5 rounded-(--shell-radius) border border-(--shell-accent-line) bg-(--shell-accent-tint) px-[7px] pt-2 pb-[9px]">
|
||||||
|
<h2
|
||||||
|
:id="titleId"
|
||||||
|
ref="titleRef"
|
||||||
|
tabindex="-1"
|
||||||
|
class="flex items-center gap-2 text-[13px] leading-tight font-semibold text-sidebar-foreground outline-hidden"
|
||||||
|
>
|
||||||
|
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center text-primary">
|
||||||
|
<SlidersHorizontal :size="15" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
{{ t('components.sidebar.customize.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-0.5 ps-7 text-[12.5px] leading-snug text-balance text-muted-foreground">{{ t('components.sidebar.customize.hint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarCustomizeRows zone-id="primary" :entries="primaryEntries">
|
||||||
|
<template #leading>
|
||||||
|
<SidebarCustomizeRow :icon="Search" :label="t('common.search')" locked />
|
||||||
|
</template>
|
||||||
|
</SidebarCustomizeRows>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<VueDraggable
|
||||||
|
v-model="groupIds"
|
||||||
|
tag="div"
|
||||||
|
handle=".group-drag-handle"
|
||||||
|
:animation="150"
|
||||||
|
:delay="200"
|
||||||
|
:delay-on-touch-only="true"
|
||||||
|
@start="onDragStart"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<div v-for="(id, index) in groupIds" :key="id">
|
||||||
|
<SidebarSeparator v-if="separatorBefore(index)" />
|
||||||
|
<!-- Inset 6px with 2px padding, so a lifted group's wash stops short of the sidebar edge without moving its columns. -->
|
||||||
|
<SidebarGroup
|
||||||
|
class="mx-1.5 w-auto rounded-md px-0.5 transition-colors duration-150"
|
||||||
|
:class="liftedId === id ? 'bg-(--shell-accent-wash)' : ''"
|
||||||
|
>
|
||||||
|
<div class="flex h-8 min-w-0 items-center gap-1.5 px-2">
|
||||||
|
<span class="sidebar-section-label min-w-0 truncate" :class="isHidden(id) ? 'text-muted-foreground' : ''">{{ groupOf(id).label }}</span>
|
||||||
|
<span
|
||||||
|
v-if="groupOf(id).count !== null"
|
||||||
|
class="sidebar-count shrink-0 rounded-md px-1.5 tabular-nums"
|
||||||
|
:class="isHidden(id) ? 'text-muted-foreground' : ''"
|
||||||
|
>{{ formatCompactNumber(groupOf(id).count ?? 0) }}</span
|
||||||
|
>
|
||||||
|
<SidebarCustomizeControls
|
||||||
|
class="ms-auto"
|
||||||
|
:name="groupOf(id).label"
|
||||||
|
:hidden="isHidden(id)"
|
||||||
|
:locked="false"
|
||||||
|
:lifted="liftedId === id"
|
||||||
|
handle-class="group-drag-handle"
|
||||||
|
@toggle="handleToggle(id)"
|
||||||
|
@grip-keydown="onGripKeydown($event, id)"
|
||||||
|
@grip-blur="onGripBlur(id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SidebarCustomizeRows v-if="groupOf(id).kind === 'nav' && !isHidden(id)" :zone-id="id" :entries="groupOf(id).entries" />
|
||||||
|
</SidebarGroup>
|
||||||
|
</div>
|
||||||
|
</VueDraggable>
|
||||||
|
<p class="sr-only" role="status" aria-live="polite">{{ announcement }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ChevronDown, Check, GripVertical, MoreVertical, Plus } from '@lucide/vue'
|
import { ChevronDown, Check, GripVertical, MoreVertical, Plus, SlidersHorizontal } from '@lucide/vue'
|
||||||
import { SIDEBAR_CAP_OPTIONS, type SidebarCap } from '@bookorbit/types'
|
import { SIDEBAR_CAP_OPTIONS, type SidebarCap } from '@bookorbit/types'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { formatCompactNumber, formatNumber } from '@/i18n/formatters'
|
import { formatCompactNumber, formatNumber } from '@/i18n/formatters'
|
||||||
|
import { useSidebarCustomizeMode } from '@/composables/useSidebarCustomizeMode'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -28,14 +29,17 @@ const props = withDefaults(
|
|||||||
isReordering?: boolean
|
isReordering?: boolean
|
||||||
/** Rail popovers show the body unconditionally, so the toggle becomes a plain label. */
|
/** Rail popovers show the body unconditionally, so the toggle becomes a plain label. */
|
||||||
collapsible?: boolean
|
collapsible?: boolean
|
||||||
|
/** Every section menu can open sidebar edit mode, which is how users find it. */
|
||||||
|
canCustomize?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{ collapsible: true },
|
{ collapsible: true, canCustomize: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{ toggle: []; add: []; 'update:cap': [SidebarCap]; 'toggle-reorder': [] }>()
|
const emit = defineEmits<{ toggle: []; add: []; 'update:cap': [SidebarCap]; 'toggle-reorder': [] }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
const { startCustomizing } = useSidebarCustomizeMode()
|
||||||
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
@@ -54,7 +58,12 @@ function handleToggleReorder() {
|
|||||||
emit('toggle-reorder')
|
emit('toggle-reorder')
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMenu = computed(() => props.canChangeCap || props.canReorder)
|
function handleCustomize() {
|
||||||
|
startCustomizing()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSectionItems = computed(() => props.canChangeCap || props.canReorder)
|
||||||
|
const hasMenu = computed(() => hasSectionItems.value || props.canCustomize)
|
||||||
|
|
||||||
function capLabel(cap: SidebarCap): string {
|
function capLabel(cap: SidebarCap): string {
|
||||||
return cap === 'all' ? t('components.sidebar.sectionHeader.showAll') : t('components.sidebar.sectionHeader.showCount', { count: formatNumber(cap) })
|
return cap === 'all' ? t('components.sidebar.sectionHeader.showAll') : t('components.sidebar.sectionHeader.showCount', { count: formatNumber(cap) })
|
||||||
@@ -112,7 +121,8 @@ const showCount = computed(() => !props.isOpen && typeof props.count === 'number
|
|||||||
<DropdownMenuLabel>{{ t('components.sidebar.sectionHeader.rowsShown') }}</DropdownMenuLabel>
|
<DropdownMenuLabel>{{ t('components.sidebar.sectionHeader.rowsShown') }}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem v-for="option in SIDEBAR_CAP_OPTIONS" :key="String(option)" @click="selectCap(option)">
|
<DropdownMenuItem v-for="option in SIDEBAR_CAP_OPTIONS" :key="String(option)" @click="selectCap(option)">
|
||||||
<Check v-if="cap === option" :size="13" class="text-primary" />
|
<Check v-if="cap === option" :size="13" class="text-primary" />
|
||||||
<span v-else class="w-[13px]" />
|
<!-- Menu items size their svgs to 16px, so the spacer matches or unchecked labels sit 3px left. -->
|
||||||
|
<span v-else class="w-4" />
|
||||||
{{ capLabel(option) }}
|
{{ capLabel(option) }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</template>
|
</template>
|
||||||
@@ -122,6 +132,11 @@ const showCount = computed(() => !props.isOpen && typeof props.count === 'number
|
|||||||
<GripVertical v-else :size="13" />
|
<GripVertical v-else :size="13" />
|
||||||
{{ isReordering ? t('components.sidebar.sectionHeader.doneReordering') : t('components.sidebar.sectionHeader.reorder') }}
|
{{ isReordering ? t('components.sidebar.sectionHeader.doneReordering') : t('components.sidebar.sectionHeader.reorder') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator v-if="canCustomize && hasSectionItems" />
|
||||||
|
<DropdownMenuItem v-if="canCustomize" @click="handleCustomize">
|
||||||
|
<SlidersHorizontal :size="13" />
|
||||||
|
{{ t('components.sidebar.customize.open') }}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function handleToggle() {
|
|||||||
:is-open="isOpen"
|
:is-open="isOpen"
|
||||||
:content-id="contentId"
|
:content-id="contentId"
|
||||||
:collapsible="!alwaysOpen"
|
:collapsible="!alwaysOpen"
|
||||||
|
:can-customize="!alwaysOpen"
|
||||||
@toggle="handleToggle"
|
@toggle="handleToggle"
|
||||||
/>
|
/>
|
||||||
<p v-else-if="label" class="mb-0.5 px-2 pt-1.5 pb-1 sidebar-section-label group-data-[collapsible=icon]:hidden">
|
<p v-else-if="label" class="mb-0.5 px-2 pt-1.5 pb-1 sidebar-section-label group-data-[collapsible=icon]:hidden">
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { h, markRaw, nextTick, reactive } from 'vue'
|
||||||
|
import type { SidebarLayout } from '@bookorbit/types'
|
||||||
|
import type { ResolvedSidebarNavEntry } from '@/composables/useSidebarNav'
|
||||||
|
import type { SidebarCustomizeGroup } from '../SidebarCustomizer.vue'
|
||||||
|
|
||||||
|
const layout = reactive<SidebarLayout>({ hidden: [], groupOrder: [], rowOrder: {} })
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({ t: (key: string, params?: { name?: string }) => (params?.name ? `${key}:${params.name}` : key) }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSidebarPrefs', () => ({
|
||||||
|
useSidebarPrefs: () => ({ layout }),
|
||||||
|
defaultSidebarLayout: () => ({ hidden: [], groupOrder: [], rowOrder: {} }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSidebarSections', () => ({
|
||||||
|
SIDEBAR_SECTIONS: [{ id: 'libraries' }, { id: 'smartScopes' }, { id: 'collections' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSidebarNav', () => ({
|
||||||
|
SIDEBAR_NAV_REGISTRY: [
|
||||||
|
{ id: 'dashboard', zone: 'primary' },
|
||||||
|
{ id: 'book-dock', zone: 'primary' },
|
||||||
|
{ id: 'tools', zone: 'primary' },
|
||||||
|
{ id: 'authors', zone: 'browse' },
|
||||||
|
{ id: 'series', zone: 'browse' },
|
||||||
|
{ id: 'statistics', zone: 'reading' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/sidebar', () => ({
|
||||||
|
SidebarGroup: { name: 'SidebarGroup', template: '<section><slot /></section>' },
|
||||||
|
SidebarSeparator: { name: 'SidebarSeparator', template: '<hr data-testid="separator" />' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-draggable-plus', () => ({
|
||||||
|
VueDraggable: { name: 'VueDraggable', template: '<div><slot /></div>' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({ radius: 'rounded' }) }))
|
||||||
|
|
||||||
|
const { default: SidebarCustomizer } = await import('../SidebarCustomizer.vue')
|
||||||
|
const { useSidebarCustomizeMode } = await import('@/composables/useSidebarCustomizeMode')
|
||||||
|
|
||||||
|
const Icon = markRaw({ name: 'Icon', render: () => h('svg') })
|
||||||
|
|
||||||
|
function entry(id: string, returnsWithWork = false): ResolvedSidebarNavEntry {
|
||||||
|
return { id, label: id, icon: Icon, to: { name: id }, isActive: false, badge: null, returnsWithWork }
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryEntries = [entry('dashboard'), entry('book-dock', true), entry('tools')]
|
||||||
|
|
||||||
|
function groups(order = ['browse', 'libraries', 'reading']): SidebarCustomizeGroup[] {
|
||||||
|
const all: Record<string, SidebarCustomizeGroup> = {
|
||||||
|
browse: { id: 'browse', kind: 'nav', label: 'Browse', count: null, entries: [entry('authors'), entry('series')] },
|
||||||
|
libraries: { id: 'libraries', kind: 'list', label: 'Libraries', count: 6, entries: [] },
|
||||||
|
reading: { id: 'reading', kind: 'nav', label: 'Reading', count: null, entries: [entry('statistics')] },
|
||||||
|
}
|
||||||
|
return order.map((id) => all[id] as SidebarCustomizeGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
function mountCustomizer(order?: string[]) {
|
||||||
|
wrapper = mount(SidebarCustomizer, { props: { primaryEntries, groups: groups(order) }, attachTo: document.body })
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function eyeFor(name: string) {
|
||||||
|
return wrapper!.find(`button[aria-label="components.sidebar.customize.showInSidebar:${name}"]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SidebarCustomizer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
Object.assign(layout, { hidden: [], groupOrder: [], rowOrder: {} })
|
||||||
|
useSidebarCustomizeMode().startCustomizing()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks Search and Dashboard and gives every other row an eye', () => {
|
||||||
|
mountCustomizer()
|
||||||
|
|
||||||
|
expect(wrapper!.text()).toContain('components.sidebar.customize.alwaysShown:common.search')
|
||||||
|
expect(wrapper!.text()).toContain('components.sidebar.customize.alwaysShown:dashboard')
|
||||||
|
expect(eyeFor('dashboard').exists()).toBe(false)
|
||||||
|
for (const name of ['book-dock', 'tools', 'Browse', 'authors', 'series', 'Libraries', 'Reading', 'statistics']) {
|
||||||
|
expect(eyeFor(name).attributes('aria-pressed')).toBe('true')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides a row in place and marks it unpressed', async () => {
|
||||||
|
mountCustomizer()
|
||||||
|
|
||||||
|
await eyeFor('series').trigger('click')
|
||||||
|
|
||||||
|
expect(layout.hidden).toEqual(['series'])
|
||||||
|
expect(eyeFor('series').attributes('aria-pressed')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tells the user a hidden Book Dock comes back when work is waiting', async () => {
|
||||||
|
mountCustomizer()
|
||||||
|
expect(wrapper!.text()).not.toContain('components.sidebar.customize.returnsWithWork')
|
||||||
|
|
||||||
|
await eyeFor('book-dock').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper!.text()).toContain('components.sidebar.customize.returnsWithWork')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('folds a hidden nav group to its header', async () => {
|
||||||
|
mountCustomizer()
|
||||||
|
expect(eyeFor('authors').exists()).toBe(true)
|
||||||
|
|
||||||
|
await eyeFor('Browse').trigger('click')
|
||||||
|
|
||||||
|
expect(layout.hidden).toEqual(['browse'])
|
||||||
|
expect(eyeFor('authors').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows list sections as a header with their row count', () => {
|
||||||
|
mountCustomizer()
|
||||||
|
|
||||||
|
const header = wrapper!.findAll('.sidebar-section-label').find((label) => label.text() === 'Libraries')
|
||||||
|
expect(header?.element.nextElementSibling?.textContent?.trim()).toBe('6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws a separator only where nav groups meet list sections', () => {
|
||||||
|
mountCustomizer(['browse', 'libraries', 'reading'])
|
||||||
|
expect(wrapper!.findAll('[data-testid="separator"]')).toHaveLength(2)
|
||||||
|
wrapper!.unmount()
|
||||||
|
|
||||||
|
mountCustomizer(['reading', 'browse', 'libraries'])
|
||||||
|
expect(wrapper!.findAll('[data-testid="separator"]')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves edit mode on Escape unless a grip already claimed it', async () => {
|
||||||
|
const { isCustomizing } = useSidebarCustomizeMode()
|
||||||
|
mountCustomizer()
|
||||||
|
|
||||||
|
const claimed = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })
|
||||||
|
claimed.preventDefault()
|
||||||
|
window.dispatchEvent(claimed)
|
||||||
|
expect(isCustomizing.value).toBe(true)
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))
|
||||||
|
await nextTick()
|
||||||
|
expect(isCustomizing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus into the editor when it opens', () => {
|
||||||
|
mountCustomizer()
|
||||||
|
|
||||||
|
expect(document.activeElement?.textContent?.trim()).toBe('components.sidebar.customize.title')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import SidebarSectionHeader from '../SidebarSectionHeader.vue'
|
||||||
|
import { useSidebarCustomizeMode } from '@/composables/useSidebarCustomizeMode'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({ t: (key: string, params?: { count?: string }) => (params?.count ? `${key}:${params.count}` : key) }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||||
|
DropdownMenu: { name: 'DropdownMenu', template: '<div><slot /></div>' },
|
||||||
|
DropdownMenuContent: { name: 'DropdownMenuContent', template: '<div><slot /></div>' },
|
||||||
|
DropdownMenuItem: { name: 'DropdownMenuItem', emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||||
|
DropdownMenuLabel: { name: 'DropdownMenuLabel', template: '<div><slot /></div>' },
|
||||||
|
DropdownMenuSeparator: { name: 'DropdownMenuSeparator', template: '<hr />' },
|
||||||
|
DropdownMenuTrigger: { name: 'DropdownMenuTrigger', template: '<button type="button"><slot /></button>' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({ radius: 'rounded' }) }))
|
||||||
|
|
||||||
|
function items(wrapper: ReturnType<typeof mount>) {
|
||||||
|
return wrapper.findAllComponents({ name: 'DropdownMenuItem' })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SidebarSectionHeader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSidebarCustomizeMode().stopCustomizing()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives a nav group header a menu holding only Customize sidebar', async () => {
|
||||||
|
const wrapper = mount(SidebarSectionHeader, { props: { label: 'Browse', isOpen: true, contentId: 'browse' } })
|
||||||
|
|
||||||
|
expect(items(wrapper).map((item) => item.text())).toEqual(['components.sidebar.customize.open'])
|
||||||
|
expect(wrapper.findAllComponents({ name: 'DropdownMenuSeparator' })).toHaveLength(0)
|
||||||
|
|
||||||
|
await items(wrapper)[0]!.trigger('click')
|
||||||
|
expect(useSidebarCustomizeMode().isCustomizing.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds Customize sidebar after the section options, behind a separator', () => {
|
||||||
|
const wrapper = mount(SidebarSectionHeader, {
|
||||||
|
props: { label: 'Libraries', isOpen: true, contentId: 'libraries', cap: 8, canChangeCap: true, canReorder: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels = items(wrapper).map((item) => item.text())
|
||||||
|
expect(labels.at(-2)).toBe('components.sidebar.sectionHeader.reorder')
|
||||||
|
expect(labels.at(-1)).toBe('components.sidebar.customize.open')
|
||||||
|
expect(wrapper.findAllComponents({ name: 'DropdownMenuSeparator' })).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserves the same 16px for an unchecked option as the menu gives the check icon', () => {
|
||||||
|
const wrapper = mount(SidebarSectionHeader, { props: { label: 'Libraries', isOpen: true, contentId: 'libraries', cap: 8, canChangeCap: true } })
|
||||||
|
|
||||||
|
const unchecked = items(wrapper).find((item) => item.text() === 'components.sidebar.sectionHeader.showCount:5')
|
||||||
|
expect(unchecked?.find('span').classes()).toContain('w-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can leave the menu out entirely', () => {
|
||||||
|
const wrapper = mount(SidebarSectionHeader, { props: { label: 'Browse', isOpen: true, contentId: 'browse', canCustomize: false } })
|
||||||
|
|
||||||
|
expect(wrapper.findComponent({ name: 'DropdownMenu' }).exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { arrangeIds, groupRuns, mergeSubsetOrder } from '../sidebar-layout'
|
||||||
|
|
||||||
|
describe('arrangeIds', () => {
|
||||||
|
const defaults = ['dashboard', 'book-dock', 'book-requests', 'tools']
|
||||||
|
|
||||||
|
it('keeps the default order when nothing is stored', () => {
|
||||||
|
expect(arrangeIds(defaults)).toEqual(defaults)
|
||||||
|
expect(arrangeIds(defaults, [])).toEqual(defaults)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows the stored order', () => {
|
||||||
|
expect(arrangeIds(defaults, ['tools', 'dashboard', 'book-requests', 'book-dock'])).toEqual(['tools', 'dashboard', 'book-requests', 'book-dock'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops ids that no longer exist and duplicates', () => {
|
||||||
|
expect(arrangeIds(defaults, ['tools', 'retired-row', 'tools', 'dashboard', 'book-dock', 'book-requests'])).toEqual([
|
||||||
|
'tools',
|
||||||
|
'dashboard',
|
||||||
|
'book-dock',
|
||||||
|
'book-requests',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places an id the stored order predates after its nearest default predecessor', () => {
|
||||||
|
// A release adds `book-requests` after `book-dock`; the user had moved Tools to the top.
|
||||||
|
expect(arrangeIds(defaults, ['tools', 'dashboard', 'book-dock'])).toEqual(['tools', 'dashboard', 'book-dock', 'book-requests'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a new first default at the top', () => {
|
||||||
|
expect(arrangeIds(['search', ...defaults], ['tools', 'dashboard', 'book-dock', 'book-requests'])).toEqual([
|
||||||
|
'search',
|
||||||
|
'tools',
|
||||||
|
'dashboard',
|
||||||
|
'book-dock',
|
||||||
|
'book-requests',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeSubsetOrder', () => {
|
||||||
|
it('writes the subset into its own slots and leaves the rest where they were', () => {
|
||||||
|
// Podcast-only rows (podcast-queue) are off screen in books mode and must not move.
|
||||||
|
const full = ['dashboard', 'book-dock', 'podcast-queue', 'tools']
|
||||||
|
expect(mergeSubsetOrder(full, ['dashboard', 'tools', 'book-dock'])).toEqual(['dashboard', 'tools', 'podcast-queue', 'book-dock'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the full order unchanged for an unchanged subset', () => {
|
||||||
|
expect(mergeSubsetOrder(['a', 'b', 'c'], ['a', 'c'])).toEqual(['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('groupRuns', () => {
|
||||||
|
it('splits groups into runs of one kind', () => {
|
||||||
|
const blocks = [
|
||||||
|
{ id: 'browse', kind: 'nav' as const },
|
||||||
|
{ id: 'libraries', kind: 'list' as const },
|
||||||
|
{ id: 'collections', kind: 'list' as const },
|
||||||
|
{ id: 'reading', kind: 'nav' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(groupRuns(blocks).map((run) => [run.kind, run.blocks.map((block) => block.id)])).toEqual([
|
||||||
|
['nav', ['browse']],
|
||||||
|
['list', ['libraries', 'collections']],
|
||||||
|
['nav', ['reading']],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges adjacent nav groups once a list section is moved out from between them', () => {
|
||||||
|
const blocks = [
|
||||||
|
{ id: 'reading', kind: 'nav' as const },
|
||||||
|
{ id: 'browse', kind: 'nav' as const },
|
||||||
|
{ id: 'libraries', kind: 'list' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(groupRuns(blocks).map((run) => run.blocks.length)).toEqual([2, 1])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Ids in the user's stored order, with every id the stored order does not mention placed after its
|
||||||
|
* nearest default predecessor. A row added in a later release then lands beside the rows it ships
|
||||||
|
* next to instead of at the end, and stale ids for rows that no longer exist drop out.
|
||||||
|
*/
|
||||||
|
export function arrangeIds(defaults: readonly string[], stored: readonly string[] = []): string[] {
|
||||||
|
const known = new Set(defaults)
|
||||||
|
const result = stored.filter((id, index) => known.has(id) && stored.indexOf(id) === index)
|
||||||
|
defaults.forEach((id, index) => {
|
||||||
|
if (result.includes(id)) return
|
||||||
|
let at = 0
|
||||||
|
for (let previous = index - 1; previous >= 0; previous--) {
|
||||||
|
const position = result.indexOf(defaults[previous] as string)
|
||||||
|
if (position !== -1) {
|
||||||
|
at = position + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.splice(at, 0, id)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a reordered subset back into the full order. Ids outside the subset (rows another media
|
||||||
|
* mode or permission hides right now) keep their slots, so reordering what is on screen never
|
||||||
|
* scrambles what is not.
|
||||||
|
*/
|
||||||
|
export function mergeSubsetOrder(full: readonly string[], subset: readonly string[]): string[] {
|
||||||
|
const inSubset = new Set(subset)
|
||||||
|
let next = 0
|
||||||
|
return full.map((id) => (inSubset.has(id) ? (subset[next++] as string) : id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SidebarBlockKind = 'nav' | 'list'
|
||||||
|
|
||||||
|
export interface SidebarRun<T> {
|
||||||
|
kind: SidebarBlockKind
|
||||||
|
blocks: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits the arranged groups into runs of one kind. A separator goes between runs, so nav groups
|
||||||
|
* read as one block and list sections as another wherever the user moves them.
|
||||||
|
*/
|
||||||
|
export function groupRuns<T extends { kind: SidebarBlockKind }>(blocks: readonly T[]): SidebarRun<T>[] {
|
||||||
|
const runs: SidebarRun<T>[] = []
|
||||||
|
for (const block of blocks) {
|
||||||
|
const last = runs[runs.length - 1]
|
||||||
|
if (last && last.kind === block.kind) last.blocks.push(block)
|
||||||
|
else runs.push({ kind: block.kind, blocks: [block] })
|
||||||
|
}
|
||||||
|
return runs
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import { useListReorder } from '../useListReorder'
|
||||||
|
|
||||||
|
function key(name: string): KeyboardEvent {
|
||||||
|
return new KeyboardEvent('keydown', { key: name, cancelable: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(initial = ['browse', 'libraries', 'reading']) {
|
||||||
|
const source = ref(initial)
|
||||||
|
const commit = vi.fn<(order: string[]) => void>()
|
||||||
|
const reorder = useListReorder(() => source.value, commit)
|
||||||
|
return { source, commit, ...reorder }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useListReorder', () => {
|
||||||
|
it('lifts, moves and drops with the keyboard, committing once on drop', () => {
|
||||||
|
const { items, liftedId, status, commit, handleGripKeydown } = setup()
|
||||||
|
|
||||||
|
handleGripKeydown(key(' '), 'reading')
|
||||||
|
expect(liftedId.value).toBe('reading')
|
||||||
|
expect(status.value).toEqual({ kind: 'lifted', id: 'reading', position: 3, total: 3 })
|
||||||
|
|
||||||
|
handleGripKeydown(key('ArrowUp'), 'reading')
|
||||||
|
handleGripKeydown(key('ArrowUp'), 'reading')
|
||||||
|
expect(items.value).toEqual(['reading', 'browse', 'libraries'])
|
||||||
|
expect(commit).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
handleGripKeydown(key('Enter'), 'reading')
|
||||||
|
expect(liftedId.value).toBeNull()
|
||||||
|
expect(status.value?.kind).toBe('dropped')
|
||||||
|
expect(commit).toHaveBeenCalledExactlyOnceWith(['reading', 'browse', 'libraries'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the list back on Escape and claims the key', () => {
|
||||||
|
const { items, commit, handleGripKeydown } = setup()
|
||||||
|
|
||||||
|
handleGripKeydown(key(' '), 'browse')
|
||||||
|
handleGripKeydown(key('ArrowDown'), 'browse')
|
||||||
|
const escape = key('Escape')
|
||||||
|
handleGripKeydown(escape, 'browse')
|
||||||
|
|
||||||
|
expect(items.value).toEqual(['browse', 'libraries', 'reading'])
|
||||||
|
expect(escape.defaultPrevented).toBe(true)
|
||||||
|
expect(commit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves Escape alone when nothing is lifted', () => {
|
||||||
|
const { handleGripKeydown } = setup()
|
||||||
|
const escape = key('Escape')
|
||||||
|
|
||||||
|
handleGripKeydown(escape, 'browse')
|
||||||
|
|
||||||
|
expect(escape.defaultPrevented).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the lifted row when its grip loses focus', () => {
|
||||||
|
const { commit, handleGripKeydown, handleGripBlur } = setup()
|
||||||
|
|
||||||
|
handleGripKeydown(key(' '), 'libraries')
|
||||||
|
handleGripKeydown(key('ArrowDown'), 'libraries')
|
||||||
|
handleGripBlur('libraries')
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledExactlyOnceWith(['browse', 'reading', 'libraries'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits a pointer drag on drop and ignores source updates mid-drag', async () => {
|
||||||
|
const { source, items, commit, onDragStart, onDragEnd } = setup()
|
||||||
|
|
||||||
|
onDragStart()
|
||||||
|
items.value = ['libraries', 'browse', 'reading']
|
||||||
|
source.value = ['browse', 'libraries', 'reading', 'collections']
|
||||||
|
await nextTick()
|
||||||
|
expect(items.value).toEqual(['libraries', 'browse', 'reading'])
|
||||||
|
|
||||||
|
onDragEnd()
|
||||||
|
expect(commit).toHaveBeenCalledExactlyOnceWith(['libraries', 'browse', 'reading'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows the source when idle', async () => {
|
||||||
|
const { source, items } = setup()
|
||||||
|
|
||||||
|
source.value = ['reading', 'browse']
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(items.value).toEqual(['reading', 'browse'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import type { SidebarLayout } from '@bookorbit/types'
|
||||||
|
import type { ResolvedSidebarNavEntry, SidebarNavBadge } from '../useSidebarNav'
|
||||||
|
|
||||||
|
const layout = reactive<SidebarLayout>({ hidden: [], groupOrder: [], rowOrder: {} })
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSidebarPrefs', () => ({
|
||||||
|
useSidebarPrefs: () => ({ layout }),
|
||||||
|
defaultSidebarLayout: () => ({ hidden: [], groupOrder: [], rowOrder: {} }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSidebarSections', () => ({
|
||||||
|
SIDEBAR_SECTIONS: [{ id: 'libraries' }, { id: 'smartScopes' }, { id: 'collections' }, { id: 'podcasts' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSidebarNav', () => ({
|
||||||
|
SIDEBAR_NAV_REGISTRY: [
|
||||||
|
{ id: 'dashboard', zone: 'primary' },
|
||||||
|
{ id: 'statistics', zone: 'reading' },
|
||||||
|
{ id: 'achievements', zone: 'reading' },
|
||||||
|
{ id: 'book-dock', zone: 'primary' },
|
||||||
|
{ id: 'book-requests', zone: 'primary' },
|
||||||
|
{ id: 'tools', zone: 'primary' },
|
||||||
|
{ id: 'podcast-queue', zone: 'primary' },
|
||||||
|
{ id: 'authors', zone: 'browse' },
|
||||||
|
{ id: 'series', zone: 'browse' },
|
||||||
|
{ id: 'annotations', zone: 'reading' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { SIDEBAR_DEFAULT_GROUP_ORDER, useSidebarLayout } = await import('../useSidebarLayout')
|
||||||
|
|
||||||
|
function entry(id: string, options: { badge?: SidebarNavBadge | null; returnsWithWork?: boolean } = {}): ResolvedSidebarNavEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
icon: {},
|
||||||
|
to: { name: id },
|
||||||
|
isActive: false,
|
||||||
|
badge: options.badge ?? null,
|
||||||
|
returnsWithWork: options.returnsWithWork ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = (entries: { id: string }[]) => entries.map((item) => item.id)
|
||||||
|
|
||||||
|
describe('useSidebarLayout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.assign(layout, { hidden: [], groupOrder: [], rowOrder: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps today's group order by default: Browse, the list sections, then Reading", () => {
|
||||||
|
expect(SIDEBAR_DEFAULT_GROUP_ORDER).toEqual(['browse', 'libraries', 'smartScopes', 'collections', 'podcasts', 'reading'])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hiding', () => {
|
||||||
|
it('toggles a row in and out of the hidden list', () => {
|
||||||
|
const { isHidden, toggleHidden } = useSidebarLayout()
|
||||||
|
|
||||||
|
toggleHidden('series')
|
||||||
|
expect(isHidden('series')).toBe(true)
|
||||||
|
expect(layout.hidden).toEqual(['series'])
|
||||||
|
|
||||||
|
toggleHidden('series')
|
||||||
|
expect(isHidden('series')).toBe(false)
|
||||||
|
expect(layout.hidden).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never hides Search or Dashboard', () => {
|
||||||
|
const { isHidden, toggleHidden } = useSidebarLayout()
|
||||||
|
|
||||||
|
toggleHidden('search')
|
||||||
|
toggleHidden('dashboard')
|
||||||
|
|
||||||
|
expect(isHidden('search')).toBe(false)
|
||||||
|
expect(isHidden('dashboard')).toBe(false)
|
||||||
|
expect(layout.hidden).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops hidden rows from the live sidebar', () => {
|
||||||
|
const { toggleHidden, visibleRows } = useSidebarLayout()
|
||||||
|
toggleHidden('series')
|
||||||
|
|
||||||
|
expect(ids(visibleRows('browse', [entry('authors'), entry('series')]))).toEqual(['authors'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('brings a hidden row back while its accent badge counts work waiting', () => {
|
||||||
|
const { toggleHidden, visibleRows } = useSidebarLayout()
|
||||||
|
toggleHidden('book-dock')
|
||||||
|
|
||||||
|
const idle = entry('book-dock', { returnsWithWork: true })
|
||||||
|
const waiting = entry('book-dock', { returnsWithWork: true, badge: { value: 3, tone: 'accent' } })
|
||||||
|
|
||||||
|
expect(ids(visibleRows('primary', [entry('dashboard'), idle]))).toEqual(['dashboard'])
|
||||||
|
expect(ids(visibleRows('primary', [entry('dashboard'), waiting]))).toEqual(['dashboard', 'book-dock'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a hidden row hidden when its count is only a total', () => {
|
||||||
|
const { toggleHidden, visibleRows } = useSidebarLayout()
|
||||||
|
toggleHidden('annotations')
|
||||||
|
|
||||||
|
expect(ids(visibleRows('reading', [entry('annotations', { badge: { value: 1500 } })]))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ordering', () => {
|
||||||
|
it('arranges rows by the stored order and keeps locked rows first', () => {
|
||||||
|
const { arrangeRows } = useSidebarLayout()
|
||||||
|
layout.rowOrder = { primary: ['tools', 'dashboard', 'book-dock', 'book-requests'] }
|
||||||
|
|
||||||
|
expect(ids(arrangeRows('primary', [entry('dashboard'), entry('book-dock'), entry('book-requests'), entry('tools')]))).toEqual([
|
||||||
|
'dashboard',
|
||||||
|
'tools',
|
||||||
|
'book-dock',
|
||||||
|
'book-requests',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores a row move without disturbing rows the current mode does not show', () => {
|
||||||
|
const { moveRows } = useSidebarLayout()
|
||||||
|
|
||||||
|
moveRows('primary', ['dashboard', 'tools', 'book-dock', 'book-requests'])
|
||||||
|
|
||||||
|
expect(layout.rowOrder.primary).toEqual(['dashboard', 'tools', 'book-dock', 'book-requests', 'podcast-queue'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores ids that do not belong to the zone', () => {
|
||||||
|
const { moveRows } = useSidebarLayout()
|
||||||
|
|
||||||
|
moveRows('browse', ['series', 'statistics', 'authors'])
|
||||||
|
|
||||||
|
expect(layout.rowOrder.browse).toEqual(['series', 'authors'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arranges and moves whole groups', () => {
|
||||||
|
const { arrangeGroups, moveGroups } = useSidebarLayout()
|
||||||
|
const groups = [{ id: 'browse' }, { id: 'libraries' }, { id: 'smartScopes' }, { id: 'collections' }, { id: 'reading' }]
|
||||||
|
|
||||||
|
moveGroups(['reading', 'browse', 'libraries', 'smartScopes', 'collections'])
|
||||||
|
|
||||||
|
expect(ids(arrangeGroups(groups))).toEqual(['reading', 'browse', 'libraries', 'smartScopes', 'collections'])
|
||||||
|
// `podcasts` is off screen in books mode and keeps its slot; the moved groups fill the others.
|
||||||
|
expect(layout.groupOrder).toEqual(['reading', 'browse', 'libraries', 'smartScopes', 'podcasts', 'collections'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets to the stock layout and restores the previous one for Undo', () => {
|
||||||
|
const { resetLayout, restoreLayout } = useSidebarLayout()
|
||||||
|
Object.assign(layout, { hidden: ['series'], groupOrder: ['reading', 'browse'], rowOrder: { browse: ['series', 'authors'] } })
|
||||||
|
|
||||||
|
const previous = resetLayout()
|
||||||
|
expect(layout).toEqual({ hidden: [], groupOrder: [], rowOrder: {} })
|
||||||
|
|
||||||
|
restoreLayout(previous)
|
||||||
|
expect(layout).toEqual({ hidden: ['series'], groupOrder: ['reading', 'browse'], rowOrder: { browse: ['series', 'authors'] } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -158,6 +158,15 @@ describe('sidebar nav registry', () => {
|
|||||||
expect(resolveNavEntry(entry('book-dock'), makeContext(), makeRoute('dashboard'), 'Book Dock').badge).toBeNull()
|
expect(resolveNavEntry(entry('book-dock'), makeContext(), makeRoute('dashboard'), 'Book Dock').badge).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks only the rows that count work waiting on the user as returning when hidden', () => {
|
||||||
|
expect(SIDEBAR_NAV_REGISTRY.filter((candidate) => candidate.returnsWithWork).map((candidate) => candidate.id)).toEqual([
|
||||||
|
'book-dock',
|
||||||
|
'book-requests',
|
||||||
|
])
|
||||||
|
expect(resolveNavEntry(entry('book-dock'), makeContext(), makeRoute('dashboard'), 'Book Dock').returnsWithWork).toBe(true)
|
||||||
|
expect(resolveNavEntry(entry('series'), makeContext(), makeRoute('dashboard'), 'Series').returnsWithWork).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('exposes the Requests badge only when requests are outstanding', () => {
|
it('exposes the Requests badge only when requests are outstanding', () => {
|
||||||
const context = makeContext({
|
const context = makeContext({
|
||||||
permissions: ['book_request_access'],
|
permissions: ['book_request_access'],
|
||||||
|
|||||||
@@ -73,6 +73,35 @@ describe('useSidebarPrefs', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('parseSidebarLayout', () => {
|
||||||
|
it.each([undefined, null, 'nonsense', 42, []])('falls back to the stock layout for %s', async (raw) => {
|
||||||
|
const { parseSidebarLayout } = await loadModule()
|
||||||
|
|
||||||
|
expect(parseSidebarLayout(raw)).toEqual({ hidden: [], groupOrder: [], rowOrder: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps string ids only, once each', async () => {
|
||||||
|
const { parseSidebarLayout } = await loadModule()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseSidebarLayout({
|
||||||
|
hidden: ['series', 7, null, 'series', '', 'annotations'],
|
||||||
|
groupOrder: 'reading',
|
||||||
|
rowOrder: { browse: ['series', 'authors', false], reading: 'statistics' },
|
||||||
|
}),
|
||||||
|
).toEqual({ hidden: ['series', 'annotations'], groupOrder: [], rowOrder: { browse: ['series', 'authors'], reading: [] } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bounds what it keeps from an oversized payload', async () => {
|
||||||
|
const { parseSidebarLayout } = await loadModule()
|
||||||
|
|
||||||
|
const parsed = parseSidebarLayout({ hidden: Array.from({ length: 500 }, (_, index) => `row-${index}`).concat('x'.repeat(65)) })
|
||||||
|
|
||||||
|
expect(parsed.hidden).toHaveLength(200)
|
||||||
|
expect(parsed.hidden).not.toContain('x'.repeat(65))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('clampCap', () => {
|
describe('clampCap', () => {
|
||||||
it.each([5, 8, 12, 20, 'all'])('keeps the allowed cap %s', async (cap) => {
|
it.each([5, 8, 12, 20, 'all'])('keeps the allowed cap %s', async (cap) => {
|
||||||
const { clampCap } = await loadModule()
|
const { clampCap } = await loadModule()
|
||||||
@@ -152,6 +181,7 @@ describe('useSidebarPrefs', () => {
|
|||||||
podcastCollections: { open: true, cap: 8 },
|
podcastCollections: { open: true, cap: 8 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sidebarLayout: { hidden: [], groupOrder: [], rowOrder: {} },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -159,6 +189,36 @@ describe('useSidebarPrefs', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('persists a layout change in its own settings key', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const { useSidebarPrefs } = await loadModule()
|
||||||
|
const { layout } = useSidebarPrefs()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
layout.hidden = ['series']
|
||||||
|
layout.groupOrder = ['reading', 'browse']
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(1)
|
||||||
|
const [, init] = apiMock.mock.calls[0] as [string, { body: string }]
|
||||||
|
const body = JSON.parse(init.body)
|
||||||
|
expect(body.settings.sidebarLayout).toEqual({ hidden: ['series'], groupOrder: ['reading', 'browse'], rowOrder: {} })
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates the layout from the signed-in user settings', async () => {
|
||||||
|
user.value = {
|
||||||
|
id: 7,
|
||||||
|
settings: { sidebarLayout: { hidden: ['annotations'], groupOrder: ['reading'], rowOrder: { browse: ['series', 'authors'] } } },
|
||||||
|
}
|
||||||
|
|
||||||
|
const { useSidebarPrefs } = await loadModule()
|
||||||
|
const { layout } = useSidebarPrefs()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(layout).toEqual({ hidden: ['annotations'], groupOrder: ['reading'], rowOrder: { browse: ['series', 'authors'] } })
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects a cap outside the allowed set', async () => {
|
it('rejects a cap outside the allowed set', async () => {
|
||||||
const { useSidebarPrefs } = await loadModule()
|
const { useSidebarPrefs } = await loadModule()
|
||||||
const { sections, setSectionCap } = useSidebarPrefs()
|
const { sections, setSectionCap } = useSidebarPrefs()
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import type { ReorderStatusKind } from '@/composables/useDraggableOrder'
|
||||||
|
|
||||||
|
export interface ListReorderStatus {
|
||||||
|
kind: ReorderStatusKind
|
||||||
|
id: string
|
||||||
|
/** 1-based, so it can be announced directly. */
|
||||||
|
position: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointer and keyboard reordering for a list of string ids, applied through `commit` on every drop.
|
||||||
|
* The keyboard model matches useDraggableOrder: Space or Enter lifts and drops, arrows move,
|
||||||
|
* Escape puts the list back. `useDraggableOrder` stays separate because it persists numeric
|
||||||
|
* displayOrder rows to the server.
|
||||||
|
*/
|
||||||
|
export function useListReorder(source: () => readonly string[], commit: (order: string[]) => void) {
|
||||||
|
const items = ref<string[]>([...source()])
|
||||||
|
const liftedId = ref<string | null>(null)
|
||||||
|
const status = ref<ListReorderStatus | null>(null)
|
||||||
|
let dragging = false
|
||||||
|
let snapshot: string[] = []
|
||||||
|
|
||||||
|
watch(source, (next) => {
|
||||||
|
if (dragging || liftedId.value !== null) return
|
||||||
|
items.value = [...next]
|
||||||
|
})
|
||||||
|
|
||||||
|
function setStatus(kind: ReorderStatusKind, index: number) {
|
||||||
|
const id = items.value[index]
|
||||||
|
if (id === undefined) return
|
||||||
|
status.value = { kind, id, position: index + 1, total: items.value.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart() {
|
||||||
|
dragging = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
dragging = false
|
||||||
|
commit([...items.value])
|
||||||
|
}
|
||||||
|
|
||||||
|
function lift(id: string) {
|
||||||
|
const index = items.value.indexOf(id)
|
||||||
|
if (index === -1) return
|
||||||
|
snapshot = [...items.value]
|
||||||
|
liftedId.value = id
|
||||||
|
setStatus('lifted', index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop() {
|
||||||
|
const id = liftedId.value
|
||||||
|
if (id === null) return
|
||||||
|
liftedId.value = null
|
||||||
|
setStatus('dropped', items.value.indexOf(id))
|
||||||
|
commit([...items.value])
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
const id = liftedId.value
|
||||||
|
if (id === null) return
|
||||||
|
items.value = [...snapshot]
|
||||||
|
liftedId.value = null
|
||||||
|
setStatus('cancelled', items.value.indexOf(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLifted(offset: number) {
|
||||||
|
const id = liftedId.value
|
||||||
|
if (id === null) return
|
||||||
|
const from = items.value.indexOf(id)
|
||||||
|
const to = from + offset
|
||||||
|
if (from === -1 || to < 0 || to >= items.value.length) return
|
||||||
|
const next = [...items.value]
|
||||||
|
next.splice(from, 1)
|
||||||
|
next.splice(to, 0, id)
|
||||||
|
items.value = next
|
||||||
|
setStatus('moved', to)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGripKeydown(event: KeyboardEvent, id: string) {
|
||||||
|
if (event.key === ' ' || event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (liftedId.value === id) drop()
|
||||||
|
else lift(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (liftedId.value !== id) return
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
moveLifted(-1)
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
moveLifted(1)
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
// Claimed here so the editor's own Escape (leave edit mode) does not also fire.
|
||||||
|
event.preventDefault()
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGripBlur(id: string) {
|
||||||
|
if (liftedId.value === id) drop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, liftedId, status, onDragStart, onDragEnd, handleGripKeydown, handleGripBlur }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/** One edit mode for the whole shell: section menus, ⌘K and the sidebar all read and flip this. */
|
||||||
|
const isCustomizing = ref(false)
|
||||||
|
|
||||||
|
export function useSidebarCustomizeMode() {
|
||||||
|
function startCustomizing() {
|
||||||
|
isCustomizing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCustomizing() {
|
||||||
|
isCustomizing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isCustomizing, startCustomizing, stopCustomizing }
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import type { SidebarLayout } from '@bookorbit/types'
|
||||||
|
import { arrangeIds, mergeSubsetOrder } from '@/components/sidebar/sidebar-layout'
|
||||||
|
import { SIDEBAR_NAV_REGISTRY, type ResolvedSidebarNavEntry } from '@/composables/useSidebarNav'
|
||||||
|
import { SIDEBAR_SECTIONS } from '@/composables/useSidebarSections'
|
||||||
|
import { defaultSidebarLayout, useSidebarPrefs } from '@/composables/useSidebarPrefs'
|
||||||
|
|
||||||
|
/** Search is the only way into ⌘K on touch devices and Dashboard is home, so neither hides or moves. */
|
||||||
|
export const SIDEBAR_LOCKED_IDS: ReadonlySet<string> = new Set(['search', 'dashboard'])
|
||||||
|
|
||||||
|
/** The top group is pinned first; these follow it, in today's order until the user moves them. */
|
||||||
|
export const SIDEBAR_DEFAULT_GROUP_ORDER: readonly string[] = ['browse', ...SIDEBAR_SECTIONS.map((section) => section.id), 'reading']
|
||||||
|
|
||||||
|
function defaultRowIds(zoneId: string): string[] {
|
||||||
|
return SIDEBAR_NAV_REGISTRY.filter((entry) => entry.zone === zoneId).map((entry) => entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLayout(layout: SidebarLayout): SidebarLayout {
|
||||||
|
return {
|
||||||
|
hidden: [...layout.hidden],
|
||||||
|
groupOrder: [...layout.groupOrder],
|
||||||
|
rowOrder: Object.fromEntries(Object.entries(layout.rowOrder).map(([id, ids]) => [id, [...ids]])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarLayout() {
|
||||||
|
const { layout } = useSidebarPrefs()
|
||||||
|
|
||||||
|
const hidden = computed(() => new Set(layout.hidden))
|
||||||
|
|
||||||
|
function isHidden(id: string): boolean {
|
||||||
|
return hidden.value.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHidden(id: string, value: boolean) {
|
||||||
|
if (SIDEBAR_LOCKED_IDS.has(id)) return
|
||||||
|
const next = layout.hidden.filter((entry) => entry !== id)
|
||||||
|
if (value) next.push(id)
|
||||||
|
layout.hidden = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHidden(id: string) {
|
||||||
|
setHidden(id, !isHidden(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A zone's rows in the user's order, locked rows first. Hidden rows are kept; callers decide. */
|
||||||
|
function arrangeRows<T extends { id: string }>(zoneId: string, entries: readonly T[]): T[] {
|
||||||
|
const order = arrangeIds(defaultRowIds(zoneId), layout.rowOrder[zoneId])
|
||||||
|
const rank = (entry: T) => {
|
||||||
|
const index = order.indexOf(entry.id)
|
||||||
|
return index === -1 ? order.length : index
|
||||||
|
}
|
||||||
|
const arranged = [...entries].sort((a, b) => rank(a) - rank(b))
|
||||||
|
return [...arranged.filter((entry) => SIDEBAR_LOCKED_IDS.has(entry.id)), ...arranged.filter((entry) => !SIDEBAR_LOCKED_IDS.has(entry.id))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the sidebar shows outside edit mode: hidden rows drop out unless they count work waiting. */
|
||||||
|
function visibleRows(zoneId: string, entries: readonly ResolvedSidebarNavEntry[]): ResolvedSidebarNavEntry[] {
|
||||||
|
return arrangeRows(zoneId, entries).filter(
|
||||||
|
(entry) => !isHidden(entry.id) || (entry.returnsWithWork && entry.badge?.tone === 'accent' && entry.badge.value > 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrangeGroups<T extends { id: string }>(groups: readonly T[]): T[] {
|
||||||
|
const order = arrangeIds(SIDEBAR_DEFAULT_GROUP_ORDER, layout.groupOrder)
|
||||||
|
const rank = (group: T) => {
|
||||||
|
const index = order.indexOf(group.id)
|
||||||
|
return index === -1 ? order.length : index
|
||||||
|
}
|
||||||
|
return [...groups].sort((a, b) => rank(a) - rank(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores the new order of the rows on screen; rows another mode or permission hides keep their slots. */
|
||||||
|
function moveRows(zoneId: string, ordered: readonly string[]) {
|
||||||
|
const full = arrangeIds(defaultRowIds(zoneId), layout.rowOrder[zoneId])
|
||||||
|
layout.rowOrder = {
|
||||||
|
...layout.rowOrder,
|
||||||
|
[zoneId]: mergeSubsetOrder(
|
||||||
|
full,
|
||||||
|
ordered.filter((id) => full.includes(id)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveGroups(ordered: readonly string[]) {
|
||||||
|
const full = arrangeIds(SIDEBAR_DEFAULT_GROUP_ORDER, layout.groupOrder)
|
||||||
|
layout.groupOrder = mergeSubsetOrder(
|
||||||
|
full,
|
||||||
|
ordered.filter((id) => full.includes(id)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restores the stock layout and hands back the one it replaced, for Undo. */
|
||||||
|
function resetLayout(): SidebarLayout {
|
||||||
|
const previous = copyLayout(layout)
|
||||||
|
Object.assign(layout, defaultSidebarLayout())
|
||||||
|
return previous
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreLayout(previous: SidebarLayout) {
|
||||||
|
Object.assign(layout, copyLayout(previous))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { layout, isHidden, toggleHidden, arrangeRows, visibleRows, arrangeGroups, moveRows, moveGroups, resetLayout, restoreLayout }
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { useBrowseCounts } from '@/composables/useBrowseCounts'
|
|||||||
import { useMediaMode } from '@/composables/useMediaMode'
|
import { useMediaMode } from '@/composables/useMediaMode'
|
||||||
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
|
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
|
||||||
|
|
||||||
/** Reading renders after the entity sections; the rest sit above them in this order. */
|
/** `primary` is pinned on top; useSidebarLayout places the others among the list sections in the user's order. */
|
||||||
export const SIDEBAR_ZONE_IDS = ['primary', 'browse', 'reading'] as const
|
export const SIDEBAR_ZONE_IDS = ['primary', 'browse', 'reading'] as const
|
||||||
export type SidebarZoneId = (typeof SIDEBAR_ZONE_IDS)[number]
|
export type SidebarZoneId = (typeof SIDEBAR_ZONE_IDS)[number]
|
||||||
|
|
||||||
@@ -46,6 +46,8 @@ export interface SidebarNavEntry {
|
|||||||
permission?: string | string[]
|
permission?: string | string[]
|
||||||
visible?: (context: NavContext) => boolean
|
visible?: (context: NavContext) => boolean
|
||||||
badge?: (context: NavContext) => SidebarNavBadge | null
|
badge?: (context: NavContext) => SidebarNavBadge | null
|
||||||
|
/** A hidden row comes back while its accent badge counts work waiting on the user. */
|
||||||
|
returnsWithWork?: boolean
|
||||||
tourId?: string
|
tourId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ export interface ResolvedSidebarNavEntry {
|
|||||||
to: RouteLocationRaw
|
to: RouteLocationRaw
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
badge: SidebarNavBadge | null
|
badge: SidebarNavBadge | null
|
||||||
|
returnsWithWork: boolean
|
||||||
tourId?: string
|
tourId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +131,7 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [
|
|||||||
isActive: (route) => route.name === 'book-dock',
|
isActive: (route) => route.name === 'book-dock',
|
||||||
permission: 'book_dock_access',
|
permission: 'book_dock_access',
|
||||||
badge: (context) => (context.bookDockTotal > 0 ? { value: context.bookDockTotal, tone: 'accent' } : null),
|
badge: (context) => (context.bookDockTotal > 0 ? { value: context.bookDockTotal, tone: 'accent' } : null),
|
||||||
|
returnsWithWork: true,
|
||||||
tourId: 'book-dock-btn',
|
tourId: 'book-dock-btn',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -143,6 +147,7 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [
|
|||||||
visible: (context) => context.showBookRequests !== false,
|
visible: (context) => context.showBookRequests !== false,
|
||||||
badge: (context) =>
|
badge: (context) =>
|
||||||
context.outstandingRequestTotal > 0 ? { value: context.outstandingRequestTotal, label: context.outstandingRequestLabel, tone: 'accent' } : null,
|
context.outstandingRequestTotal > 0 ? { value: context.outstandingRequestTotal, label: context.outstandingRequestLabel, tone: 'accent' } : null,
|
||||||
|
returnsWithWork: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tools',
|
id: 'tools',
|
||||||
@@ -217,6 +222,7 @@ export function resolveNavEntry(entry: SidebarNavEntry, context: NavContext, rou
|
|||||||
to: typeof entry.to === 'function' ? entry.to(context) : entry.to,
|
to: typeof entry.to === 'function' ? entry.to(context) : entry.to,
|
||||||
isActive: entry.isActive(route),
|
isActive: entry.isActive(route),
|
||||||
badge: entry.badge ? entry.badge(context) : null,
|
badge: entry.badge ? entry.badge(context) : null,
|
||||||
|
returnsWithWork: entry.returnsWithWork === true,
|
||||||
tourId: entry.tourId,
|
tourId: entry.tourId,
|
||||||
} satisfies ResolvedSidebarNavEntry
|
} satisfies ResolvedSidebarNavEntry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type SidebarCap,
|
type SidebarCap,
|
||||||
type SidebarCappedSectionId,
|
type SidebarCappedSectionId,
|
||||||
type SidebarConfig,
|
type SidebarConfig,
|
||||||
|
type SidebarLayout,
|
||||||
type SidebarSectionId,
|
type SidebarSectionId,
|
||||||
type SidebarSectionState,
|
type SidebarSectionState,
|
||||||
} from '@bookorbit/types'
|
} from '@bookorbit/types'
|
||||||
@@ -64,11 +65,41 @@ export function parseSidebarConfig(raw: unknown): Record<SidebarSectionId, Sideb
|
|||||||
return sections
|
return sections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generous bounds: a real layout holds a few dozen ids, so anything past these is not ours. */
|
||||||
|
const LAYOUT_MAX_IDS = 200
|
||||||
|
const LAYOUT_MAX_ID_LENGTH = 64
|
||||||
|
|
||||||
|
function parseIdList(raw: unknown): string[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const ids = raw.filter((id): id is string => typeof id === 'string' && id.length > 0 && id.length <= LAYOUT_MAX_ID_LENGTH)
|
||||||
|
return [...new Set(ids)].slice(0, LAYOUT_MAX_IDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSidebarLayout(): SidebarLayout {
|
||||||
|
return { hidden: [], groupOrder: [], rowOrder: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape-checks the stored layout only; which ids still exist is decided where it is applied. */
|
||||||
|
export function parseSidebarLayout(raw: unknown): SidebarLayout {
|
||||||
|
const layout = defaultSidebarLayout()
|
||||||
|
if (typeof raw !== 'object' || raw === null) return layout
|
||||||
|
const stored = raw as { hidden?: unknown; groupOrder?: unknown; rowOrder?: unknown }
|
||||||
|
layout.hidden = parseIdList(stored.hidden)
|
||||||
|
layout.groupOrder = parseIdList(stored.groupOrder)
|
||||||
|
if (typeof stored.rowOrder === 'object' && stored.rowOrder !== null && !Array.isArray(stored.rowOrder)) {
|
||||||
|
for (const [groupId, ids] of Object.entries(stored.rowOrder).slice(0, LAYOUT_MAX_IDS)) {
|
||||||
|
if (groupId.length > 0 && groupId.length <= LAYOUT_MAX_ID_LENGTH) layout.rowOrder[groupId] = parseIdList(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
export function userScopedKey(userId: number | null | undefined, suffix: string): string {
|
export function userScopedKey(userId: number | null | undefined, suffix: string): string {
|
||||||
return userId ? `bookorbit:u${userId}:sidebar:${suffix}` : `bookorbit:sidebar:${suffix}`
|
return userId ? `bookorbit:u${userId}:sidebar:${suffix}` : `bookorbit:sidebar:${suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = reactive(defaultSections())
|
const sections = reactive(defaultSections())
|
||||||
|
const layout = reactive(defaultSidebarLayout())
|
||||||
|
|
||||||
let hydratedForUserId: number | null = null
|
let hydratedForUserId: number | null = null
|
||||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -78,6 +109,14 @@ function currentConfig(): SidebarConfig {
|
|||||||
return { sections: { ...sections } }
|
return { sections: { ...sections } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentLayout(): SidebarLayout {
|
||||||
|
return {
|
||||||
|
hidden: [...layout.hidden],
|
||||||
|
groupOrder: [...layout.groupOrder],
|
||||||
|
rowOrder: Object.fromEntries(Object.entries(layout.rowOrder).map(([id, ids]) => [id, [...ids]])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function persistNow(): Promise<void> {
|
async function persistNow(): Promise<void> {
|
||||||
const { user, me } = useAuth()
|
const { user, me } = useAuth()
|
||||||
if (!user.value) return
|
if (!user.value) return
|
||||||
@@ -87,7 +126,7 @@ async function persistNow(): Promise<void> {
|
|||||||
const res = await api('/api/v1/users/me/settings', {
|
const res = await api('/api/v1/users/me/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ settings: { sidebarConfig: config } }),
|
body: JSON.stringify({ settings: { sidebarConfig: config, sidebarLayout: currentLayout() } }),
|
||||||
})
|
})
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
await me()
|
await me()
|
||||||
@@ -136,6 +175,7 @@ function hydrate(): void {
|
|||||||
hydratedForUserId = userId
|
hydratedForUserId = userId
|
||||||
suppressPersist = true
|
suppressPersist = true
|
||||||
Object.assign(sections, parseSidebarConfig(user.value?.settings?.sidebarConfig))
|
Object.assign(sections, parseSidebarConfig(user.value?.settings?.sidebarConfig))
|
||||||
|
Object.assign(layout, parseSidebarLayout(user.value?.settings?.sidebarLayout))
|
||||||
const migrated = migrateLegacySectionKeys(userId)
|
const migrated = migrateLegacySectionKeys(userId)
|
||||||
suppressPersist = false
|
suppressPersist = false
|
||||||
|
|
||||||
@@ -151,7 +191,7 @@ export function useSidebarPrefs() {
|
|||||||
watcherStarted = true
|
watcherStarted = true
|
||||||
watch(() => user.value?.id ?? null, hydrate, { immediate: true })
|
watch(() => user.value?.id ?? null, hydrate, { immediate: true })
|
||||||
watch(
|
watch(
|
||||||
sections,
|
[sections, layout],
|
||||||
() => {
|
() => {
|
||||||
if (suppressPersist) return
|
if (suppressPersist) return
|
||||||
schedulePersist()
|
schedulePersist()
|
||||||
@@ -182,7 +222,7 @@ export function useSidebarPrefs() {
|
|||||||
storage.set(userScopedKey(user.value?.id ?? null, suffix), value)
|
storage.set(userScopedKey(user.value?.id ?? null, suffix), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sections, setSectionOpen, toggleSection, setSectionCap, readDeviceValue, writeDeviceValue }
|
return { sections, layout, setSectionOpen, toggleSection, setSectionCap, readDeviceValue, writeDeviceValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam: clears the module-level hydration state between specs. */
|
/** Test seam: clears the module-level hydration state between specs. */
|
||||||
@@ -193,4 +233,5 @@ export function resetSidebarPrefs(): void {
|
|||||||
watcherStarted = false
|
watcherStarted = false
|
||||||
suppressPersist = false
|
suppressPersist = false
|
||||||
Object.assign(sections, defaultSections())
|
Object.assign(sections, defaultSections())
|
||||||
|
Object.assign(layout, defaultSidebarLayout())
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -134,6 +134,20 @@ describe('useCommandPaletteResults', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers Customize sidebar as an action that opens sidebar edit mode', async () => {
|
||||||
|
const { useSidebarCustomizeMode } = await import('@/composables/useSidebarCustomizeMode')
|
||||||
|
const { isCustomizing, stopCustomizing } = useSidebarCustomizeMode()
|
||||||
|
stopCustomizing()
|
||||||
|
const { sections } = setup()
|
||||||
|
|
||||||
|
const action = sections.value.find((section) => section.id === 'actions')?.items.find((item) => item.key === 'action:customize-sidebar')
|
||||||
|
expect(action?.kind === 'action' && action.label).toBe('components.sidebar.customize.open')
|
||||||
|
if (action?.kind === 'action') action.run()
|
||||||
|
|
||||||
|
expect(isCustomizing.value).toBe(true)
|
||||||
|
stopCustomizing()
|
||||||
|
})
|
||||||
|
|
||||||
it('opens on continue reading, the book libraries and the actions', async () => {
|
it('opens on continue reading, the book libraries and the actions', async () => {
|
||||||
const { isOpen, sections } = setup()
|
const { isOpen, sections } = setup()
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { computed, ref, watch, type Component, type Ref } from 'vue'
|
import { computed, ref, watch, type Component, type Ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
import { Moon, Settings, Sparkles, Sun, Upload } from '@lucide/vue'
|
import { Moon, Settings, SlidersHorizontal, Sparkles, Sun, Upload } from '@lucide/vue'
|
||||||
import type { AuthorSummary, BookCard, DashboardScrollerBatchResponse, SeriesSummary } from '@bookorbit/types'
|
import type { AuthorSummary, BookCard, DashboardScrollerBatchResponse, SeriesSummary } from '@bookorbit/types'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { formatNumber } from '@/i18n/formatters'
|
import { formatNumber } from '@/i18n/formatters'
|
||||||
|
import { useSidebarCustomizeMode } from '@/composables/useSidebarCustomizeMode'
|
||||||
import { useSidebarNav } from '@/composables/useSidebarNav'
|
import { useSidebarNav } from '@/composables/useSidebarNav'
|
||||||
import { SIDEBAR_SECTIONS, type SidebarListKind } from '@/composables/useSidebarSections'
|
import { SIDEBAR_SECTIONS, type SidebarListKind } from '@/composables/useSidebarSections'
|
||||||
import { fetchAuthors } from '@/features/author/api/author'
|
import { fetchAuthors } from '@/features/author/api/author'
|
||||||
@@ -81,6 +82,7 @@ export function useCommandPaletteResults(query: Ref<string>, tab: Ref<PaletteTab
|
|||||||
const { bookScopes } = useSmartScopes()
|
const { bookScopes } = useSmartScopes()
|
||||||
const { bookCollections } = useCollections()
|
const { bookCollections } = useCollections()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
const { startCustomizing } = useSidebarCustomizeMode()
|
||||||
|
|
||||||
const trimmed = computed(() => query.value.trim())
|
const trimmed = computed(() => query.value.trim())
|
||||||
const needle = computed(() => trimmed.value.toLocaleLowerCase())
|
const needle = computed(() => trimmed.value.toLocaleLowerCase())
|
||||||
@@ -203,6 +205,13 @@ export function useCommandPaletteResults(query: Ref<string>, tab: Ref<PaletteTab
|
|||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
run: () => handlers.navigate({ name: 'whats-new' }),
|
run: () => handlers.navigate({ name: 'whats-new' }),
|
||||||
})
|
})
|
||||||
|
list.push({
|
||||||
|
kind: 'action',
|
||||||
|
key: 'action:customize-sidebar',
|
||||||
|
label: t('components.sidebar.customize.open'),
|
||||||
|
icon: SlidersHorizontal,
|
||||||
|
run: startCustomizing,
|
||||||
|
})
|
||||||
list.push({
|
list.push({
|
||||||
kind: 'action',
|
kind: 'action',
|
||||||
key: 'action:settings',
|
key: 'action:settings',
|
||||||
|
|||||||
@@ -9068,6 +9068,19 @@
|
|||||||
"cancelled": "Reorder cancelled. {name} returned to position {position} of {total}.",
|
"cancelled": "Reorder cancelled. {name} returned to position {position} of {total}.",
|
||||||
"filteredHint": "Clear the filter to reorder"
|
"filteredHint": "Clear the filter to reorder"
|
||||||
},
|
},
|
||||||
|
"customize": {
|
||||||
|
"open": "Customize sidebar",
|
||||||
|
"title": "Customize sidebar",
|
||||||
|
"hint": "Drag to reorder. Hidden pages stay in Search.",
|
||||||
|
"reset": "Reset to default",
|
||||||
|
"done": "Done",
|
||||||
|
"resetDone": "Sidebar reset to default",
|
||||||
|
"showInSidebar": "Show {name} in the sidebar",
|
||||||
|
"show": "Show",
|
||||||
|
"hide": "Hide",
|
||||||
|
"alwaysShown": "{name} is always shown",
|
||||||
|
"returnsWithWork": "Shows when work is waiting"
|
||||||
|
},
|
||||||
"podcastImportProgress": "{processed} / {total} files",
|
"podcastImportProgress": "{processed} / {total} files",
|
||||||
"podcastImportCounting": "Counting files…",
|
"podcastImportCounting": "Counting files…",
|
||||||
"playlists": "Smart Playlists",
|
"playlists": "Smart Playlists",
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface UserSettings {
|
|||||||
/** Separate from dashboardConfig so legacy clients replacing it preserve shelf preferences. */
|
/** Separate from dashboardConfig so legacy clients replacing it preserve shelf preferences. */
|
||||||
dashboardShelfConfig?: import("./dashboard").DashboardShelfConfig;
|
dashboardShelfConfig?: import("./dashboard").DashboardShelfConfig;
|
||||||
sidebarConfig?: import("./sidebar").SidebarConfig;
|
sidebarConfig?: import("./sidebar").SidebarConfig;
|
||||||
|
/** Separate from sidebarConfig so an older client rewriting that key cannot wipe the layout. */
|
||||||
|
sidebarLayout?: import("./sidebar").SidebarLayout | null;
|
||||||
achievementPreferences?: {
|
achievementPreferences?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ export interface SidebarConfig {
|
|||||||
sections: Record<SidebarSectionId, SidebarSectionState>;
|
sections: Record<SidebarSectionId, SidebarSectionState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which sidebar rows and groups a user hid, and the order they arranged them in. Ids are nav entry
|
||||||
|
* ids (`series`, `book-dock`) and group ids (`browse`, `libraries`); ids a client does not know are
|
||||||
|
* ignored, and ids missing from an order fall back to their default position.
|
||||||
|
*/
|
||||||
|
export interface SidebarLayout {
|
||||||
|
hidden: string[];
|
||||||
|
/** Movable groups, top to bottom. The top group is pinned and never listed. */
|
||||||
|
groupOrder: string[];
|
||||||
|
/** Row order inside each nav group, keyed by group id. */
|
||||||
|
rowOrder: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Totals behind the sidebar Browse badges, served as one cached payload so the shell needs a single request. */
|
/** Totals behind the sidebar Browse badges, served as one cached payload so the shell needs a single request. */
|
||||||
export interface BrowseCounts {
|
export interface BrowseCounts {
|
||||||
authors: number;
|
authors: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user