Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/server/src/books/books-repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BookGenre, BookWithData } from '@koinsight/common/types';
import { Book } from '@koinsight/common/types/book';
import { Book, BookStatus } from '@koinsight/common/types/book';
import { BookDevice } from '@koinsight/common/types/book-device';
import { Genre } from '@koinsight/common/types/genre';
import { sum } from 'ramda';
Expand Down Expand Up @@ -134,4 +134,8 @@ export class BooksRepository {
static async setReferencePages(id: number, referencePages: number | null) {
return db<Book>('book').where({ id }).update({ reference_pages: referencePages });
}

static async setStatus(id: number, status: BookStatus) {
return db<Book>('book').where({ id }).update({ status });
}
}
29 changes: 29 additions & 0 deletions apps/server/src/books/books-router.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { BookStatus } from '@koinsight/common/types/book';
import { NextFunction, Request, Response, Router } from 'express';
import { BooksRepository } from './books-repository';
import { BooksService } from './books-service';
import { coversRouter } from './covers/covers-router';
import { getBookById } from './get-book-by-id-middleware';

const VALID_STATUSES: BookStatus[] = ['complete', 'reading', 'abandoned', 'on_hold', null];

const router = Router();

router.use('/:bookId/cover', coversRouter);
Expand Down Expand Up @@ -102,4 +105,30 @@ router.put('/:bookId/reference_pages', getBookById, async (req: Request, res: Re
}
});

/**
* Updates a book's status (complete, reading, abandoned, on_hold, or null)
*/
router.put('/:bookId/status', getBookById, async (req: Request, res: Response) => {
const book = req.book!;
const { status } = req.body;

if (status === undefined) {
res.status(400).json({ error: 'Missing required fields' });
return;
}

if (!VALID_STATUSES.includes(status)) {
res.status(400).json({ error: 'Invalid status value' });
return;
}

try {
await BooksRepository.setStatus(book.id, status);
res.status(200).json({ message: 'Status updated' });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to update status' });
}
});

export { router as booksRouter };
1 change: 1 addition & 0 deletions apps/server/src/db/factories/book-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function fakeBook(overrides: Partial<FakeBook> = {}): FakeBook {
series: faker.book.series(),
language: faker.location.language().alpha2,
soft_deleted: false,
status: null,
...overrides,
};

Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/db/migrations/20251223100000_add_status_to_book.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
return knex.schema.alterTable('book', (table) => {
table.string('status').defaultTo(null);
});
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.alterTable('book', (table) => {
table.dropColumn('status');
});
}
6 changes: 5 additions & 1 deletion apps/server/src/upload/upload-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,14 @@ export class UploadService {
authors: book.authors,
series: book.series,
language: book.language,
status: book.status ?? null,
}));

await Promise.all(
newBooks.map(({ id, ...book }) => trx<Book>('book').insert(book).onConflict('md5').ignore())
newBooks.map(({ id, ...book }) => {
const query = trx<Book>('book').insert(book).onConflict('md5');
return book.status ? query.merge(['status']) : query.ignore();
})
);

// Determine device ID: from stats, override, or fall back to unknown device
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/api/books.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Book, BookWithData } from '@koinsight/common/types';
import { Book, BookStatus, BookWithData } from '@koinsight/common/types';
import useSWR from 'swr';
import { API_URL, fetchFromAPI } from './api';

Expand Down Expand Up @@ -37,3 +37,7 @@ export function uploadBookCover(bookId: Book['id'], formData: FormData) {
headers: { Accept: 'multipart/form-data' },
});
}

export async function updateBookStatus(id: Book['id'], status: BookStatus) {
return fetchFromAPI<{ message: string }>(`books/${id}/status`, 'PUT', { status });
}
1 change: 1 addition & 0 deletions apps/web/src/components/status-icons/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AbandonedIcon, CompletedIcon, OnHoldIcon, ReadingIcon } from './status-icons';
84 changes: 84 additions & 0 deletions apps/web/src/components/status-icons/status-icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Tooltip } from '@mantine/core';
import { IconArchive, IconBook, IconCircleCheck, IconPlayerPause } from '@tabler/icons-react';
import { JSX } from 'react';

type StatusIconProps = {
size?: number;
withTooltip?: boolean;
};

export function CompletedIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element {
const icon = (
<IconCircleCheck
size={size}
stroke={1.5}
style={{ flexShrink: 0, color: 'var(--mantine-color-green-6)' }}
/>
);

if (withTooltip) {
return (
<Tooltip label="Completed" withArrow>
{icon}
</Tooltip>
);
}
return icon;
}

export function ReadingIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element {
const icon = (
<IconBook
size={size}
stroke={1.5}
style={{ flexShrink: 0, color: 'var(--mantine-color-blue-6)' }}
/>
);

if (withTooltip) {
return (
<Tooltip label="Reading" withArrow>
{icon}
</Tooltip>
);
}
return icon;
}

export function OnHoldIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element {
const icon = (
<IconPlayerPause
size={size}
stroke={1.5}
style={{ flexShrink: 0, color: 'var(--mantine-color-yellow-6)' }}
/>
);

if (withTooltip) {
return (
<Tooltip label="On Hold" withArrow>
{icon}
</Tooltip>
);
}
return icon;
}

export function AbandonedIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element {
const icon = (
<IconArchive
size={size}
stroke={1.5}
style={{ flexShrink: 0, color: 'var(--mantine-color-red-6)' }}
/>
);

if (withTooltip) {
return (
<Tooltip label="Abandoned" withArrow>
{icon}
</Tooltip>
);
}
return icon;
}
104 changes: 102 additions & 2 deletions apps/web/src/pages/book-page/book-card.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { BookWithData } from '@koinsight/common/types';
import { BookStatus, BookWithData } from '@koinsight/common/types';
import {
ActionIcon,
Box,
Button,
Flex,
Group,
Image,
Menu,
Modal,
Tabs,
Title,
Expand All @@ -15,13 +16,22 @@ import { useDisclosure, useMediaQuery } from '@mantine/hooks';
import {
IconBooks,
IconCalendar,
IconChevronDown,
IconHighlight,
IconNote,
IconPencil,
IconUser,
IconX,
} from '@tabler/icons-react';
import { JSX, useState } from 'react';
import { API_URL } from '../../api/api';
import { updateBookStatus } from '../../api/books';
import {
AbandonedIcon,
CompletedIcon,
OnHoldIcon,
ReadingIcon,
} from '../../components/status-icons';
import { formatRelativeDate } from '../../utils/dates';
import { BookPageCoverSelector } from './components/book-page-cover-selector';

Expand All @@ -33,6 +43,36 @@ type BookCardProps = {
book: BookWithData;
};

const getStatusIcon = (status: BookStatus, size = 16) => {
switch (status) {
case 'complete':
return <CompletedIcon size={size} withTooltip={false} />;
case 'reading':
return <ReadingIcon size={size} withTooltip={false} />;
case 'on_hold':
return <OnHoldIcon size={size} withTooltip={false} />;
case 'abandoned':
return <AbandonedIcon size={size} withTooltip={false} />;
default:
return null;
}
};

const getStatusLabel = (status: BookStatus) => {
switch (status) {
case 'complete':
return 'Completed';
case 'reading':
return 'Reading';
case 'on_hold':
return 'On Hold';
case 'abandoned':
return 'Abandoned';
default:
return 'Set Status';
}
};

export function BookCard({ book }: BookCardProps): JSX.Element {
const media = useMediaQuery(`(max-width: 62em)`);
const [isCoverSelectorOpened, { open: openCoverSelector, close: closeCoverSelector }] =
Expand All @@ -44,6 +84,11 @@ export function BookCard({ book }: BookCardProps): JSX.Element {
closeCoverSelector();
};

const handleStatusChange = async (status: BookStatus) => {
await updateBookStatus(book.id, status);
mutate(`books/${book.id}`);
};

return (
<Flex align="center" gap="lg">
<Box
Expand Down Expand Up @@ -106,7 +151,62 @@ export function BookCard({ book }: BookCardProps): JSX.Element {
<span className={style.Author}>{book.authors ?? 'N/A'}</span>
</Flex>

<Title fw="800">{book.title}</Title>
<Flex align="center" gap={8}>
<Title fw="800">{book.title}</Title>
{book.status && getStatusIcon(book.status, 24)}
</Flex>

<Menu shadow="md" width={200}>
<Menu.Target>
<Button
variant="light"
size="xs"
mt="xs"
leftSection={getStatusIcon(book.status, 14)}
rightSection={<IconChevronDown size={14} />}
>
{getStatusLabel(book.status)}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Reading status</Menu.Label>
<Menu.Item
leftSection={<CompletedIcon size={14} withTooltip={false} />}
onClick={() => handleStatusChange('complete')}
color={book.status === 'complete' ? 'green' : undefined}
>
Completed
</Menu.Item>
<Menu.Item
leftSection={<ReadingIcon size={14} withTooltip={false} />}
onClick={() => handleStatusChange('reading')}
color={book.status === 'reading' ? 'blue' : undefined}
>
Reading
</Menu.Item>
<Menu.Item
leftSection={<OnHoldIcon size={14} withTooltip={false} />}
onClick={() => handleStatusChange('on_hold')}
color={book.status === 'on_hold' ? 'yellow' : undefined}
>
On Hold
</Menu.Item>
<Menu.Item
leftSection={<AbandonedIcon size={14} withTooltip={false} />}
onClick={() => handleStatusChange('abandoned')}
color={book.status === 'abandoned' ? 'red' : undefined}
>
Abandoned
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconX size={14} />}
onClick={() => handleStatusChange(null)}
>
Clear status
</Menu.Item>
</Menu.Dropdown>
</Menu>

<Flex align="center" gap={8} mt="sm">
<Tooltip label="Series" position="top" withArrow>
Expand Down
Loading