feat: author detail and book detail UI/UX polish

- replace author header stat pills with stat blocks (book count, last added)
- collapse author admin actions (edit, merge, refresh) into an Actions dropdown menu
- author edit/merge panels now appear as a conditional card below the header
- extract BookDetailLayout component to unify card shell + tab bar across all book views
- wrap book detail, files, and edit metadata views in a shared card container
- integrate tab bar into the card top edge for visual cohesion
- card fills viewport height, content scrolls internally
- reduce horizontal gap by removing per-view mx-2
This commit is contained in:
neon
2026-03-10 04:13:33 -06:00
parent 1d44702bec
commit 0a533ed2e1
6 changed files with 95 additions and 67 deletions
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { AuthorDetail } from '@projectx/types'
import { BookCopy, Clock3, X } from 'lucide-vue-next'
import { MoreHorizontal, Pencil, RefreshCcw, UsersRound, X } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
const props = defineProps<{
author: AuthorDetail
@@ -9,6 +10,15 @@ const props = defineProps<{
previewDescription?: string | null
previewProvider?: string | null
loadingPreview?: boolean
canUpdate?: boolean
canMerge?: boolean
refreshing?: boolean
}>()
const emit = defineEmits<{
edit: []
merge: []
refresh: []
}>()
const initials = computed(() => {
@@ -21,16 +31,15 @@ const initials = computed(() => {
const resolvedBio = computed(() => {
const local = props.author.description?.trim()
if (local) return local
const preview = props.previewDescription?.trim()
return preview || ''
return props.previewDescription?.trim() || ''
})
const usesPreviewBio = computed(() => !props.author.description?.trim() && !!props.previewDescription?.trim())
const lastAddedLabel = computed(() => {
if (!props.author.lastAddedAt) return 'No recent additions'
if (!props.author.lastAddedAt) return 'Never'
const date = new Date(props.author.lastAddedAt)
if (Number.isNaN(date.getTime())) return 'No recent additions'
if (Number.isNaN(date.getTime())) return 'Never'
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
})
@@ -40,6 +49,8 @@ const previewProviderLabel = computed(() => {
return props.previewProvider
})
const showMenu = computed(() => props.canUpdate || props.canMerge)
const imageLightboxOpen = ref(false)
const canOpenImageLightbox = computed(() => Boolean(props.imageUrl))
</script>
@@ -63,33 +74,58 @@ const canOpenImageLightbox = computed(() => Boolean(props.imageUrl))
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<h1 class="truncate text-2xl font-semibold tracking-tight text-foreground">{{ author.name }}</h1>
<p v-if="author.sortName && author.sortName !== author.name" class="text-sm text-muted-foreground">{{ author.sortName }}</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1.5 rounded-md border border-border/80 bg-background/70 px-2 py-1">
<BookCopy :size="13" />
{{ author.bookCount.toLocaleString() }} books
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-border/80 bg-background/70 px-2 py-1">
<Clock3 :size="13" />
{{ lastAddedLabel }}
</span>
</div>
<DropdownMenu v-if="showMenu">
<DropdownMenuTrigger as-child>
<button
class="mt-0.5 inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md bg-muted px-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted/70"
>
<MoreHorizontal :size="14" />
Actions
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuItem v-if="canUpdate" @click="emit('edit')">
<Pencil class="mr-2 h-4 w-4" />
Edit Author
</DropdownMenuItem>
<DropdownMenuItem v-if="canMerge" @click="emit('merge')">
<UsersRound class="mr-2 h-4 w-4" />
Merge Authors
</DropdownMenuItem>
<DropdownMenuSeparator v-if="canUpdate" />
<DropdownMenuItem v-if="canUpdate" :disabled="refreshing" @click="emit('refresh')">
<RefreshCcw class="mr-2 h-4 w-4" :class="refreshing ? 'animate-spin' : ''" />
{{ refreshing ? 'Refreshing...' : 'Refresh Metadata' }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="mt-3 rounded-lg border border-border/70 bg-background/50 p-3">
<div class="mt-3">
<p v-if="resolvedBio" class="text-sm leading-6 text-foreground/90">{{ resolvedBio }}</p>
<p v-else-if="loadingPreview" class="text-sm text-muted-foreground">Looking up author metadata...</p>
<p v-else class="text-sm text-muted-foreground">No biography available yet. Use refresh metadata to fetch it.</p>
<p v-if="usesPreviewBio && previewProviderLabel" class="mt-2 text-xs text-muted-foreground">
<p v-else class="text-sm text-muted-foreground">No biography available. Use the menu to refresh metadata.</p>
<p v-if="usesPreviewBio && previewProviderLabel" class="mt-1.5 text-xs text-muted-foreground">
Preview from {{ previewProviderLabel }}. Save metadata to persist it.
</p>
</div>
<div class="mt-4 flex gap-3">
<div class="rounded-lg border border-border/70 bg-background/40 px-4 py-2.5">
<p class="text-base font-semibold text-foreground">{{ author.bookCount.toLocaleString() }}</p>
<p class="text-xs text-muted-foreground">Books</p>
</div>
<div class="rounded-lg border border-border/70 bg-background/40 px-4 py-2.5">
<p class="text-base font-semibold text-foreground">{{ lastAddedLabel }}</p>
<p class="text-xs text-muted-foreground">Last Added</p>
</div>
</div>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ChevronLeft, Pencil, RefreshCcw, UsersRound } from 'lucide-vue-next'
import { ChevronLeft } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { AuthorSummary, BookCard } from '@projectx/types'
@@ -305,42 +305,26 @@ watch(authorName, () => {
:preview-description="metadataPreview?.description ?? null"
:preview-provider="metadataPreview?.provider ?? null"
:loading-preview="loadingMetadataPreview"
:can-update="canUpdate"
:can-merge="canMerge"
:refreshing="refreshingMetadata"
@edit="
editOpen = !editOpen
if (editOpen) mergeOpen = false
"
@merge="
mergeOpen = !mergeOpen
if (mergeOpen) editOpen = false
"
@refresh="refreshMetadata"
/>
<div v-if="metadataPreviewError" class="mt-3 rounded-md border border-border/70 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Could not load external author preview metadata right now.
</div>
<section v-if="author && (canUpdate || canMerge)" class="mt-4 rounded-xl border border-border/70 bg-card/60 p-3">
<div class="flex flex-wrap items-center gap-2">
<button
v-if="canUpdate"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="editOpen = !editOpen"
>
<Pencil :size="13" />
{{ editOpen ? 'Close Edit' : 'Edit Author' }}
</button>
<button
v-if="canMerge"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="mergeOpen = !mergeOpen"
>
<UsersRound :size="13" />
{{ mergeOpen ? 'Close Merge' : 'Merge Authors' }}
</button>
<button
v-if="canUpdate"
:disabled="refreshingMetadata"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-60"
@click="refreshMetadata"
>
<RefreshCcw :size="13" />
{{ refreshingMetadata ? 'Refreshing...' : 'Refresh Metadata' }}
</button>
</div>
<div v-if="editOpen && canUpdate" class="mt-3 space-y-2 rounded-lg border border-border/70 bg-background/40 p-3">
<section v-if="author && (editOpen || mergeOpen)" class="mt-4 rounded-xl border border-border/70 bg-card/60 p-3 space-y-3">
<div v-if="editOpen && canUpdate" class="space-y-2">
<div class="grid gap-2 md:grid-cols-2">
<label class="text-xs text-muted-foreground">
Name
@@ -369,7 +353,7 @@ watch(authorName, () => {
</div>
</div>
<div v-if="mergeOpen && canMerge" class="mt-3 space-y-2 rounded-lg border border-border/70 bg-background/40 p-3">
<div v-if="mergeOpen && canMerge" class="space-y-2">
<input
v-model="mergeQuery"
class="h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm"
@@ -0,0 +1,14 @@
<script setup lang="ts">
import BookDetailHeader from './BookDetailHeader.vue'
defineProps<{ bookId: number }>()
</script>
<template>
<div class="flex flex-col my-2 -ml-2 min-h-[calc(100%-1.5rem)] overflow-hidden rounded-xl border border-border/70 bg-card/60">
<BookDetailHeader :book-id="bookId" />
<main class="flex-1 overflow-y-auto overflow-x-hidden px-6 py-6">
<slot />
</main>
</div>
</template>
+3 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import BookDetailHeader from '@/features/book/components/detail/BookDetailHeader.vue'
import BookDetailLayout from '@/features/book/components/detail/BookDetailLayout.vue'
import DetailsTab from '@/features/book/components/detail/tabs/DetailsTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
import { useBookEvents } from '@/features/book/composables/useBookEvents'
@@ -38,9 +38,7 @@ watch(bookId, (id) => fetch(id), { immediate: true })
</script>
<template>
<BookDetailHeader :book-id="bookId" />
<main class="flex-none overflow-x-hidden px-6 py-6">
<BookDetailLayout :book-id="bookId">
<DetailsTab v-if="detail" :book="detail" />
<template v-else-if="loading">
@@ -65,5 +63,5 @@ watch(bookId, (id) => fetch(id), { immediate: true })
</div>
</div>
</template>
</main>
</BookDetailLayout>
</template>
+3 -5
View File
@@ -2,7 +2,7 @@
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { BookDetail } from '@projectx/types'
import BookDetailHeader from '@/features/book/components/detail/BookDetailHeader.vue'
import BookDetailLayout from '@/features/book/components/detail/BookDetailLayout.vue'
import EditMetadataTab from '@/features/book/components/detail/tabs/EditMetadataTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
@@ -24,14 +24,12 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
</script>
<template>
<BookDetailHeader :book-id="bookId" />
<main class="flex-1 overflow-y-auto overflow-x-hidden px-6 py-6">
<BookDetailLayout :book-id="bookId">
<EditMetadataTab v-if="detail" :book="detail" @saved="onMetadataSaved" @cover-changed="onCoverChanged" />
<div v-else-if="loading" class="max-w-2xl space-y-4">
<div class="h-9 rounded-md bg-muted animate-pulse" />
<div class="h-9 rounded-md bg-muted animate-pulse" />
<div class="h-9 rounded-md bg-muted animate-pulse" />
</div>
</main>
</BookDetailLayout>
</template>
+3 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import BookDetailHeader from '@/features/book/components/detail/BookDetailHeader.vue'
import BookDetailLayout from '@/features/book/components/detail/BookDetailLayout.vue'
import FilesTab from '@/features/book/components/detail/tabs/FilesTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
@@ -15,12 +15,10 @@ watch(bookId, (id) => fetch(id), { immediate: true })
</script>
<template>
<BookDetailHeader :book-id="bookId" />
<main class="flex-1 overflow-y-auto overflow-x-hidden px-6 py-6">
<BookDetailLayout :book-id="bookId">
<FilesTab v-if="detail" :book="detail" />
<div v-else-if="loading" class="space-y-3">
<div v-for="i in 3" :key="i" class="h-16 rounded-md bg-muted animate-pulse" />
</div>
</main>
</BookDetailLayout>
</template>