From 5a442d0edef32e21bbf57cdf98dba8fd5aba04f3 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Fri, 9 Jan 2026 14:18:14 +0800 Subject: [PATCH] item card --- next-ui/src/components.d.ts | 2 + .../src/components/item/ItemCard.stories.ts | 113 +++++++++++ next-ui/src/components/item/ItemCard.vue | 181 ++++++++++++++++++ .../src/components/item/SeriesCard.stories.ts | 65 +++++++ next-ui/src/components/item/SeriesCard.vue | 62 ++++++ next-ui/src/types/ItemCard.ts | 24 +++ 6 files changed, 447 insertions(+) create mode 100644 next-ui/src/components/item/ItemCard.stories.ts create mode 100644 next-ui/src/components/item/ItemCard.vue create mode 100644 next-ui/src/components/item/SeriesCard.stories.ts create mode 100644 next-ui/src/components/item/SeriesCard.vue create mode 100644 next-ui/src/types/ItemCard.ts diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index a15c622d..c920afe8 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -42,6 +42,8 @@ declare module 'vue' { ImportBooksDirectorySelection: typeof import('./components/import/books/DirectorySelection.vue')['default'] ImportBooksTransientBooksTable: typeof import('./components/import/books/TransientBooksTable.vue')['default'] ImportReadlistTable: typeof import('./components/import/readlist/Table.vue')['default'] + ItemCard: typeof import('./components/item/ItemCard.vue')['default'] + ItemSeriesCard: typeof import('./components/item/SeriesCard.vue')['default'] LayoutAppBar: typeof import('./components/layout/app/Bar.vue')['default'] LayoutAppDrawer: typeof import('./components/layout/app/drawer/Drawer.vue')['default'] LayoutAppDrawerFooter: typeof import('./components/layout/app/drawer/Footer.vue')['default'] diff --git a/next-ui/src/components/item/ItemCard.stories.ts b/next-ui/src/components/item/ItemCard.stories.ts new file mode 100644 index 00000000..194024f6 --- /dev/null +++ b/next-ui/src/components/item/ItemCard.stories.ts @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import ItemCard from './ItemCard.vue' +import { seriesThumbnailUrl } from '@/api/images' +import { delay, http } from 'msw' +import { fn } from 'storybook/test' + +const meta = { + component: ItemCard, + render: (args: object) => ({ + components: { ItemCard }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: { + title: 'Card title', + posterUrl: seriesThumbnailUrl('id'), + width: 150, + onSelection: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + line1: 'Line 1', + line2: 'Line 2', + stretchPoster: true, + }, +} + +export const LongText: Story = { + args: { + title: 'A very long title that will wrap', + line1: 'A very long title that will wrap', + line2: 'A very long title that will wrap', + stretchPoster: true, + }, +} + +export const EmptyLines: Story = { + args: { + allowEmptyLine1: true, + allowEmptyLine2: true, + }, +} + +export const NoEmptyLines: Story = { + args: { + allowEmptyLine1: false, + allowEmptyLine2: false, + }, +} + +export const TopRightCount: Story = { + args: { + topRight: 24, + }, +} + +export const TopRightIcon: Story = { + args: { + topRightIcon: 'i-mdi:check', + }, +} + +export const SelectableHover: Story = { + args: {}, + play: ({ canvas, userEvent }) => { + userEvent.hover(canvas.getByRole('img')) + }, +} + +export const Selected: Story = { + args: { + selected: true, + }, +} + +export const PreSelect: Story = { + args: { + preSelect: true, + }, +} + +export const Big: Story = { + args: { + line1: 'Line 1', + line2: 'Line 2', + width: 300, + }, +} + +export const PosterError: Story = { + args: { + posterUrl: '/error', + }, +} + +export const Loading: Story = { + parameters: { + msw: { + handlers: [http.all('*/api/*', async () => await delay(5_000))], + }, + }, +} diff --git a/next-ui/src/components/item/ItemCard.vue b/next-ui/src/components/item/ItemCard.vue new file mode 100644 index 00000000..2ad5f723 --- /dev/null +++ b/next-ui/src/components/item/ItemCard.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/next-ui/src/components/item/SeriesCard.stories.ts b/next-ui/src/components/item/SeriesCard.stories.ts new file mode 100644 index 00000000..ae916121 --- /dev/null +++ b/next-ui/src/components/item/SeriesCard.stories.ts @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import SeriesCard from './SeriesCard.vue' +import { mockSeries1 } from '@/mocks/api/handlers/series' +import { fn } from 'storybook/test' + +const meta = { + component: SeriesCard, + render: (args: object) => ({ + components: { SeriesCard }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: { + series: mockSeries1, + onSelection: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} +export const Read: Story = { + args: { + series: { + ...mockSeries1, + booksCount: 5, + booksReadCount: 5, + booksUnreadCount: 0, + booksInProgressCount: 0, + }, + }, +} + +export const Oneshot: Story = { + args: { + series: { + ...mockSeries1, + oneshot: true, + }, + }, +} + +export const Deleted: Story = { + args: { + series: { + ...mockSeries1, + deleted: true, + }, + }, +} + +export const Selected: Story = { + args: { + selected: true, + }, +} diff --git a/next-ui/src/components/item/SeriesCard.vue b/next-ui/src/components/item/SeriesCard.vue new file mode 100644 index 00000000..d6ad2b0c --- /dev/null +++ b/next-ui/src/components/item/SeriesCard.vue @@ -0,0 +1,62 @@ + + + diff --git a/next-ui/src/types/ItemCard.ts b/next-ui/src/types/ItemCard.ts new file mode 100644 index 00000000..c96fc7e0 --- /dev/null +++ b/next-ui/src/types/ItemCard.ts @@ -0,0 +1,24 @@ +export type ItemCardProps = { + /** + * Card width. + * + * Defaults to `150`. + */ + width?: string | number + /** + * Disable card selection. + */ + disableSelection?: boolean + /** + * Whether the card is currently selected. + */ + selected?: boolean + /** + * State where the selection checkbox is shown, for instance when other items in the group have been selected already. + */ + preSelect?: boolean +} + +export type ItemCardEmits = { + selection: [selected: boolean] +}