mirror of
https://github.com/gotson/komga.git
synced 2026-01-20 15:15:16 +01:00
more work on item cards
This commit is contained in:
parent
5e376dfac6
commit
420afe5d09
6 changed files with 180 additions and 125 deletions
4
next-ui/src/components.d.ts
vendored
4
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
74
next-ui/src/components/item/Card/Series.vue
Normal file
74
next-ui/src/components/item/Card/Series.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue