item card

This commit is contained in:
Gauthier Roebroeck 2026-01-09 14:18:14 +08:00
parent 7749244328
commit 5a442d0ede
6 changed files with 447 additions and 0 deletions

View file

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

View file

@ -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: '<ItemCard v-bind="args" />',
}),
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<typeof ItemCard>
export default meta
type Story = StoryObj<typeof meta>
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))],
},
},
}

View file

@ -0,0 +1,181 @@
<template>
<v-card :width="width">
<v-hover
v-slot="{ props }"
v-model="isHovering"
:disabled="overlayDisabled"
>
<div
class="position-relative bg-scrim"
v-bind="props"
>
<v-img
id="abc123"
:contain="!stretchPoster"
:position="stretchPoster ? 'top' : undefined"
:src="posterUrl"
lazy-src="@/assets/cover.svg"
aspect-ratio="0.7071"
:class="{ normal: true, inset: selected }"
>
<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>
<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>
</v-img>
<!-- The overlay is outside the image, so that we can scale transform the image only -->
<v-overlay
:model-value="overlayShown"
:opacity="overlayTransparent ? 0 : 0.3"
contained
transition="fade-transition"
>
<v-icon-btn
:icon="
preSelect && !isHovering
? 'i-mdi:checkbox-blank-circle-outline'
: 'i-mdi:checkbox-marked-circle'
"
:variant="selected || preSelect ? 'text' : 'plain'"
:color="selected ? 'primary' : 'white'"
class="top-0 left-0 position-absolute"
@click="emit('selection', !selected)"
/>
</v-overlay>
</div>
</v-hover>
<v-card-title class="text-subtitle-2 px-2">{{ title }}</v-card-title>
<v-card-subtitle
:class="`px-2 pb-1 ${allowEmptyLine1 ? 'min-height' : undefined} ${line1Classes}`"
>{{ line1 }}</v-card-subtitle
>
<v-card-subtitle
:class="`px-2 pb-1 ${allowEmptyLine2 ? 'min-height' : undefined} ${line2Classes}`"
>{{ line2 }}</v-card-subtitle
>
</v-card>
</template>
<script setup lang="ts">
import type { ItemCardEmits, ItemCardProps } from '@/types/ItemCard'
const {
stretchPoster = true,
width = 150,
allowEmptyLine1 = false,
allowEmptyLine2 = false,
disableSelection = false,
selected = false,
preSelect = false,
} = defineProps<
ItemCardProps & {
/**
* Poster URL.
*/
posterUrl?: string
/**
* Whether to stretch the poster or not. If `false`, the image will have the `contain` property.
*
* Defaults to `true`.
*/
stretchPoster?: boolean
/**
* Card title. Displayed under the poster.
*/
title: string
/**
* First line of text.
*/
line1?: string
/**
* Classes to apply on `line1`.
*/
line1Classes?: string
/**
* Second line of text.
*/
line2?: string
/**
* Classes to apply on `line2`.
*/
line2Classes?: string
/**
* Whether the `line1` container will be shown even if `line1` is empty.
*
* Defaults to `false`.
*/
allowEmptyLine1?: boolean
/**
* Whether the `line2` container will be shown even if `line2` is empty.
*
* Defaults to `false`.
*/
allowEmptyLine2?: boolean
/**
* Number displayed in the top-right corner.
*/
topRight?: number
/**
* Icon displayed in the top-right corner. Takes precedence over `topRight`.
*/
topRightIcon?: string
}
>()
const emit = defineEmits<ItemCardEmits>()
const isHovering = ref(false)
const overlayDisabled = computed(() => {
return disableSelection
})
const overlayTransparent = computed(() => (selected || preSelect) && !isHovering.value)
const overlayShown = computed(() => isHovering.value || overlayTransparent.value || preSelect)
</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;
}
</style>

View file

@ -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: '<SeriesCard v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {
series: mockSeries1,
onSelection: fn(),
},
} satisfies Meta<typeof SeriesCard>
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,
},
}

View file

@ -0,0 +1,62 @@
<template>
<ItemCard
:title="series.metadata.title"
:line1="line1"
:line1-classes="line1Classes"
:poster-url="seriesThumbnailUrl(series.id)"
:top-right="unreadCount"
:top-right-icon="isRead ? 'i-mdi:check' : undefined"
v-bind="props"
@selection="(val) => emit('selection', val)"
/>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { seriesThumbnailUrl } from '@/api/images'
import { useIntl } from 'vue-intl'
import type { ItemCardEmits, ItemCardProps } from '@/types/ItemCard'
const intl = useIntl()
const { series, ...props } = defineProps<
{
series: components['schemas']['SeriesDto']
} & ItemCardProps
>()
const emit = defineEmits<ItemCardEmits>()
const isRead = computed(() => series.booksCount === series.booksReadCount)
const unreadCount = computed(() =>
series.oneshot ? undefined : series.booksUnreadCount + series.booksInProgressCount,
)
const line1 = computed(() => {
if (series.deleted)
return intl.formatMessage({
description: 'Series card subtitle: unavailable',
defaultMessage: 'Unavailable',
id: 'wbH42A',
})
if (series.oneshot) {
return intl.formatMessage({
description: 'Series card subtitle: oneshot',
defaultMessage: 'One-shot',
id: 'NKVL81',
})
}
return intl.formatMessage(
{
description: 'Series card subtitle: count of books',
defaultMessage: '{count} books',
id: 'bJsa/f',
},
{ count: series.booksCount },
)
})
const line1Classes = computed(() => {
if (series.deleted) return 'text-error'
return undefined
})
</script>

View file

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