more work on item cards

This commit is contained in:
Gauthier Roebroeck 2026-01-12 16:29:49 +08:00
parent 5e376dfac6
commit 420afe5d09
6 changed files with 180 additions and 125 deletions

View file

@ -42,8 +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']
ItemCard: typeof import('./components/item/Card/ItemCard.vue')['default']
ItemCardSeries: typeof import('./components/item/Card/Series.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

@ -16,12 +16,20 @@ const meta = {
}),
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',
title: { text: 'Card title' },
posterUrl: seriesThumbnailUrl('id'),
width: 150,
onSelection: fn(),
onClickFab: fn(),
preSelect: false,
selected: false,
},
} satisfies Meta<typeof ItemCard>
@ -30,32 +38,37 @@ type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
line1: 'Line 1',
line2: 'Line 2',
lines: [{ text: 'Line 1' }, { text: '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',
title: {
text: 'Short 2 lines',
lines: 2,
},
lines: [
{
text: 'A very long title that will wrap but that is very long so it takes more lines',
lines: 2,
},
{ text: 'Short 2 lines', lines: 2 },
],
stretchPoster: true,
},
}
export const EmptyLines: Story = {
args: {
allowEmptyLine1: true,
allowEmptyLine2: true,
lines: [{ allowEmpty: true }, { allowEmpty: true }],
},
}
export const NoEmptyLines: Story = {
args: {
allowEmptyLine1: false,
allowEmptyLine2: false,
lines: [{ allowEmpty: false }, { allowEmpty: false }],
},
}
@ -78,9 +91,20 @@ export const SelectableHover: Story = {
},
}
export const FabHover: Story = {
args: {
fabIcon: 'i-mdi:check',
disableSelection: true,
},
play: ({ canvas, userEvent }) => {
userEvent.hover(canvas.getByRole('img'))
},
}
export const Selected: Story = {
args: {
selected: true,
fabIcon: 'i-mdi:check',
},
}
@ -92,8 +116,7 @@ export const PreSelect: Story = {
export const Big: Story = {
args: {
line1: 'Line 1',
line2: 'Line 2',
lines: [{ text: 'Line 1' }, { text: 'Line 2' }],
width: 300,
},
}

View file

@ -6,11 +6,10 @@
:disabled="overlayDisabled"
>
<div
class="position-relative bg-scrim"
class="position-relative"
v-bind="props"
>
<v-img
id="abc123"
:contain="!stretchPoster"
:position="stretchPoster ? 'top' : undefined"
:src="posterUrl"
@ -49,10 +48,12 @@
:opacity="overlayTransparent ? 0 : 0.3"
contained
transition="fade-transition"
content-class="fill-height w-100"
>
<v-icon-btn
v-if="!disableSelection"
:icon="
preSelect && !isHovering
isPreSelect && !isHovering
? 'i-mdi:checkbox-blank-circle-outline'
: 'i-mdi:checkbox-marked-circle'
"
@ -61,33 +62,61 @@
class="top-0 left-0 position-absolute"
@click="emit('selection', !selected)"
/>
<v-hover
v-if="isHovering && fabIcon && !hideFab"
v-slot="{ isHovering: fabHover, props: fabProps }"
>
<v-fab
:icon="fabIcon"
location="center center"
absolute
:variant="fabHover ? 'flat' : 'outlined'"
:color="fabHover ? 'primary' : 'white'"
:class="{ plain: !fabHover }"
v-bind="fabProps"
@click="emit('clickFab')"
/>
</v-hover>
</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-title
:class="['text-subtitle-2 px-2 pb-0 mb-2', { 'force-line-count text-wrap': title.lines }]"
:style="[{ '--lines': title.lines }, { '--line-height': 1.6 }]"
>{{ title.text }}</v-card-title
>
<v-card-subtitle
:class="`px-2 pb-1 ${allowEmptyLine2 ? 'min-height' : undefined} ${line2Classes}`"
>{{ line2 }}</v-card-subtitle
<template
v-for="(line, i) in lines"
:key="i"
>
<v-card-subtitle
v-if="line.text || line.allowEmpty"
:class="[
'px-2 mb-1',
{ 'min-height': line.allowEmpty },
line.classes,
{ 'force-line-count text-wrap': line.lines },
]"
:style="[{ '--lines': line.lines }, { '--line-height': 1.4 }]"
>{{ line.text }}
</v-card-subtitle>
</template>
</v-card>
</template>
<script setup lang="ts">
import type { ItemCardEmits, ItemCardProps } from '@/types/ItemCard'
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
const {
stretchPoster = true,
width = 150,
allowEmptyLine1 = false,
allowEmptyLine2 = false,
disableSelection = false,
selected = false,
preSelect = false,
fabIcon,
} = defineProps<
ItemCardProps & {
/**
@ -103,36 +132,11 @@ const {
/**
* Card title. Displayed under the poster.
*/
title: string
title: ItemCardTitle
/**
* First line of text.
* Lines of text to display under the title.
*/
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
lines?: ItemCardLine[]
/**
* Number displayed in the top-right corner.
*/
@ -141,18 +145,25 @@ const {
* Icon displayed in the top-right corner. Takes precedence over `topRight`.
*/
topRightIcon?: string
fabIcon?: string
}
>()
const emit = defineEmits<ItemCardEmits>()
const emit = defineEmits<
ItemCardEmits & {
clickFab: []
}
>()
const isHovering = ref(false)
const overlayDisabled = computed(() => {
return disableSelection
return disableSelection && !fabIcon
})
const overlayTransparent = computed(() => (selected || preSelect) && !isHovering.value)
const isPreSelect = computed(() => preSelect && !selected && !disableSelection)
const overlayTransparent = computed(() => (selected || isPreSelect.value) && !isHovering.value)
const overlayShown = computed(() => isHovering.value || overlayTransparent.value || preSelect)
const hideFab = computed(() => selected || isPreSelect.value)
</script>
<style lang="scss">
@ -178,4 +189,8 @@ const overlayShown = computed(() => isHovering.value || overlayTransparent.value
transform: scale(0.78);
border-radius: 8px;
}
.plain {
opacity: 0.62;
}
</style>

View file

@ -1,26 +1,31 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SeriesCard from './SeriesCard.vue'
import Series from './Series.vue'
import { mockSeries1 } from '@/mocks/api/handlers/series'
import { fn } from 'storybook/test'
const meta = {
component: SeriesCard,
component: Series,
render: (args: object) => ({
components: { SeriesCard },
components: { Series },
setup() {
return { args }
},
template: '<SeriesCard v-bind="args" />',
template: '<Series v-bind="args" />',
}),
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 SeriesCard>
} satisfies Meta<typeof Series>
export default meta
type Story = StoryObj<typeof meta>

View file

@ -0,0 +1,74 @@
<template>
<ItemCard
:title="title"
:lines="lines"
:poster-url="seriesThumbnailUrl(series.id)"
:top-right="unreadCount"
:top-right-icon="isRead ? 'i-mdi:check' : undefined"
fab-icon="i-mdi:play"
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, ItemCardLine, ItemCardProps, ItemCardTitle } 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 title = computed<ItemCardTitle>(() => ({ text: series.metadata.title, lines: 2 }))
const lines = computed<ItemCardLine[]>(() => {
if (series.deleted)
return [
{
text: intl.formatMessage({
description: 'Series card subtitle: unavailable',
defaultMessage: 'Unavailable',
id: 'wbH42A',
}),
classes: 'text-error',
},
]
if (series.oneshot) {
return [
{
text: intl.formatMessage({
description: 'Series card subtitle: oneshot',
defaultMessage: 'One-shot',
id: 'NKVL81',
}),
},
]
}
return [
{
text: intl.formatMessage(
{
description: 'Series card subtitle: count of books',
defaultMessage: '{count} books',
id: 'bJsa/f',
},
{ count: series.booksCount },
),
},
]
})
</script>

View file

@ -1,62 +0,0 @@
<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>