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:
neon
2026-10-20 16:38:23 +00:00
parent 568614265f
commit 3c87418416
26 changed files with 1598 additions and 78 deletions
+139 -69
View File
@@ -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
</SidebarSectionPopover> v-for="entry in block.zone.entries"
</div> :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>
<template v-else> <div v-else-if="isRail" class="flex flex-col items-center gap-1 px-2">
<SidebarEntitySection <template v-for="block in run.blocks" :key="block.id">
v-for="section in sections" <SidebarSectionPopover
:key="section.id" v-if="block.kind === 'list'"
v-bind="section.props" :label="block.section.props.label"
:tour-id="section.tourId" :icon="block.section.icon"
:can-reorder="section.canReorder" :count="block.section.props.items.length"
:persist-order="section.persistOrder" >
:activity="section.activity" <SidebarEntitySection v-bind="block.section.props" always-open @add="handleAdd(block.section)" @navigate="handleNavigate" />
@add="handleAdd(section)" </SidebarSectionPopover>
@navigate="handleNavigate" </template>
/> </div>
</template>
<template v-if="bottomZones.length"> <template v-else>
<SidebarSeparator /> <template v-for="block in run.blocks" :key="block.id">
<SidebarZone <SidebarEntitySection
v-for="zone in bottomZones" v-if="block.kind === 'list'"
:key="zone.id" v-bind="block.section.props"
:label="zone.labelKey ? t(zone.labelKey) : null" :tour-id="block.section.tourId"
:section-id="zone.sectionId ?? undefined" :can-reorder="block.section.canReorder"
:always-open="isRail" :persist-order="block.section.persistOrder"
> :activity="block.section.activity"
<SidebarNavItem @add="handleAdd(block.section)"
v-for="entry in zone.entries" @navigate="handleNavigate"
:key="entry.id" />
:is-active="entry.isActive" </template>
:tooltip="entry.badge?.label ?? entry.label" </template>
: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>
</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()
+109
View File
@@ -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 }
}
+105
View File
@@ -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 }
}
+7 -1
View File
@@ -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
} }
+44 -3
View File
@@ -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())
} }
@@ -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',
+13
View File
@@ -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",
+2
View File
@@ -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;
}; };
+13
View File
@@ -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;