mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): add year, progress, rating, and fields to book list row
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user