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:
neon
2026-03-20 05:07:16 -06:00
parent 7d3a88e23d
commit 2df7e093c5
27 changed files with 565 additions and 42 deletions
+1 -5
View File
@@ -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;
}
+2 -2
View File
@@ -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) {