diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts
index be966678..3c6fd972 100644
--- a/next-ui/src/components.d.ts
+++ b/next-ui/src/components.d.ts
@@ -49,6 +49,7 @@ declare module 'vue' {
ImportBooksTransientBooksTable: typeof import('./components/import/books/TransientBooksTable.vue')['default']
ImportReadlistTable: typeof import('./components/import/readlist/Table.vue')['default']
ItemCard: typeof import('./components/item/card/ItemCard.vue')['default']
+ ItemCardWide: typeof import('./components/item/CardWide/ItemCardWide.vue')['default']
LayoutAppBar: typeof import('./components/layout/app/Bar.vue')['default']
LayoutAppBarHolder: typeof import('./components/layout/app/BarHolder.vue')['default']
LayoutAppDrawer: typeof import('./components/layout/app/drawer/Drawer.vue')['default']
@@ -86,6 +87,7 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
SelectionBar: typeof import('./components/selection/Bar.vue')['default']
SeriesCard: typeof import('./components/series/card/SeriesCard.vue')['default']
+ SeriesCardWide: typeof import('./components/series/CardWide/SeriesCardWide.vue')['default']
SeriesDeletionWarning: typeof import('./components/series/DeletionWarning.vue')['default']
SeriesFormEditMetadata: typeof import('./components/series/form/EditMetadata.vue')['default']
SeriesMenu: typeof import('./components/series/menu/SeriesMenu.vue')['default']
diff --git a/next-ui/src/components/item/CardWide/ItemCardWide.stories.ts b/next-ui/src/components/item/CardWide/ItemCardWide.stories.ts
new file mode 100644
index 00000000..8c07ed74
--- /dev/null
+++ b/next-ui/src/components/item/CardWide/ItemCardWide.stories.ts
@@ -0,0 +1,166 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import ItemCardWide from './ItemCardWide.vue'
+import { seriesThumbnailUrl } from '@/api/images'
+import { delay, http } from 'msw'
+import { fn } from 'storybook/test'
+
+const meta = {
+ component: ItemCardWide,
+ render: (args: object) => ({
+ components: { ItemCardWide },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ docs: {
+ description: {
+ component: 'A flexible card that serves as the base for entity cards.',
+ },
+ },
+ },
+ args: {
+ title: 'Card title',
+ text: 'Card content',
+ posterUrl: seriesThumbnailUrl('id'),
+ width: 150,
+ onSelection: fn(),
+ onClickFab: fn(),
+ onClickQuickAction: fn(),
+ onClickMenu: fn(),
+ preSelect: false,
+ selected: false,
+ stretchPoster: true,
+ menuIcon: 'i-mdi:menu',
+ quickActionIcon: 'i-mdi:pencil',
+ },
+ argTypes: {
+ posterUrl: {
+ options: [seriesThumbnailUrl('id'), seriesThumbnailUrl('idL')],
+ control: { type: 'radio' },
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ stretchPoster: true,
+ },
+}
+
+export const LongText: Story = {
+ args: {
+ title:
+ 'The Fractured World of Miravara this is a super long title that should overshoot the container, but that should still be alright because it should wrap nicely',
+ text: `The story unfolds on Miravara, a planet encased within a colossal, translucent barrier known as the Glass Frontier. For centuries, this barrier has protected the populace from a dying sun, filtering its radiation into usable power. Entire cities cling to its surface like glowing barnacles—each one a hybrid of sleek crystal towers and decaying steel foundations. Below the barrier lies the Underglow, a vast labyrinth of machinery and forgotten people who maintain the world’s vital energy grid, unseen by those above.
+
+Politically, Miravara is divided between the High Dominion—wealthy citizens who live near the surface of the barrier—and the Conduits, a laboring caste living in the Underglow. Above them all looms the enigmatic Council of Luminarchs, a secretive group rumored to communicate directly with the Glass Frontier itself.`,
+ stretchPoster: true,
+ },
+}
+
+export const TopRightCount: Story = {
+ args: {
+ topRight: 24,
+ },
+}
+
+export const TopRightIcon: Story = {
+ args: {
+ topRightIcon: 'i-mdi:check',
+ },
+}
+
+export const LandscapeStretched: Story = {
+ args: {
+ topRightIcon: 'i-mdi:check',
+ posterUrl: seriesThumbnailUrl('idL'),
+ stretchPoster: true,
+ },
+}
+
+export const LandscapeNotStretched: Story = {
+ args: {
+ topRightIcon: 'i-mdi:check',
+ posterUrl: seriesThumbnailUrl('idL'),
+ stretchPoster: false,
+ },
+}
+
+export const QuickActionIcon: Story = {
+ args: {
+ quickActionIcon: 'i-mdi:pencil',
+ },
+ play: ({ canvas, userEvent }) => {
+ userEvent.hover(canvas.getByRole('img'))
+ },
+}
+
+export const MenuIcon: Story = {
+ args: {
+ menuIcon: 'i-mdi:menu',
+ },
+ play: ({ canvas, userEvent }) => {
+ userEvent.hover(canvas.getByRole('img'))
+ },
+}
+
+export const SelectableHover: Story = {
+ args: {},
+ play: ({ canvas, userEvent }) => {
+ userEvent.hover(canvas.getByRole('img'))
+ },
+}
+
+export const DisableSelection: Story = {
+ args: {
+ disableSelection: true,
+ },
+ play: ({ canvas, userEvent }) => {
+ userEvent.hover(canvas.getByRole('img'))
+ },
+}
+
+export const Selected: Story = {
+ args: {
+ selected: true,
+ },
+}
+
+export const PreSelect: Story = {
+ args: {
+ preSelect: true,
+ },
+}
+
+export const Progress: Story = {
+ args: {
+ progressPercent: 33,
+ },
+}
+
+export const Big: Story = {
+ args: {
+ 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/CardWide/ItemCardWide.vue b/next-ui/src/components/item/CardWide/ItemCardWide.vue
new file mode 100644
index 00000000..2e6d21aa
--- /dev/null
+++ b/next-ui/src/components/item/CardWide/ItemCardWide.vue
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ topRight }}
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/item/card/ItemCard.vue b/next-ui/src/components/item/card/ItemCard.vue
index e8c7292a..39e80bc5 100644
--- a/next-ui/src/components/item/card/ItemCard.vue
+++ b/next-ui/src/components/item/card/ItemCard.vue
@@ -65,7 +65,7 @@
transition="fade-transition"
content-class="fill-height w-100"
>
-
+
',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ docs: {
+ description: {
+ component: '',
+ },
+ },
+ },
+ 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,
+ },
+}
+
+export const Hover: Story = {
+ args: {},
+ play: ({ canvas, userEvent }) => {
+ userEvent.hover(canvas.getByRole('img'))
+ },
+}
+
+export const HoverNonAdmin: Story = {
+ args: {},
+ parameters: {
+ msw: {
+ handlers: [
+ httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userRegular)),
+ ],
+ },
+ },
+ play: ({ canvas, userEvent }) => {
+ userEvent.hover(canvas.getByRole('img'))
+ },
+}
diff --git a/next-ui/src/components/series/CardWide/SeriesCardWide.vue b/next-ui/src/components/series/CardWide/SeriesCardWide.vue
new file mode 100644
index 00000000..c2716a8b
--- /dev/null
+++ b/next-ui/src/components/series/CardWide/SeriesCardWide.vue
@@ -0,0 +1,67 @@
+
+ emit('selection', val)"
+ @click-quick-action="showEditMetadataDialog()"
+ @card-long-press="bottomSheet = true"
+ />
+
+
+
+
+
diff --git a/next-ui/src/pages/libraries/[id]/series.vue b/next-ui/src/pages/libraries/[id]/series.vue
index 92c1fab5..57217976 100644
--- a/next-ui/src/pages/libraries/[id]/series.vue
+++ b/next-ui/src/pages/libraries/[id]/series.vue
@@ -3,7 +3,7 @@
@@ -50,22 +52,33 @@
:series="item.raw"
:selected="isSelected(item)"
:pre-select="preSelect"
- :width="appStore.gridCardWidth"
+ :width="display.xs.value ? undefined : appStore.gridCardWidth"
@selection="toggleSelect(item)"
/>
-
-
+
-
+ >
+
+
+
+
+
@@ -87,15 +100,20 @@ import { useItemsPerPage, usePagination } from '@/composables/pagination'
import { useSearchConditionLibraries } from '@/composables/search'
import { storeToRefs } from 'pinia'
import { useSelectionStore } from '@/stores/selection'
+import { useDisplay } from 'vuetify'
const route = useRoute('/libraries/[id]/series')
const libraryId = route.params.id
const { libraries } = useGetLibrariesById(libraryId)
const { librariesCondition } = useSearchConditionLibraries(libraries)
+const display = useDisplay()
const appStore = useAppStore()
const { browsingPageSize } = storeToRefs(appStore)
const presentationMode = appStore.getPresentationMode(`${libraryId}_series`, 'grid')
+const presentationModeEffective = computed(() =>
+ display.xs.value ? 'grid' : presentationMode.value,
+)
const { itemsPerPage } = useItemsPerPage(browsingPageSize)
const { page0, page1, pageCount } = usePagination()