mirror of
https://github.com/gotson/komga.git
synced 2026-03-31 10:33:59 +02:00
item card
This commit is contained in:
parent
7749244328
commit
5a442d0ede
6 changed files with 447 additions and 0 deletions
2
next-ui/src/components.d.ts
vendored
2
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
113
next-ui/src/components/item/ItemCard.stories.ts
Normal file
113
next-ui/src/components/item/ItemCard.stories.ts
Normal 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))],
|
||||
},
|
||||
},
|
||||
}
|
||||
181
next-ui/src/components/item/ItemCard.vue
Normal file
181
next-ui/src/components/item/ItemCard.vue
Normal 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>
|
||||
65
next-ui/src/components/item/SeriesCard.stories.ts
Normal file
65
next-ui/src/components/item/SeriesCard.stories.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
62
next-ui/src/components/item/SeriesCard.vue
Normal file
62
next-ui/src/components/item/SeriesCard.vue
Normal 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>
|
||||
24
next-ui/src/types/ItemCard.ts
Normal file
24
next-ui/src/types/ItemCard.ts
Normal 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]
|
||||
}
|
||||
Loading…
Reference in a new issue