mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: implement per-user read status tracking
- add user_book_status table (userId, bookId, status, source, timestamps) - add UserBookStatusModule with repository and service - auto-update status on progress save (fire-and-forget, skips manual overrides) - expose PATCH /api/v1/books/:id/status endpoint - include readStatus on BookCard and BookDetail responses - surface status icon overlay on cover cards (toggleable) - add status dropdown in book detail page inline with rating stars - rewrite statistics summary to query user_book_status directly
This commit is contained in:
@@ -218,11 +218,7 @@
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 50%,
|
||||
color-mix(in oklch, var(--primary) 5%, transparent) 50%
|
||||
);
|
||||
background: linear-gradient(to bottom, transparent 50%, color-mix(in oklch, var(--primary) 5%, transparent) 50%);
|
||||
background-size: 100% 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { storage } from '@/services/storage'
|
||||
|
||||
export type CardOverlayKey = 'series' | 'progress-bar' | 'progress-pill' | 'format' | 'rating' | 'new'
|
||||
export type CardOverlayKey = 'series' | 'progress-bar' | 'progress-pill' | 'format' | 'rating' | 'new' | 'read-status'
|
||||
export type AuthorCoverShape = 'square' | 'circle'
|
||||
|
||||
const coverSize = ref(storage.get('coverSize', 140))
|
||||
const gridGap = ref(storage.get('gridGap', 20))
|
||||
const viewMode = ref<'grid' | 'list'>(storage.get('viewMode', 'grid'))
|
||||
const cardOverlays = ref<CardOverlayKey[]>(storage.get('cardOverlays', ['progress-bar', 'format', 'rating']))
|
||||
const cardOverlays = ref<CardOverlayKey[]>(storage.get('cardOverlays', ['progress-bar', 'format', 'rating', 'read-status']))
|
||||
const lensFilterExpanded = ref(storage.get('lensFilterExpanded', true))
|
||||
const authorCoverSize = ref(storage.get('authorCoverSize', 120))
|
||||
const authorCoverShape = ref<AuthorCoverShape>(storage.get('authorCoverShape', 'circle'))
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from 'lucide-vue-next'
|
||||
import { useBookStatus, STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS } from '../composables/useBookStatus'
|
||||
import type { ReadStatus } from '@projectx/types'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -149,6 +151,29 @@ function handleDownloadFile(file: BookFileRef) {
|
||||
function handleExportAll() {
|
||||
void exportBooks([props.book.id], true)
|
||||
}
|
||||
|
||||
const { setStatus } = useBookStatus()
|
||||
|
||||
const localReadStatus = ref<ReadStatus | null>(props.book.readStatus?.status ?? null)
|
||||
watch(
|
||||
() => props.book.readStatus?.status,
|
||||
(val) => {
|
||||
localReadStatus.value = val ?? null
|
||||
},
|
||||
)
|
||||
const showReadBadge = computed(
|
||||
() => cardOverlays.value.includes('read-status') && localReadStatus.value != null && localReadStatus.value !== 'unread' && !props.selectionMode,
|
||||
)
|
||||
|
||||
async function handleSetStatus(status: ReadStatus) {
|
||||
const prev = localReadStatus.value
|
||||
localReadStatus.value = status
|
||||
try {
|
||||
await setStatus(props.book.id, status)
|
||||
} catch {
|
||||
localReadStatus.value = prev
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -189,6 +214,18 @@ function handleExportAll() {
|
||||
<!-- "New" dot - top-right accent indicator -->
|
||||
<div v-if="showNewOverlay && !isMissing" class="absolute top-1.5 right-1.5 z-10 size-2 rounded-full bg-primary shadow-sm pointer-events-none" />
|
||||
|
||||
<!-- Read status icon - top-left -->
|
||||
<div
|
||||
v-if="showReadBadge && !isMissing"
|
||||
class="absolute top-1.5 left-1.5 z-10 pointer-events-none group-hover:opacity-0 transition-opacity duration-150"
|
||||
>
|
||||
<component
|
||||
:is="STATUS_ICONS[localReadStatus!]"
|
||||
:size="14"
|
||||
:class="[STATUS_COLORS[localReadStatus!], 'drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bottom-left overlays: rating dots + progress pill -->
|
||||
<div
|
||||
v-if="(showRatingOverlay || showProgressPill) && !selectionMode"
|
||||
@@ -384,6 +421,23 @@ function handleExportAll() {
|
||||
<FolderPlus class="size-4 mr-2" />
|
||||
Add to Collection
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<component
|
||||
:is="STATUS_ICONS[localReadStatus ?? 'unread']"
|
||||
class="size-4 mr-2"
|
||||
:class="STATUS_COLORS[localReadStatus ?? 'unread']"
|
||||
/>
|
||||
Set Status
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="handleSetStatus(opt.value)">
|
||||
<component :is="STATUS_ICONS[opt.value]" class="size-4 mr-2" :class="STATUS_COLORS[opt.value]" />
|
||||
{{ opt.label }}
|
||||
<Check v-if="localReadStatus === opt.value" class="size-3 ml-auto text-primary" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem v-if="hasPermission('email_send')" @click="showSendDialog = true">
|
||||
<Send class="size-4 mr-2" />
|
||||
Send via Email
|
||||
|
||||
@@ -42,6 +42,7 @@ const missingBook: BookCard = {
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null,
|
||||
metadataScore: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
@@ -60,6 +61,7 @@ const presentBook: BookCard = {
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null,
|
||||
metadataScore: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ const missingBook: BookCard = {
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null,
|
||||
metadataScore: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
@@ -59,6 +60,7 @@ const presentBook: BookCard = {
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null,
|
||||
metadataScore: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
folderPath: '/books',
|
||||
lastWrittenAt: null,
|
||||
metadataScore: null,
|
||||
readStatus: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, ChevronDown, FolderPlus, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { Check, ChevronDown, FolderPlus, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
|
||||
import { bookCoverStyle } from '@/features/book/lib/book-cover'
|
||||
import { getFormatColor } from '@/features/book/lib/format-colors'
|
||||
import { getProviderColor } from '@/lib/provider-colors'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
import type { BookDetail, BookKoboState } from '@projectx/types'
|
||||
import type { BookDetail, BookKoboState, ReadStatus } from '@projectx/types'
|
||||
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
|
||||
import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue'
|
||||
import RecommendedBooksRow from '@/features/book/components/detail/RecommendedBooksRow.vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { api } from '@/lib/api'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
@@ -115,6 +117,26 @@ async function setRating(star: number) {
|
||||
|
||||
const ratingStars = [1, 2, 3, 4, 5]
|
||||
|
||||
const { setStatus } = useBookStatus()
|
||||
|
||||
const localReadStatus = ref<ReadStatus | null>(props.book.readStatus?.status ?? null)
|
||||
watch(
|
||||
() => props.book.readStatus?.status,
|
||||
(val) => {
|
||||
localReadStatus.value = (val as ReadStatus) ?? null
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSetReadStatus(status: ReadStatus) {
|
||||
const prev = localReadStatus.value
|
||||
localReadStatus.value = status
|
||||
try {
|
||||
await setStatus(props.book.id, status)
|
||||
} catch {
|
||||
localReadStatus.value = prev
|
||||
}
|
||||
}
|
||||
|
||||
const fileProgressById = ref<Record<number, FileProgress>>({})
|
||||
const collections = ref<CollectionMembership[]>([])
|
||||
const koboState = ref<BookKoboState | null>(null)
|
||||
@@ -608,7 +630,7 @@ watch(
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-1" @mouseleave="hoverRating = null">
|
||||
<div class="mt-3 flex items-center gap-1" @mouseleave="hoverRating = null">
|
||||
<template v-if="canEditMetadata">
|
||||
<Tooltip v-for="star in ratingStars" :key="star">
|
||||
<TooltipTrigger as-child>
|
||||
@@ -627,6 +649,25 @@ watch(
|
||||
:class="(localRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/60'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="w-px h-3.5 bg-border mx-1.5" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
<component :is="STATUS_ICONS[localReadStatus ?? 'unread']" class="size-3.5" :class="STATUS_COLORS[localReadStatus ?? 'unread']" />
|
||||
{{ STATUS_OPTIONS.find((o) => o.value === (localReadStatus ?? 'unread'))?.label }}
|
||||
<ChevronDown class="size-3 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="handleSetReadStatus(opt.value)">
|
||||
<component :is="STATUS_ICONS[opt.value]" class="size-4 mr-2" :class="STATUS_COLORS[opt.value]" />
|
||||
{{ opt.label }}
|
||||
<Check v-if="localReadStatus === opt.value" class="size-3 ml-auto text-primary" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<!-- Format badges + provider links -->
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { api } from '@/lib/api'
|
||||
import { Book, BookCheck, BookOpen, BookX } from 'lucide-vue-next'
|
||||
import type { ReadStatus } from '@projectx/types'
|
||||
|
||||
export type StatusOption = {
|
||||
value: ReadStatus
|
||||
label: string
|
||||
}
|
||||
|
||||
export const STATUS_OPTIONS: StatusOption[] = [
|
||||
{ value: 'unread', label: 'Unread' },
|
||||
{ value: 'reading', label: 'Reading' },
|
||||
{ value: 'read', label: 'Read' },
|
||||
{ value: 'abandoned', label: 'Abandoned' },
|
||||
]
|
||||
|
||||
export const STATUS_ICONS: Record<ReadStatus, unknown> = {
|
||||
unread: Book,
|
||||
reading: BookOpen,
|
||||
read: BookCheck,
|
||||
abandoned: BookX,
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<ReadStatus, string> = {
|
||||
unread: 'text-muted-foreground',
|
||||
reading: 'text-blue-500',
|
||||
read: 'text-emerald-500',
|
||||
abandoned: 'text-orange-400',
|
||||
}
|
||||
|
||||
export function useBookStatus() {
|
||||
async function setStatus(bookId: number, status: ReadStatus): Promise<void> {
|
||||
const res = await api(`/api/v1/books/${bookId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
return { setStatus, STATUS_OPTIONS }
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] =
|
||||
{ key: 'format', label: 'File format', hint: 'Color-coded EPUB, PDF, CBZ badge at bottom-right' },
|
||||
{ key: 'rating', label: 'Rating dots', hint: '5-dot rating indicator at bottom-left' },
|
||||
{ key: 'new', label: 'New indicator', hint: 'Accent dot on books added in the last 7 days' },
|
||||
{ key: 'read-status', label: 'Read status', hint: 'Color icon showing reading, read, or abandoned status' },
|
||||
]
|
||||
|
||||
function toggleOverlay(key: CardOverlayKey) {
|
||||
|
||||
Reference in New Issue
Block a user