basic card wide

This commit is contained in:
Gauthier Roebroeck 2026-01-27 10:43:49 +08:00
parent 3789f07eff
commit 368c390e2f
7 changed files with 595 additions and 11 deletions

View file

@ -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']

View file

@ -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: '<ItemCardWide v-bind="args" />',
}),
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<typeof ItemCardWide>
export default meta
type Story = StoryObj<typeof meta>
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 worlds vital energy grid, unseen by those above.
Politically, Miravara is divided between the High Dominionwealthy citizens who live near the surface of the barrierand 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))],
},
},
}

View file

@ -0,0 +1,235 @@
<template>
<v-hover
v-slot="{ props }"
v-model="isHovering"
>
<v-card
v-on-long-press.prevent="onCardLongPress"
v-bind="props"
:elevation="isHovering ? 3 : 1"
:class="isPreSelect || selected ? 'cursor-pointer' : 'cursor-default'"
@click="isPreSelect || selected ? emit('selection', !selected) : {}"
>
<v-card-text>
<div class="d-flex flex-row ga-4">
<div>
<v-fade-transition>
<v-icon-btn
v-if="showSelection"
:icon="
selected || (isPreSelect && isHovering)
? 'i-mdi:checkbox-marked-circle'
: 'i-mdi:checkbox-blank-circle-outline'
"
:color="selected ? 'primary' : undefined"
:variant="selected ? 'text' : 'flat'"
class="position-absolute top-0 left-0"
style="z-index: 2"
@click.stop="emit('selection', !selected)"
/>
</v-fade-transition>
<v-img
:cover="stretchPoster"
:position="stretchPoster ? 'top ' : undefined"
:src="posterUrl"
lazy-src="@/assets/cover.svg"
aspect-ratio="0.7071"
:class="{ normal: true, inset: selected }"
:width="width"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
color="grey"
indeterminate
/>
</div>
</template>
<!-- This will just show lazy-src without the v-progress -->
<template #error></template>
<!-- Top-right icon -->
<div
v-if="topRightIcon || topRight"
class="top-0 right-0 position-absolute translucent text-white px-2 py-1 font-weight-bold text-caption"
style="border-bottom-left-radius: 4px"
>
<v-icon
v-if="topRightIcon"
:icon="topRightIcon"
/>
<template v-else>{{ topRight }}</template>
</div>
<!-- Progress bar -->
<v-progress-linear
v-if="progressPercent"
:model-value="progressPercent"
color="primary"
height="10"
class="position-absolute bottom-0"
style="top: unset"
/>
</v-img>
</div>
<div>
<div
class="text-h6 force-line-count text-wrap"
:style="[{ '--lines': 1 }, { '--line-height': 1.6 }]"
>
{{ title }}
</div>
<div
class="text-subtitle-1 force-line-count text-pre-wrap mt-2"
:style="[{ '--lines': display.xs.value ? 1 : 3 }, { '--line-height': 1.6 }]"
>
{{ text }}
</div>
</div>
<!-- icon holder -->
<div class="d-flex flex-column ga-1 position-absolute top-0 right-0 mt-2 me-2">
<!-- menu icon -->
<v-fade-transition>
<v-icon-btn
v-if="menuIcon && showMenu"
:icon="menuIcon"
v-bind="menuProps"
@click.stop="emit('clickMenu')"
/>
</v-fade-transition>
<!-- quick action icon -->
<v-fade-transition>
<v-icon-btn
v-if="quickActionIcon && showQuickAction"
:icon="quickActionIcon"
v-bind="quickActionProps"
@click.stop="emit('clickQuickAction')"
/>
</v-fade-transition>
</div>
</div>
</v-card-text>
</v-card>
</v-hover>
</template>
<script setup lang="ts">
import type { ItemCardEmits, ItemCardProps } from '@/types/ItemCard'
import { vOnLongPress } from '@vueuse/components'
import { usePrimaryInput } from '@/composables/device'
import { useDisplay } from 'vuetify'
const display = useDisplay()
const { isTouchPrimary } = usePrimaryInput()
function onCardLongPress() {
if (isTouchPrimary.value) emit('cardLongPress')
}
const {
stretchPoster,
width = 150,
disableSelection = false,
selected = false,
preSelect = false,
quickActionIcon,
quickActionProps = {},
menuIcon,
} = defineProps<
ItemCardProps & {
/**
* Poster URL.
*/
posterUrl?: string
/**
* Card title.
*/
title?: string
/**
* Text to display under the title.
*/
text?: string
/**
* Number displayed in the top-right corner.
*/
topRight?: number
/**
* Icon displayed in the top-right corner. Takes precedence over `topRight`.
*/
topRightIcon?: string
/**
* Icon displayed in the bottom-left corner.
*/
quickActionIcon?: string
/**
* Props to pass to the menu icon element.
*/
quickActionProps?: Record<string, unknown>
/**
* Icon displayed in the bottom-right corner.
*/
menuIcon?: string
/**
* Props to pass to the menu icon element.
*/
menuProps?: object
progressPercent?: number
}
>()
const emit = defineEmits<
ItemCardEmits & {
clickFab: []
clickQuickAction: []
clickMenu: []
cardLongPress: []
}
>()
const isHovering = ref(false)
const isPreSelect = computed(() => preSelect && !selected && !disableSelection)
const showSelection = computed(
() => !disableSelection && (isHovering.value || isPreSelect.value || selected),
)
const showQuickAction = computed(
() => (isHovering.value || isTouchPrimary.value) && !selected && !preSelect,
)
const showMenu = computed(
() => isHovering.value && !selected && !preSelect && !isTouchPrimary.value,
)
</script>
<style lang="scss">
.min-height {
min-height: 1.5rem;
}
.translucent {
background-color: rgba(0, 0, 0, 0.7);
}
// Required so that opacity change in VOverlay has transition
.v-overlay__scrim {
transition: opacity 0.3s ease;
}
// Required as initial state so that transition happens on deselect
.normal {
transform: scale(1);
transition: transform 0.135s cubic-bezier(0, 0, 0.2, 1);
}
.inset {
transform: scale(0.78);
border-radius: 8px;
}
.plain {
opacity: 0.62;
}
</style>

View file

@ -65,7 +65,7 @@
transition="fade-transition"
content-class="fill-height w-100"
>
<!-- Top right number / icon -->
<!-- Top left selection -->
<v-icon-btn
v-if="!hideSelection"
:icon="

View file

@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SeriesCardWide from './SeriesCardWide.vue'
import { mockSeries1 } from '@/mocks/api/handlers/series'
import { fn } from 'storybook/test'
import { httpTyped } from '@/mocks/api/httpTyped'
import { userRegular } from '@/mocks/api/handlers/users'
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
const meta = {
component: SeriesCardWide,
render: (args: object) => ({
components: { SeriesCardWide, DialogConfirmEditInstance, DialogConfirmInstance },
setup() {
return { args }
},
template:
'<SeriesCardWide v-bind="args" /><DialogConfirmEditInstance/><DialogConfirmInstance/>',
}),
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<typeof SeriesCardWide>
export default meta
type Story = StoryObj<typeof meta>
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'))
},
}

View file

@ -0,0 +1,67 @@
<template>
<ItemCardWide
:title="series.metadata.title"
:text="series.metadata.summary"
:poster-url="seriesThumbnailUrl(series.id)"
:top-right="unreadCount"
:top-right-icon="isRead ? 'i-mdi:check' : undefined"
:quick-action-icon="quickActionIcon"
:quick-action-props="quickActionProps"
:menu-icon="menuIcon"
:menu-props="menuProps"
v-bind="props"
@selection="(val) => emit('selection', val)"
@click-quick-action="showEditMetadataDialog()"
@card-long-press="bottomSheet = true"
/>
<SeriesMenu
:series="series"
:activator="menuActivator"
/>
<SeriesMenuBottomSheet
v-model="bottomSheet"
:series="series"
/>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { seriesThumbnailUrl } from '@/api/images'
import type { ItemCardEmits, ItemCardProps } from '@/types/ItemCard'
import { useCurrentUser } from '@/colada/users'
import { useEditSeriesMetadataDialog } from '@/composables/series/useEditSeriesMetadataDialog'
const { series, ...props } = defineProps<
{
series: components['schemas']['SeriesDto']
} & ItemCardProps
>()
const emit = defineEmits<ItemCardEmits>()
const bottomSheet = ref(false)
const isRead = computed(() => series.booksCount === series.booksReadCount)
const unreadCount = computed(() =>
series.oneshot ? undefined : series.booksUnreadCount + series.booksInProgressCount,
)
const { isAdmin } = useCurrentUser()
const quickActionIcon = computed(() => (isAdmin.value ? 'i-mdi:pencil' : undefined))
const quickActionProps = computed(() => ({
onMouseenter: (event: Event) => (editMetadataActivator.value = event.currentTarget as Element),
}))
const menuIcon = computed(() => (isAdmin.value ? 'i-mdi:dots-vertical' : undefined))
const menuProps = computed(() => ({
onmouseenter: (event: Event) => (menuActivator.value = event.currentTarget as Element),
}))
const { prepareDialog: showEditSeriesMetadataDialog, activator: editMetadataActivator } =
useEditSeriesMetadataDialog()
function showEditMetadataDialog() {
showEditSeriesMetadataDialog(series)
}
const menuActivator = ref()
</script>

View file

@ -3,7 +3,7 @@
<v-spacer />
<v-slider
v-if="presentationMode === 'grid'"
v-if="display.smAndUp.value"
v-model="appStore.gridCardWidth"
:min="130"
:max="200"
@ -14,8 +14,10 @@
/>
<PresentationSelector
v-if="display.smAndUp.value"
v-model="presentationMode"
:modes="['grid', 'list']"
toggle
/>
<PageSizeSelector
@ -36,7 +38,7 @@
>
<template #default="{ items, toggleSelect, isSelected }">
<v-container
v-if="presentationMode === 'grid'"
v-if="presentationModeEffective === 'grid'"
fluid
>
<v-row>
@ -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)"
/>
</v-col>
</v-row>
</v-container>
<v-list v-if="presentationMode === 'list'">
<v-list-item
<v-container
v-if="presentationModeEffective === 'list'"
fluid
>
<v-row
v-for="item in items"
:key="item.raw.id"
:title="item.raw.metadata.title"
:base-color="isSelected(item) ? 'red' : 'blue'"
@click="toggleSelect(item)"
/>
</v-list>
>
<v-col>
<SeriesCardWide
stretch-poster
:series="item.raw"
:selected="isSelected(item)"
:pre-select="preSelect"
:width="appStore.gridCardWidth"
@selection="toggleSelect(item)"
/>
</v-col>
</v-row>
</v-container>
</template>
</v-data-iterator>
@ -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()