mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add resizable persisted sidebar width
This commit is contained in:
@@ -25,6 +25,16 @@ const emit = defineEmits<{
|
||||
const confirmingDelete = ref(false)
|
||||
const exportMenuOpen = ref(false)
|
||||
|
||||
function onExport(allFormats: boolean) {
|
||||
emit('export', allFormats)
|
||||
exportMenuOpen.value = false
|
||||
}
|
||||
|
||||
function onConfirmDelete() {
|
||||
emit('delete')
|
||||
confirmingDelete.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
@@ -45,183 +55,191 @@ watch(
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-4"
|
||||
>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 px-2.5 py-2 rounded-full bg-card/90 backdrop-blur-xl border border-primary/40 shadow-[0_8px_32px_rgba(0,0,0,0.35)]"
|
||||
>
|
||||
<template v-if="!confirmingDelete && !exportMenuOpen">
|
||||
<!-- Count -->
|
||||
<span class="px-2.5 py-0.5 text-sm font-semibold tabular-nums whitespace-nowrap rounded-full bg-primary/10 text-primary">{{ count }}</span>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 px-2.5 py-2 rounded-full bg-card/90 backdrop-blur-xl border border-primary/40 shadow-[0_8px_32px_rgba(0,0,0,0.35)]"
|
||||
>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<template v-if="!confirmingDelete && !exportMenuOpen">
|
||||
<!-- Count -->
|
||||
<span class="px-2.5 py-0.5 text-sm font-semibold tabular-nums whitespace-nowrap rounded-full bg-primary/10 text-primary">{{ count }}</span>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="emit('send')"
|
||||
>
|
||||
<Mail :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Send via email</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="emit('send')"
|
||||
>
|
||||
<Mail :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Send via email</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="exportMenuOpen = true"
|
||||
>
|
||||
<Download :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Export as ZIP</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="exportMenuOpen = true"
|
||||
>
|
||||
<Download :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Export as ZIP</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="emit('add-to-collection')"
|
||||
>
|
||||
<FolderPlus :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Add to collection</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="emit('add-to-collection')"
|
||||
>
|
||||
<FolderPlus :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Add to collection</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="inCollection">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-destructive hover:bg-destructive hover:text-destructive-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="emit('remove-from-collection')"
|
||||
>
|
||||
<FolderMinus :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Remove from collection</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="inCollection">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="
|
||||
count > 0
|
||||
? 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
|
||||
: 'text-muted-foreground/30 cursor-not-allowed'
|
||||
"
|
||||
@click="emit('remove-from-collection')"
|
||||
>
|
||||
<FolderMinus :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Remove from collection</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="emit('refresh-metadata')"
|
||||
>
|
||||
<RefreshCw :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Refresh metadata</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="emit('refresh-metadata')"
|
||||
>
|
||||
<RefreshCw :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Refresh metadata</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="emit('re-extract-cover')"
|
||||
>
|
||||
<ImageDown :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Re-extract cover from file</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="emit('re-extract-cover')"
|
||||
>
|
||||
<ImageDown :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Re-extract cover from file</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="count > 0 ? 'text-destructive hover:bg-destructive hover:text-destructive-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
|
||||
@click="confirmingDelete = true"
|
||||
>
|
||||
<Trash2 :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Delete selected</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:disabled="count === 0"
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="
|
||||
count > 0
|
||||
? 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
|
||||
: 'text-muted-foreground/30 cursor-not-allowed'
|
||||
"
|
||||
@click="confirmingDelete = true"
|
||||
>
|
||||
<Trash2 :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Delete selected</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="emit('exit')"
|
||||
>
|
||||
<X :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Exit selection</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="h-9 w-9 flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="emit('exit')"
|
||||
>
|
||||
<X :size="ICON_SIZE" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Exit selection</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="exportMenuOpen">
|
||||
<span class="px-3 text-sm font-semibold text-foreground whitespace-nowrap">Export as ZIP:</span>
|
||||
<template v-else-if="exportMenuOpen">
|
||||
<span class="px-3 text-sm font-semibold text-foreground whitespace-nowrap">Export as ZIP:</span>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
@click="emit('export', false); exportMenuOpen = false"
|
||||
>
|
||||
Primary only
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
@click="onExport(false)"
|
||||
>
|
||||
Primary only
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
@click="emit('export', true); exportMenuOpen = false"
|
||||
>
|
||||
All formats
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
@click="onExport(true)"
|
||||
>
|
||||
All formats
|
||||
</button>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
|
||||
<button
|
||||
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="exportMenuOpen = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="exportMenuOpen = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="px-3 text-sm font-semibold text-destructive whitespace-nowrap">Delete {{ count }} book{{ count === 1 ? '' : 's' }}?</span>
|
||||
<template v-else>
|
||||
<span class="px-3 text-sm font-semibold text-destructive whitespace-nowrap">Delete {{ count }} book{{ count === 1 ? '' : 's' }}?</span>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
<div class="w-px h-5 bg-border mx-1 shrink-0" />
|
||||
|
||||
<button
|
||||
class="h-8 px-3 rounded-full text-sm font-medium text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||||
@click="emit('delete'); confirmingDelete = false"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
class="h-8 px-3 rounded-full text-sm font-medium text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||||
@click="onConfirmDelete"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="confirmingDelete = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="confirmingDelete = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</template>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -4,14 +4,8 @@ import { defaultDocument, useEventListener, useMediaQuery, useVModel } from '@vu
|
||||
import { TooltipProvider } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
provideSidebarContext,
|
||||
SIDEBAR_COOKIE_MAX_AGE,
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
SIDEBAR_KEYBOARD_SHORTCUT,
|
||||
SIDEBAR_WIDTH,
|
||||
SIDEBAR_WIDTH_ICON,
|
||||
} from './utils'
|
||||
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH_ICON } from './utils'
|
||||
import { useSidebarWidth } from './useSidebarWidth'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -63,6 +57,8 @@ useEventListener('keydown', (event: KeyboardEvent) => {
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = computed(() => (open.value ? 'expanded' : 'collapsed'))
|
||||
const { widthPx, setWidth, minWidthPx, maxWidthPx } = useSidebarWidth()
|
||||
const desktopSidebarWidth = computed(() => `${widthPx.value}px`)
|
||||
|
||||
provideSidebarContext({
|
||||
state,
|
||||
@@ -72,6 +68,10 @@ provideSidebarContext({
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
widthPx,
|
||||
setWidth,
|
||||
minWidthPx,
|
||||
maxWidthPx,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -80,7 +80,7 @@ provideSidebarContext({
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width': desktopSidebarWidth,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
}"
|
||||
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSidebar } from './utils'
|
||||
|
||||
@@ -7,7 +8,80 @@ const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar, setWidth, widthPx, state } = useSidebar()
|
||||
|
||||
const DRAG_THRESHOLD_PX = 3
|
||||
|
||||
const activePointerId = ref<number | null>(null)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartWidth = ref(0)
|
||||
const dragSide = ref<'left' | 'right'>('left')
|
||||
const isResizeGesture = ref(false)
|
||||
const didDrag = ref(false)
|
||||
|
||||
function resolveSide(target: EventTarget | null): 'left' | 'right' {
|
||||
const element = target instanceof HTMLElement ? target : null
|
||||
const side = element?.closest('[data-side]')?.getAttribute('data-side')
|
||||
return side === 'right' ? 'right' : 'left'
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (activePointerId.value === null || event.pointerId !== activePointerId.value) return
|
||||
if (!isResizeGesture.value) return
|
||||
|
||||
const deltaX = event.clientX - dragStartX.value
|
||||
if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) {
|
||||
didDrag.value = true
|
||||
}
|
||||
|
||||
const nextWidth = dragSide.value === 'left' ? dragStartWidth.value + deltaX : dragStartWidth.value - deltaX
|
||||
setWidth(nextWidth)
|
||||
}
|
||||
|
||||
function cleanupDragListeners() {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerEnd)
|
||||
window.removeEventListener('pointercancel', onPointerEnd)
|
||||
}
|
||||
|
||||
function resetDragState() {
|
||||
activePointerId.value = null
|
||||
isResizeGesture.value = false
|
||||
didDrag.value = false
|
||||
}
|
||||
|
||||
function onPointerEnd(event: PointerEvent) {
|
||||
if (activePointerId.value === null || event.pointerId !== activePointerId.value) return
|
||||
|
||||
const shouldToggleSidebar = event.type === 'pointerup' && !didDrag.value
|
||||
cleanupDragListeners()
|
||||
resetDragState()
|
||||
|
||||
if (shouldToggleSidebar) {
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0) return
|
||||
|
||||
activePointerId.value = event.pointerId
|
||||
dragStartX.value = event.clientX
|
||||
dragStartWidth.value = widthPx.value
|
||||
dragSide.value = resolveSide(event.currentTarget)
|
||||
isResizeGesture.value = state.value === 'expanded'
|
||||
didDrag.value = false
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerEnd)
|
||||
window.addEventListener('pointercancel', onPointerEnd)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDragListeners()
|
||||
resetDragState()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,7 +92,7 @@ const { toggleSidebar } = useSidebar()
|
||||
:tabindex="-1"
|
||||
:class="
|
||||
cn(
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:top-0 after:bottom-0 after:left-1/2 after:w-[2px] after:rounded-full group-data-[variant=floating]:after:top-3 group-data-[variant=floating]:after:bottom-3 sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
@@ -27,7 +101,7 @@ const { toggleSidebar } = useSidebar()
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@click="toggleSidebar"
|
||||
@pointerdown="onPointerDown"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { storage } from '@/services/storage'
|
||||
|
||||
const STORAGE_KEY = 'projectx:sidebar:width'
|
||||
|
||||
export const SIDEBAR_WIDTH_DEFAULT_PX = 256
|
||||
export const SIDEBAR_WIDTH_MIN_PX = 224
|
||||
export const SIDEBAR_WIDTH_MAX_PX = 480
|
||||
export const SIDEBAR_WIDTH_PERSIST_DEBOUNCE_MS = 120
|
||||
|
||||
function clampSidebarWidth(value: number): number {
|
||||
if (!Number.isFinite(value)) return SIDEBAR_WIDTH_DEFAULT_PX
|
||||
const rounded = Math.round(value)
|
||||
return Math.min(SIDEBAR_WIDTH_MAX_PX, Math.max(SIDEBAR_WIDTH_MIN_PX, rounded))
|
||||
}
|
||||
|
||||
function readInitialSidebarWidth(): number {
|
||||
const value = storage.get<number>(STORAGE_KEY, SIDEBAR_WIDTH_DEFAULT_PX)
|
||||
return clampSidebarWidth(value)
|
||||
}
|
||||
|
||||
export function useSidebarWidth() {
|
||||
const widthPx = ref<number>(readInitialSidebarWidth())
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingPersistWidth: number | null = null
|
||||
|
||||
function persistWidth(width: number) {
|
||||
storage.set(STORAGE_KEY, width)
|
||||
}
|
||||
|
||||
function schedulePersist(width: number) {
|
||||
pendingPersistWidth = width
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer)
|
||||
}
|
||||
persistTimer = setTimeout(() => {
|
||||
if (pendingPersistWidth !== null) {
|
||||
persistWidth(pendingPersistWidth)
|
||||
pendingPersistWidth = null
|
||||
}
|
||||
persistTimer = null
|
||||
}, SIDEBAR_WIDTH_PERSIST_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function setWidth(value: number) {
|
||||
const clampedWidth = clampSidebarWidth(value)
|
||||
if (clampedWidth === widthPx.value) return
|
||||
widthPx.value = clampedWidth
|
||||
schedulePersist(clampedWidth)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = null
|
||||
}
|
||||
if (pendingPersistWidth !== null) {
|
||||
persistWidth(pendingPersistWidth)
|
||||
pendingPersistWidth = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
widthPx,
|
||||
setWidth,
|
||||
minWidthPx: SIDEBAR_WIDTH_MIN_PX,
|
||||
maxWidthPx: SIDEBAR_WIDTH_MAX_PX,
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,8 @@ export const [useSidebar, provideSidebarContext] = createContext<{
|
||||
openMobile: Ref<boolean>
|
||||
setOpenMobile: (value: boolean) => void
|
||||
toggleSidebar: () => void
|
||||
widthPx: Ref<number>
|
||||
setWidth: (value: number) => void
|
||||
minWidthPx: number
|
||||
maxWidthPx: number
|
||||
}>('Sidebar')
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, ExternalLink, FolderPlus, Pencil, Star, Trash2, X } from 'lucide-vue-next'
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose } from 'reka-ui'
|
||||
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose, DialogTitle, DialogDescription } from 'reka-ui'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import { getProviderColor } from '@/lib/provider-colors'
|
||||
import { useBookDetail } from '../composables/useBookDetail'
|
||||
@@ -151,12 +151,20 @@ function editMetadata() {
|
||||
router.push({ name: 'book-edit', params: { bookId: detail.value.id } })
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function openDetails() {
|
||||
if (!detail.value) return
|
||||
router.push({ name: 'book-detail', params: { bookId: detail.value.id } })
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<SheetContent side="right" class="sm:max-w-[400px] p-0 overflow-hidden">
|
||||
<SheetTitle class="sr-only">{{ detail?.title ? `Quick view for ${detail.title}` : 'Book quick view' }}</SheetTitle>
|
||||
<SheetDescription class="sr-only">Preview book details and run quick actions.</SheetDescription>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header: cover + title block -->
|
||||
<div class="p-5 pt-10 border-b shrink-0">
|
||||
@@ -190,9 +198,9 @@ function editMetadata() {
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0 pr-2">
|
||||
<SheetTitle class="text-sm font-bold leading-snug line-clamp-3">
|
||||
<h2 class="text-sm font-bold leading-snug line-clamp-3">
|
||||
{{ detail.title ?? 'Untitled' }}
|
||||
</SheetTitle>
|
||||
</h2>
|
||||
<p v-if="detail.subtitle" class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{{ detail.subtitle }}
|
||||
</p>
|
||||
@@ -261,7 +269,10 @@ function editMetadata() {
|
||||
<span v-if="detail.publishedYear" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{{ detail.publishedYear }}
|
||||
</span>
|
||||
<span v-if="detail.language" class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
<span
|
||||
v-if="detail.language"
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground"
|
||||
>
|
||||
{{ detail.language }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -339,7 +350,7 @@ function editMetadata() {
|
||||
</button>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center text-primary-foreground gap-2 h-9 rounded-md bg-sky-600 text-sm font-medium hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-600 transition-colors"
|
||||
@click="router.push({ name: 'book-detail', params: { bookId: detail!.id } }); emit('update:open', false)"
|
||||
@click="openDetails"
|
||||
>
|
||||
<ExternalLink class="size-4" />
|
||||
Details
|
||||
@@ -391,6 +402,8 @@ function editMetadata() {
|
||||
<DialogContent
|
||||
class="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 max-w-[90vw] max-h-[90vh] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<DialogTitle class="sr-only">{{ detail?.title ? `${detail.title} cover preview` : 'Book cover preview' }}</DialogTitle>
|
||||
<DialogDescription class="sr-only">Enlarged cover image preview dialog.</DialogDescription>
|
||||
<img v-if="detail" :src="coverSrc!" :alt="detail.title ?? ''" class="max-w-[90vw] max-h-[90vh] rounded-md shadow-2xl object-contain" />
|
||||
<DialogClose
|
||||
class="absolute -top-3 -right-3 p-1 rounded-full bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user