fix: mobile UI bugs - overlay touch, sticky header, keyboard sheet, viewport height, reader timeout

This commit is contained in:
neon
2026-05-12 15:43:30 +00:00
parent ff817e4c8a
commit f79f810c8c
27 changed files with 79 additions and 36 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Docker image to run. Pin a sha-* tag or @sha256 digest for stricter deploys.
APP_IMAGE=ghcr.io/neonsolstice/bookorbit:latest
APP_IMAGE=ghcr.io/bookorbit/bookorbit:latest
# App publish port on host
APP_PORT=3000
+2 -2
View File
@@ -24,9 +24,9 @@ const viewKey = computed(() => {
</script>
<template>
<SidebarProvider class="app-shell glow-wrapper min-h-screen" :class="backgroundClass">
<SidebarProvider class="app-shell glow-wrapper min-h-svh" :class="backgroundClass">
<AppSidebar />
<SidebarInset class="app-shell-inset flex flex-col h-screen overflow-hidden relative bg-transparent pb-3">
<SidebarInset class="app-shell-inset flex flex-col h-svh overflow-hidden relative bg-transparent pb-3">
<!-- 1. Global App Header: Fixed at the top, independent of views -->
<AppHeader />
@@ -0,0 +1,31 @@
import { onMounted, onUnmounted, ref } from 'vue'
/**
* Tracks the height of the virtual keyboard using the visualViewport API.
* On iOS/Android, when the on-screen keyboard opens, the visual viewport shrinks.
* Returns the keyboard height in pixels (0 when hidden).
*/
export function useVirtualKeyboard() {
const keyboardHeight = ref(0)
function update() {
if (!window.visualViewport) return
const height = Math.max(0, window.innerHeight - window.visualViewport.height - window.visualViewport.offsetTop)
keyboardHeight.value = Math.round(height)
}
onMounted(() => {
if (!window.visualViewport) return
window.visualViewport.addEventListener('resize', update)
window.visualViewport.addEventListener('scroll', update)
update()
})
onUnmounted(() => {
if (!window.visualViewport) return
window.visualViewport.removeEventListener('resize', update)
window.visualViewport.removeEventListener('scroll', update)
})
return { keyboardHeight }
}
+2 -5
View File
@@ -163,7 +163,7 @@ function openMagicLinkCreate() {
<!-- Tab bar (superusers only) -->
<div
v-if="isSuperuser"
class="flex gap-1 mt-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-[5.25rem] z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
class="flex gap-1 mt-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
>
<button
class="px-3 py-3 md:py-2 text-sm font-medium shrink-0 border-b-2 -mb-px transition-colors snap-start"
@@ -190,10 +190,7 @@ function openMagicLinkCreate() {
</div>
<!-- Mobile CTA (when no tab bar is shown, keep the sticky button) -->
<div
v-if="!isSuperuser"
class="md:hidden sticky top-[5.25rem] z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2 mt-4 mb-3"
>
<div v-if="!isSuperuser" class="md:hidden sticky top-0 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2 mt-4 mb-3">
<button class="settings-btn-primary w-full min-h-10 justify-center" @click="openCreate">
<UserPlus :size="14" />
Create user
+1 -1
View File
@@ -169,7 +169,7 @@ watch(
</div>
</div>
<div class="md:hidden sticky top-[5.25rem] z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
<div class="md:hidden sticky top-0 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
<div class="flex items-center gap-2">
<button class="settings-btn-primary h-9 flex-1 justify-center" @click="handleSearch">
<Search :size="13" />
@@ -236,7 +236,7 @@ async function handleSetStatus(status: ReadStatus) {
<template>
<div
ref="root"
class="group flex flex-col @container"
class="group flex flex-col @container touch-manipulation"
:class="[selectionMode || (primaryFile && !isMissing) ? 'cursor-pointer' : 'cursor-default', selectionMode ? 'select-none' : '']"
@click="handleCardClick"
@contextmenu.prevent
@@ -364,7 +364,7 @@ async function handleSetStatus(status: ReadStatus) {
<div
v-if="!selectionMode"
class="absolute inset-0 flex flex-col p-2 bg-black/70 transition-opacity duration-150"
:class="[showMobileOverlay || 'group-hover:opacity-100 group-active:opacity-100', showMobileOverlay ? 'opacity-100' : 'opacity-0']"
:class="[showMobileOverlay || 'group-hover:opacity-100', showMobileOverlay ? 'opacity-100' : 'opacity-0 pointer-events-none']"
>
<!-- Top row: Quick View -->
<div class="shrink-0 flex justify-end">
@@ -378,7 +378,7 @@ async function handleSetStatus(status: ReadStatus) {
<button
v-if="primaryFile && !isMissing"
class="size-[30cqi] flex items-center justify-center rounded-full bg-primary text-white shadow-2xl transition-all duration-300 scale-75 hover:scale-110 active:scale-90"
:class="[showMobileOverlay || 'group-hover:scale-100 group-active:scale-100', showMobileOverlay ? 'scale-100' : '']"
:class="[showMobileOverlay || 'group-hover:scale-100', showMobileOverlay ? 'scale-100' : '']"
@click.stop="openFile(primaryFile)"
>
<component :is="isAudiobook ? Play : BookOpen" class="size-[16cqi]" :class="{ 'ml-[2cqi]': isAudiobook }" />
@@ -474,7 +474,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
<div class="hidden lg:flex flex-none w-px h-4 bg-border mx-0.5" />
<button
class="hidden lg:flex items-center gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
class="flex items-center gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
:disabled="!isDirty || saving"
@click="reset"
>
@@ -482,7 +482,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
Cancel
</button>
<button
class="hidden lg:flex items-center gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-40"
class="flex items-center gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-40"
:disabled="!isDirty || saving"
@click="submit"
>
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
import { useCollections } from '../composables/useCollections'
import IconPicker from '@/components/IconPicker.vue'
import type { Collection } from '@bookorbit/types'
import { useVirtualKeyboard } from '@/composables/useVirtualKeyboard'
const props = defineProps<{
open: boolean
@@ -20,6 +21,7 @@ const emit = defineEmits<{
}>()
const { fetchCollectionsWithMembership, createCollection, addBooksToCollection } = useCollections()
const { keyboardHeight } = useVirtualKeyboard()
const localCollections = ref<Collection[]>([])
const addedIds = ref<Set<number>>(new Set())
@@ -114,6 +116,7 @@ function handlePointerDownOutside(e: Event) {
<SheetContent
side="bottom"
class="max-h-[80vh] overflow-y-auto sm:inset-x-auto sm:right-auto sm:left-1/2 sm:-translate-x-1/2 sm:w-full sm:max-w-lg sm:rounded-t-lg"
:style="keyboardHeight > 0 ? { bottom: `${keyboardHeight}px` } : undefined"
@pointer-down-outside="handlePointerDownOutside"
>
<SheetHeader>
@@ -100,7 +100,7 @@ onMounted(async () => {
<template v-else>
<!-- Tab bar -->
<div
class="flex gap-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-[5.25rem] z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
class="flex gap-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
>
<button
v-for="tab in tabs"
@@ -112,7 +112,7 @@ function availableRecipients(group: EmailGroup) {
<div class="md:hidden flex items-center justify-between">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Recipient Groups</p>
</div>
<div v-if="!showCreate" class="md:hidden sticky top-[8.9rem] z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
<div v-if="!showCreate" class="md:hidden sticky top-11 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
<button
class="w-full min-h-10 flex items-center justify-center gap-1.5 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
@click="showCreate = true"
@@ -199,7 +199,7 @@ watch(
</div>
<div
v-if="canManageEmail && !showForm"
class="md:hidden sticky top-[8.9rem] z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2"
class="md:hidden sticky top-11 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2"
>
<button
class="w-full min-h-10 flex items-center justify-center gap-1.5 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
@@ -134,7 +134,7 @@ function deviceLabel(type: string | null): string {
<div class="md:hidden flex items-center justify-between">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Recipients</p>
</div>
<div v-if="!showForm" class="md:hidden sticky top-[8.9rem] z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2 mb-6">
<div v-if="!showForm" class="md:hidden sticky top-11 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2 mb-6">
<button
class="w-full min-h-10 flex items-center justify-center gap-1.5 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
@click="openCreate()"
@@ -133,7 +133,7 @@ watch(
<div class="md:hidden flex items-center justify-between">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Email Templates</p>
</div>
<div v-if="!showForm" class="md:hidden sticky top-[8.9rem] z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
<div v-if="!showForm" class="md:hidden sticky top-11 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
<button
class="w-full min-h-10 flex items-center justify-center gap-1.5 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
@click="openCreate()"
@@ -57,6 +57,8 @@ export function useFoliate(
loading.value = true
error.value = null
let loadTimeoutId: ReturnType<typeof setTimeout> | undefined
try {
await loadScript()
@@ -81,8 +83,13 @@ export function useFoliate(
el.appendChild(view)
viewRef.value = view
// Safety timeout: if the 'load' event never fires (e.g. service worker or
// iframe restrictions on iOS), clear the loading state with a helpful message
// so the UI doesn't get stuck forever.
view.addEventListener('load', (e: Event) => {
const detail = (e as CustomEvent).detail
clearTimeout(loadTimeoutId)
loading.value = false
// The paginator's internal #view reference is updated in a microtask after the
// 'load' event fires. Deferring to a macrotask ensures setStyles targets the
@@ -107,10 +114,18 @@ export function useFoliate(
view.addEventListener('error', (e: Event) => {
const detail = (e as CustomEvent).detail
console.error('[foliate] error event', detail)
clearTimeout(loadTimeoutId)
error.value = detail?.message ?? 'Reader error'
loading.value = false
})
loadTimeoutId = setTimeout(() => {
if (loading.value) {
error.value = 'Could not open the book. Your browser may not fully support the reader. Try refreshing or using a different browser.'
loading.value = false
}
}, 30_000)
if (format === 'epub') {
const infoRes = await api(`/api/v1/epub/${bookId}/info?fileId=${fileId}`)
if (!infoRes.ok) throw new Error(`Failed to fetch EPUB info: ${infoRes.status}`)
@@ -141,6 +156,7 @@ export function useFoliate(
}
} catch (e) {
console.error('[useFoliate]', e)
clearTimeout(loadTimeoutId)
error.value = e instanceof Error ? e.message : 'Failed to open book'
loading.value = false
}
@@ -33,7 +33,7 @@ onMounted(load)
</SettingsPageHeader>
<template v-else>
<div
class="md:hidden sticky top-[5.25rem] z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
class="md:hidden sticky top-11 z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
>
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
@@ -102,7 +102,7 @@ async function runAuthorBackfillAll() {
<template>
<p class="settings-group-label">Configuration</p>
<div
class="md:hidden sticky top-[5.25rem] z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
class="md:hidden sticky top-11 z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
>
<div class="flex items-center gap-2 flex-wrap">
<button class="settings-btn-primary flex-1 justify-center" :disabled="saving" @click="saveConfig">
@@ -29,7 +29,7 @@ onMounted(load)
</SettingsPageHeader>
<template v-else>
<div
class="md:hidden sticky top-[5.25rem] z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
class="md:hidden sticky top-11 z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
>
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
@@ -83,7 +83,7 @@ onUnmounted(() => {
</SettingsPageHeader>
<template v-else>
<div
class="md:hidden sticky top-[5.25rem] z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
class="md:hidden sticky top-11 z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
>
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
@@ -221,7 +221,7 @@ onUnmounted(() => {
</p>
</div>
<div class="md:hidden sticky top-[5.25rem] z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg p-3 space-y-3">
<div class="md:hidden sticky top-0 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg p-3 space-y-3">
<div class="space-y-1.5 pt-1 pb-4 border-border/60">
<div class="flex items-center justify-between gap-2">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Upload preview</p>
@@ -139,7 +139,7 @@ function isExpired(expiresAt: string | null | undefined): boolean {
Create shareable login links for shared accounts.
</p>
</div>
<div class="md:hidden sticky top-[5.25rem] z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2 mt-4 mb-3">
<div class="md:hidden sticky top-11 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2 mt-4 mb-3">
<button class="settings-btn-primary w-full min-h-10 justify-center" :disabled="sharedUsers.length === 0" @click="openCreateForm">
<Plus :size="14" />
Create link
@@ -63,7 +63,7 @@ function selectTab(tab: Tab) {
<div
:class="[
tabWidths[activeTab],
'flex gap-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-[5.25rem] z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x',
'flex gap-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x',
]"
>
<button
@@ -132,7 +132,7 @@ function handleResetClick() {
</div>
</div>
<div
class="md:hidden sticky top-[5.25rem] z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
class="md:hidden sticky top-11 z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
>
<div class="flex items-center gap-2 flex-wrap">
<button
@@ -255,10 +255,7 @@ watch(
<div class="md:hidden flex items-center justify-between mb-2">
<p class="settings-group-label mb-0">OPDS Accounts</p>
</div>
<div
v-if="!showCreateForm"
class="md:hidden sticky top-[5.25rem] z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2 mb-3"
>
<div v-if="!showCreateForm" class="md:hidden sticky top-0 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2 mb-3">
<button
class="w-full min-h-10 flex items-center justify-center gap-1.5 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
@click="showCreateForm = true"
+1 -1
View File
@@ -31,7 +31,7 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
</SettingsPageHeader>
<template v-else>
<div
class="md:hidden sticky top-[5.25rem] z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
class="md:hidden sticky top-11 z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
>
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
@@ -40,7 +40,7 @@ function selectTab(tab: Tab) {
<SettingsPageHeader title="Reader" subtitle="Configure defaults for all reading modes in one place." />
<div
class="flex gap-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-[5.25rem] z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
class="flex gap-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
>
<button
v-for="tab in tabs"
@@ -101,9 +101,7 @@ function handleResetToDefault() {
</button>
</div>
</div>
<div
class="md:hidden sticky top-[5.25rem] z-10 mb-0 px-3 py-2 border-b border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
>
<div class="md:hidden sticky top-11 z-10 mb-0 px-3 py-2 border-b border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75">
<div class="flex items-center gap-2 flex-wrap">
<button
class="settings-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground border border-border rounded-md hover:text-destructive hover:border-destructive/50 hover:bg-destructive/5 transition-colors disabled:opacity-40"
+1
View File
@@ -20,6 +20,7 @@ export default defineConfig({
},
server: {
host: true,
allowedHosts: true,
proxy: {
'/api': {
target: 'http://localhost:3000',