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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ topRight }}
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+ {{ line1 }}
+ {{ line2 }}
+
+
+
+
+
+
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 @@
+
+ emit('selection', val)"
+ />
+
+
+
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]
+}