mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user