refactor: split AppHeader into global nav and per-view ViewHeader

This commit is contained in:
neon
2026-01-21 19:20:01 -06:00
parent bc1ee837bf
commit 0f1bc55b24
5 changed files with 218 additions and 213 deletions
+9 -188
View File
@@ -1,50 +1,21 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ArrowLeft, LayoutGrid, List, Moon, MoreHorizontal, Search, SlidersHorizontal, Sun, X } from 'lucide-vue-next'
import { ArrowLeft, Search, SlidersHorizontal, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import AccentPicker from '@/components/AccentPicker.vue'
import RadiusPicker from '@/components/RadiusPicker.vue'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { useThemeStore } from '@/stores/theme'
import { useGlobalSearch, type GlobalSearchResult } from '@/features/book/composables/useGlobalSearch'
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
const props = defineProps<{
title: string
total: number
loaded: number
coverSize: number
gridGap: number
viewMode: 'grid' | 'list'
}>()
const emit = defineEmits<{
'update:coverSize': [value: number]
'update:gridGap': [value: number]
'update:viewMode': [value: 'grid' | 'list']
}>()
const router = useRouter()
const themeStore = useThemeStore()
const searchFocused = ref(false)
const mobileSearchOpen = ref(false)
const mobileDisplayOpen = ref(false)
const mobileSearchInput = ref<HTMLInputElement | null>(null)
const globalSearchQuery = ref('')
@@ -117,9 +88,7 @@ function navigateToResult(result: GlobalSearchResult) {
<p class="text-sm font-medium text-foreground truncate">{{ result.title ?? 'Untitled' }}</p>
<p v-if="result.authors.length" class="text-xs text-muted-foreground truncate">{{ result.authors.join(', ') }}</p>
</div>
<span
class="text-[10px] font-medium text-primary/70 bg-primary/8 px-1.5 py-0.5 rounded-full border border-primary/15 shrink-0 max-w-[80px] truncate"
>
<span class="text-[10px] font-medium text-primary/70 bg-primary/8 px-1.5 py-0.5 rounded-full border border-primary/15 shrink-0 max-w-[80px] truncate">
{{ result.libraryName }}
</span>
</button>
@@ -129,19 +98,9 @@ function navigateToResult(result: GlobalSearchResult) {
<!-- Normal state -->
<template v-else>
<!-- Left: sidebar trigger + library context -->
<!-- Left: sidebar trigger -->
<SidebarTrigger class="-ml-1 text-muted-foreground hover:text-foreground" />
<Separator orientation="vertical" class="mx-1 h-4" />
<div class="flex items-center gap-2 min-w-0">
<span class="font-serif font-semibold text-[15px] text-foreground tracking-tight truncate">
{{ title }}
</span>
<span
class="hidden md:inline-flex text-[11px] font-medium text-primary/70 bg-primary/8 px-2 py-0.5 rounded-full tabular-nums border border-primary/15 shrink-0"
>
{{ loaded.toLocaleString() }}<span class="text-muted-foreground/60 mx-0.5">/</span>{{ total.toLocaleString() }}
</span>
</div>
<!-- Center: desktop global search -->
<div
@@ -180,89 +139,30 @@ function navigateToResult(result: GlobalSearchResult) {
<p class="text-sm font-medium text-foreground truncate">{{ result.title ?? 'Untitled' }}</p>
<p v-if="result.authors.length" class="text-xs text-muted-foreground truncate">{{ result.authors.join(', ') }}</p>
</div>
<span
class="text-[10px] font-medium text-primary/70 bg-primary/8 px-1.5 py-0.5 rounded-full border border-primary/15 shrink-0 max-w-[80px] truncate"
>
<span class="text-[10px] font-medium text-primary/70 bg-primary/8 px-1.5 py-0.5 rounded-full border border-primary/15 shrink-0 max-w-[80px] truncate">
{{ result.libraryName }}
</span>
</button>
</div>
</div>
<!-- Right: actions -->
<!-- Right -->
<div class="ml-auto flex items-center gap-1">
<slot name="actions" />
<!-- Mobile: search icon -->
<Button variant="ghost" size="icon" class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground" @click="mobileSearchOpen = true">
<Search :size="15" />
</Button>
<!-- Desktop: view toggle -->
<div class="hidden md:flex items-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="props.viewMode === 'grid' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="emit('update:viewMode', 'grid')"
>
<LayoutGrid :size="15" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="props.viewMode === 'list' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="emit('update:viewMode', 'list')"
>
<List :size="15" />
</Button>
</div>
<Separator orientation="vertical" class="hidden md:block mx-1 h-4" />
<!-- Desktop: display settings popover -->
<!-- Desktop: appearance settings popover -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8 text-muted-foreground hover:text-foreground">
<SlidersHorizontal :size="15" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-4" align="end">
<PopoverContent class="w-56 p-4" align="end">
<div class="space-y-4">
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Display</p>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.coverSize }}px</span>
</div>
<input
:value="props.coverSize"
@input="emit('update:coverSize', Number(($event.target as HTMLInputElement).value))"
type="range"
min="80"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Grid gap</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.gridGap }}px</span>
</div>
<input
:value="props.gridGap"
@input="emit('update:gridGap', Number(($event.target as HTMLInputElement).value))"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
/>
</div>
<Separator />
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Appearance</p>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Accent</span>
<AccentPicker />
@@ -277,87 +177,8 @@ function navigateToResult(result: GlobalSearchResult) {
<Separator orientation="vertical" class="hidden md:block mx-1 h-4" />
<!-- Desktop: theme toggle -->
<div class="hidden md:flex">
<ThemeToggle />
</div>
<!-- Mobile: overflow dropdown -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground">
<MoreHorizontal :size="15" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuLabel class="text-xs text-muted-foreground">View</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="props.viewMode" @update:model-value="emit('update:viewMode', $event as 'grid' | 'list')">
<DropdownMenuRadioItem value="grid">Grid</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="list">List</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="mobileDisplayOpen = true">
<SlidersHorizontal :size="14" class="mr-2" />
Display
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="themeStore.toggleTheme()">
<component :is="themeStore.theme === 'dark' ? Sun : Moon" :size="14" class="mr-2" />
{{ themeStore.theme === 'dark' ? 'Light mode' : 'Dark mode' }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ThemeToggle />
</div>
</template>
</header>
<!-- Mobile display settings sheet (portaled to body) -->
<Sheet v-model:open="mobileDisplayOpen">
<SheetContent side="bottom">
<SheetHeader>
<SheetTitle>Display</SheetTitle>
</SheetHeader>
<div class="space-y-4 px-4 pb-6">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.coverSize }}px</span>
</div>
<input
:value="props.coverSize"
@input="emit('update:coverSize', Number(($event.target as HTMLInputElement).value))"
type="range"
min="80"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Grid gap</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.gridGap }}px</span>
</div>
<input
:value="props.gridGap"
@input="emit('update:gridGap', Number(($event.target as HTMLInputElement).value))"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
/>
</div>
<Separator />
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Accent</span>
<AccentPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Radius</span>
<RadiusPicker />
</div>
</div>
</SheetContent>
</Sheet>
</template>
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref } from 'vue'
import { LayoutGrid, List, MoreHorizontal, SlidersHorizontal } from 'lucide-vue-next'
import * as LucideIcons from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
const props = defineProps<{
title: string
icon?: string
total: number
loaded: number
coverSize: number
gridGap: number
viewMode: 'grid' | 'list'
}>()
const emit = defineEmits<{
'update:coverSize': [value: number]
'update:gridGap': [value: number]
'update:viewMode': [value: 'grid' | 'list']
}>()
const mobileDisplayOpen = ref(false)
function getIconComponent(name: string) {
return (LucideIcons as Record<string, unknown>)[name] ?? null
}
</script>
<template>
<div class="flex h-11 shrink-0 items-center gap-2 border-b border-border/60 bg-card/40 px-4">
<!-- Left: optional icon + title + count -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<component v-if="icon" :is="getIconComponent(icon)" :size="15" class="shrink-0 text-muted-foreground" />
<span class="font-semibold text-[14px] text-foreground tracking-tight truncate">{{ title }}</span>
<span
class="hidden sm:inline-flex text-[11px] font-medium text-primary/70 bg-primary/8 px-2 py-0.5 rounded-full tabular-nums border border-primary/15 shrink-0"
>
{{ loaded.toLocaleString() }}<span class="text-muted-foreground/60 mx-0.5">/</span>{{ total.toLocaleString() }}
</span>
</div>
<!-- Right -->
<div class="flex items-center gap-2 shrink-0">
<slot name="toolbar" />
<slot name="actions" />
<!-- Desktop: view mode toggle -->
<div class="hidden md:flex items-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="viewMode === 'grid' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="emit('update:viewMode', 'grid')"
>
<LayoutGrid :size="15" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="viewMode === 'list' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="emit('update:viewMode', 'list')"
>
<List :size="15" />
</Button>
</div>
<Separator orientation="vertical" class="hidden md:block mx-1 h-4" />
<!-- Desktop: display settings popover -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8 text-muted-foreground hover:text-foreground">
<SlidersHorizontal :size="15" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-56 p-4" align="end">
<div class="space-y-4">
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Display</p>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ coverSize }}px</span>
</div>
<input
:value="coverSize"
@input="emit('update:coverSize', Number(($event.target as HTMLInputElement).value))"
type="range"
min="80"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Grid gap</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ gridGap }}px</span>
</div>
<input
:value="gridGap"
@input="emit('update:gridGap', Number(($event.target as HTMLInputElement).value))"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
/>
</div>
</div>
</PopoverContent>
</Popover>
<!-- Mobile: overflow dropdown -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground">
<MoreHorizontal :size="15" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuRadioGroup :model-value="viewMode" @update:model-value="emit('update:viewMode', $event as 'grid' | 'list')">
<DropdownMenuRadioItem value="grid">Grid</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="list">List</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="mobileDisplayOpen = true">
<SlidersHorizontal :size="14" class="mr-2" />
Display
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<!-- Mobile display sheet -->
<Sheet v-model:open="mobileDisplayOpen">
<SheetContent side="bottom">
<SheetHeader>
<SheetTitle>Display</SheetTitle>
</SheetHeader>
<div class="space-y-4 px-4 pb-6">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ coverSize }}px</span>
</div>
<input
:value="coverSize"
@input="emit('update:coverSize', Number(($event.target as HTMLInputElement).value))"
type="range"
min="80"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Grid gap</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ gridGap }}px</span>
</div>
<input
:value="gridGap"
@input="emit('update:gridGap', Number(($event.target as HTMLInputElement).value))"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
/>
</div>
</div>
</SheetContent>
</Sheet>
</template>
+13 -22
View File
@@ -6,6 +6,7 @@ import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
import AppHeader from '@/components/AppHeader.vue'
import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import SettingsDrawer from '@/features/settings/SettingsDrawer.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
@@ -110,23 +111,18 @@ watch(loading, (isLoading) => {
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader
<AppHeader />
<ViewHeader
:title="title"
:total="total"
:loaded="books.length"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
/>
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
<div v-if="error" class="text-sm text-destructive mb-4">{{ error }}</div>
<!-- Sort + filter toolbar -->
<div class="flex items-center gap-2 mb-3 flex-wrap">
<!-- Sort field -->
>
<template #toolbar>
<div class="flex items-center gap-1">
<span class="text-xs text-muted-foreground">Sort:</span>
<span class="text-xs text-muted-foreground hidden lg:block">Sort:</span>
<select
:value="sortField"
@change="sortField = ($event.target as HTMLSelectElement).value as SortField"
@@ -143,25 +139,16 @@ watch(loading, (isLoading) => {
<ArrowUpAZ v-else :size="15" />
</button>
</div>
<div class="w-px h-5 bg-border" />
<!-- Filter toggle -->
<div class="w-px h-5 bg-border shrink-0" />
<button
@click="filterOpen = !filterOpen"
class="flex items-center gap-1.5 h-8 px-3 rounded-md border text-sm transition-colors"
:class="
activeFilterCount > 0
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:class="activeFilterCount > 0 ? 'border-primary text-primary bg-primary/10' : 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'"
>
<Filter :size="13" />
<span>Filters</span>
<span v-if="activeFilterCount > 0" class="text-xs font-semibold">({{ activeFilterCount }})</span>
</button>
<!-- Clear filters -->
<button
v-if="activeFilterCount > 0"
@click="clearFilters"
@@ -171,7 +158,11 @@ watch(loading, (isLoading) => {
<X :size="13" />
Clear
</button>
</div>
</template>
</ViewHeader>
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
<div v-if="error" class="text-sm text-destructive mb-4">{{ error }}</div>
<!-- Filter builder panel -->
<div v-if="filterOpen" class="mb-4 p-3 rounded-md border border-border bg-card">
+5 -2
View File
@@ -5,6 +5,7 @@ import { Settings2, Trash2 } from 'lucide-vue-next'
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import AppHeader from '@/components/AppHeader.vue'
import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import SettingsDrawer from '@/features/settings/SettingsDrawer.vue'
import LensEditorPanel from '@/features/lens/components/LensEditorPanel.vue'
@@ -118,8 +119,10 @@ watch(loading, (isLoading) => {
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader
<AppHeader />
<ViewHeader
:title="lens?.name ?? 'Lens'"
:icon="lens?.icon ?? undefined"
:total="total"
:loaded="books.length"
v-model:coverSize="coverSize"
@@ -148,7 +151,7 @@ watch(loading, (isLoading) => {
<span>{{ confirmDelete ? 'Confirm?' : 'Delete' }}</span>
</button>
</template>
</AppHeader>
</ViewHeader>
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
<!-- Rule chips -->
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1769086950417,
"when": 1769091323340,
"tag": "0000_baseline",
"breakpoints": true
}