feat(books): add year, progress, rating, and fields to book list row

This commit is contained in:
neon
2026-02-05 15:39:35 -06:00
parent 068359727d
commit d0b54beb95
11 changed files with 193 additions and 28 deletions
@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { BookCard, BookFileRef } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover'
import { api } from '@/lib/api'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Check, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next'
import { BookOpen, Check, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Star, Trash2, TriangleAlert } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useCoverVersions } from '../composables/useCoverVersions'
@@ -19,6 +20,7 @@ type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'de
const emit = defineEmits<{
action: [type: BookActionType]
select: []
'rating-change': [rating: number | null]
}>()
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
@@ -33,9 +35,30 @@ const isMissing = computed(() => props.book.status === 'missing')
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const secondaryFiles = computed(() => props.book.files.filter((f) => f !== primaryFile.value))
const metaLine = computed(() => {
const parts: string[] = []
if (props.book.publishedYear) parts.push(String(props.book.publishedYear))
if (props.book.pageCount) parts.push(`${props.book.pageCount}p`)
if (props.book.language) parts.push(props.book.language.toUpperCase())
return parts.length > 0 ? parts.join(' · ') : null
})
const visibleTags = computed(() => props.book.tags.slice(0, 2))
const coverLoaded = ref(false)
const coverFailed = ref(false)
const localRating = ref<number | null>(props.book.rating)
const hoverRating = ref<number | null>(null)
const displayRating = computed(() => hoverRating.value ?? localRating.value)
async function setRating(star: number) {
const newRating = localRating.value === star ? null : star
localRating.value = newRating
emit('rating-change', newRating)
await api(`/api/books/${props.book.id}/rating`, { method: 'PATCH', body: JSON.stringify({ rating: newRating }) })
}
const { coverUrl } = useCoverVersions()
const coverSrc = computed(() => coverUrl(props.book.id))
@@ -86,10 +109,38 @@ function openFile(file: BookFileRef) {
<span class="text-sm font-medium text-foreground truncate leading-snug">{{ book.title ?? '-' }}</span>
<span v-if="authorLine" class="text-xs text-muted-foreground truncate">{{ authorLine }}</span>
<span v-if="seriesLine" class="text-xs text-muted-foreground/70 truncate italic">{{ seriesLine }}</span>
<span v-if="metaLine" class="text-xs text-muted-foreground/60 truncate">{{ metaLine }}</span>
<!-- Tags -->
<div v-if="visibleTags.length > 0" class="flex items-center gap-1 flex-wrap">
<span v-for="tag in visibleTags" :key="tag" class="text-[10px] px-1.5 py-0 rounded-full bg-muted text-muted-foreground leading-5">{{
tag
}}</span>
</div>
<!-- Reading progress -->
<div v-if="book.readingProgress != null && book.readingProgress > 0" class="flex items-center gap-1.5 mt-0.5">
<div class="h-1 flex-1 rounded-full bg-muted overflow-hidden max-w-24">
<div class="h-full rounded-full bg-primary/60 transition-all" :style="{ width: `${book.readingProgress}%` }" />
</div>
<span class="text-[10px] text-muted-foreground/70 tabular-nums">{{ Math.round(book.readingProgress) }}%</span>
</div>
</div>
<!-- Right badges + actions -->
<div v-if="!selectionMode" class="flex items-center gap-1.5 shrink-0" @click.stop>
<!-- Star rating -->
<div class="hidden sm:flex items-center gap-0.5" @mouseleave="hoverRating = null">
<button
v-for="star in 5"
:key="star"
class="p-0.5 transition-colors"
:title="`Rate ${star}`"
@mouseenter="hoverRating = star"
@click="setRating(star)"
>
<Star class="size-3" :class="(displayRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40'" />
</button>
</div>
<!-- Format badges -->
<div class="flex items-center gap-1">
<span
+7
View File
@@ -12,6 +12,13 @@ export type BookCard = {
seriesName: string | null;
seriesIndex: number | null;
files: BookFileRef[];
publishedYear: number | null;
pageCount: number | null;
language: string | null;
tags: string[];
rating: number | null;
readingProgress: number | null;
addedAt: string;
};
export type BookDetailFile = {
@@ -194,6 +194,7 @@ CREATE TABLE "book_metadata" (
"page_count" integer,
"series_name" varchar(500),
"series_index" real,
"rating" integer,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
@@ -1,5 +1,5 @@
{
"id": "b24abd51-bfb7-46d7-a788-d934fb481275",
"id": "4ca552ff-02e0-40e0-bb1f-2c1e53060015",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -1442,6 +1442,12 @@
"primaryKey": false,
"notNull": false
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1770367501977,
"tag": "0000_baseline",
"when": 1770373997889,
"tag": "0000_add_rating_and_book_card_fields",
"breakpoints": true
}
]
+1
View File
@@ -17,6 +17,7 @@ export const bookMetadata = pgTable('book_metadata', {
pageCount: integer('page_count'),
seriesName: varchar('series_name', { length: 500 }),
seriesIndex: real('series_index'),
rating: integer('rating'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
+7 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Headers, NotFoundException, Param, ParseIntPipe, Post, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Headers, NotFoundException, Param, ParseIntPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import type { FastifyReply } from 'fastify';
@@ -8,6 +8,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { BookService } from './book.service';
import { BookQueryPipe } from './pipes/book-query.pipe';
import { SaveProgressDto } from './dto/save-progress.dto';
import { UpdateRatingDto } from './dto/update-rating.dto';
import { SearchBooksDto } from './dto/search-books.dto';
import type { BookQuery } from '@projectx/types';
@@ -118,6 +119,11 @@ export class BookController {
await this.bookService.saveProgress(user.id, fileId, dto, user);
}
@Patch(':id/rating')
updateRating(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRatingDto, @CurrentUser() user: RequestUser) {
return this.bookService.updateRating(id, dto.rating ?? null, user);
}
@Get(':id')
getDetail(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.bookService.getDetail(id, user);
+30 -9
View File
@@ -12,8 +12,8 @@ type Db = NodePgDatabase<typeof schema>;
export class BookRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findCards(opts: { where: SQL | undefined; orderBy: SQL[]; limit: number; offset: number }) {
const { where, orderBy, limit, offset } = opts;
async findCards(opts: { where: SQL | undefined; orderBy: SQL[]; limit: number; offset: number; userId: number }) {
const { where, orderBy, limit, offset, userId } = opts;
const [rows, [{ total }]] = await Promise.all([
this.db
@@ -21,9 +21,14 @@ export class BookRepository {
id: books.id,
status: books.status,
folderPath: books.folderPath,
addedAt: books.addedAt,
title: bookMetadata.title,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
publishedYear: bookMetadata.publishedYear,
pageCount: bookMetadata.pageCount,
language: bookMetadata.language,
rating: bookMetadata.rating,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
@@ -36,7 +41,7 @@ export class BookRepository {
const bookIds = rows.map((r) => r.id);
const [authorRows, fileRows] = await Promise.all([
const [authorRows, fileRows, tagRows] = await Promise.all([
bookIds.length > 0
? this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
@@ -51,9 +56,25 @@ export class BookRepository {
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds))
: [],
bookIds.length > 0
? this.db
.select({ bookId: bookTags.bookId, name: tags.name })
.from(bookTags)
.innerJoin(tags, eq(tags.id, bookTags.tagId))
.where(inArray(bookTags.bookId, bookIds))
: [],
]);
return { rows, authorRows, fileRows, total: Number(total) };
const primaryFileIds = fileRows.filter((f) => f.role === 'primary').map((f) => f.id);
const progressRows =
primaryFileIds.length > 0
? await this.db
.select({ bookFileId: readingProgress.bookFileId, percentage: readingProgress.percentage })
.from(readingProgress)
.where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds)))
: [];
return { rows, authorRows, fileRows, tagRows, progressRows, total: Number(total) };
}
async findById(id: number) {
@@ -161,14 +182,14 @@ export class BookRepository {
}
async countWhere(where: SQL | undefined): Promise<number> {
const [{ total }] = await this.db
.select({ total: count() })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(where);
const [{ total }] = await this.db.select({ total: count() }).from(books).leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(where);
return total;
}
async updateRating(bookId: number, rating: number | null): Promise<void> {
await this.db.insert(bookMetadata).values({ bookId, rating }).onConflictDoUpdate({ target: bookMetadata.bookId, set: { rating } });
}
async upsertProgress(userId: number, fileId: number, cfi: string | null, pageNumber: number | null, percentage: number) {
await this.db
.insert(readingProgress)
+70 -14
View File
@@ -42,9 +42,23 @@ export class BookService {
}
private assembleBookCards(
rows: { id: number; status: string; folderPath: string; title: string | null; seriesName: string | null; seriesIndex: number | null }[],
rows: {
id: number;
status: string;
folderPath: string;
addedAt: Date;
title: string | null;
seriesName: string | null;
seriesIndex: number | null;
publishedYear: number | null;
pageCount: number | null;
language: string | null;
rating: number | null;
}[],
authorRows: { bookId: number; name: string }[],
fileRows: { bookId: number; id: number; format: string | null; role: string }[],
tagRows: { bookId: number; name: string }[],
progressRows: { bookFileId: number; percentage: number }[],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
@@ -60,28 +74,59 @@ export class BookService {
filesByBook.set(row.bookId, list);
}
return rows.map((row) => ({
id: row.id,
status: row.status,
title: row.title ?? basename(row.folderPath),
seriesName: row.seriesName ?? null,
seriesIndex: row.seriesIndex ?? null,
authors: authorsByBook.get(row.id) ?? [],
files: filesByBook.get(row.id) ?? [],
}));
const tagsByBook = new Map<number, string[]>();
for (const row of tagRows) {
const list = tagsByBook.get(row.bookId) ?? [];
list.push(row.name);
tagsByBook.set(row.bookId, list);
}
const progressByFileId = new Map<number, number>();
for (const row of progressRows) {
progressByFileId.set(row.bookFileId, row.percentage);
}
return rows.map((row) => {
const files = filesByBook.get(row.id) ?? [];
const primaryFile = files.find((f) => f.role === 'primary') ?? files[0] ?? null;
const readingProgress = primaryFile != null ? (progressByFileId.get(primaryFile.id) ?? null) : null;
return {
id: row.id,
status: row.status,
title: row.title ?? basename(row.folderPath),
seriesName: row.seriesName ?? null,
seriesIndex: row.seriesIndex ?? null,
authors: authorsByBook.get(row.id) ?? [],
files,
publishedYear: row.publishedYear ?? null,
pageCount: row.pageCount ?? null,
language: row.language ?? null,
tags: tagsByBook.get(row.id) ?? [],
rating: row.rating ?? null,
readingProgress,
addedAt: row.addedAt.toISOString(),
};
});
}
async queryForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise<BooksPage> {
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId });
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards({
const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where,
orderBy,
limit: query.pagination.size,
offset: query.pagination.page * query.pagination.size,
userId: user.id,
});
return { items: this.assembleBookCards(rows, authorRows, fileRows), total, page: query.pagination.page, size: query.pagination.size };
return {
items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows),
total,
page: query.pagination.page,
size: query.pagination.size,
};
}
async globalQuery(user: RequestUser, query: BookQuery): Promise<BooksPage> {
@@ -89,13 +134,19 @@ export class BookService {
const accessibleLibraryIds = libs.map((l) => l.id);
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds });
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards({
const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where,
orderBy,
limit: query.pagination.size,
offset: query.pagination.page * query.pagination.size,
userId: user.id,
});
return { items: this.assembleBookCards(rows, authorRows, fileRows), total, page: query.pagination.page, size: query.pagination.size };
return {
items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows),
total,
page: query.pagination.page,
size: query.pagination.size,
};
}
async getCoverPath(id: number, user: RequestUser): Promise<string | null> {
@@ -130,6 +181,11 @@ export class BookService {
return this.bookRepo.searchAcrossLibraries(libraryIds, q, limit);
}
async updateRating(id: number, rating: number | null, user: RequestUser): Promise<void> {
await this.verifyBookAccess(id, user);
await this.bookRepo.updateRating(id, rating);
}
async getProgress(userId: number, fileId: number, user: RequestUser) {
await this.verifyFileAccess(fileId, user);
return this.bookRepo.findProgress(userId, fileId);
@@ -12,4 +12,11 @@ export class BookCardDto {
seriesName: string | null;
seriesIndex: number | null;
files: BookFileRefDto[];
publishedYear: number | null;
pageCount: number | null;
language: string | null;
tags: string[];
rating: number | null;
readingProgress: number | null;
addedAt: string;
}
@@ -0,0 +1,9 @@
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class UpdateRatingDto {
@IsOptional()
@IsInt()
@Min(1)
@Max(5)
rating?: number | null;
}