fix(front): consistent small cards

This commit is contained in:
ArneBo 2025-01-29 12:04:04 +01:00
parent 10140959d3
commit 3d710dbb02
13 changed files with 156 additions and 108 deletions

View File

@ -4,9 +4,11 @@ import { computed } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { momentFormat } from '~/utils/filters' import { momentFormat } from '~/utils/filters'
import defaultCover from '~/assets/audio/default-cover.png'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import Card from '~/components/ui/Card.vue' import Card from '~/components/ui/Card.vue'
import Link from '~/components/ui/Link.vue'
import TagsList from '~/components/tags/List.vue' import TagsList from '~/components/tags/List.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
@ -39,7 +41,7 @@ if (import.meta.env.PROD) {
const store = useStore() const store = useStore()
const imageUrl = computed(() => props.album.cover?.urls.original const imageUrl = computed(() => props.album.cover?.urls.original
? store.getters['instance/absoluteUrl'](props.album.cover?.urls.large_square_crop) ? store.getters['instance/absoluteUrl'](props.album.cover?.urls.large_square_crop)
: `${import.meta.env.BASE_URL}embed-default-cover.jpeg` : defaultCover
) )
</script> </script>
@ -49,6 +51,7 @@ const imageUrl = computed(() => props.album.cover?.urls.original
:image="imageUrl" :image="imageUrl"
:tags="album.tags" :tags="album.tags"
:to="{name: 'library.albums.detail', params: {id: album.id}}" :to="{name: 'library.albums.detail', params: {id: album.id}}"
small
> >
<template #topright> <template #topright>
<PlayButton <PlayButton
@ -62,27 +65,27 @@ const imageUrl = computed(() => props.album.cover?.urls.original
v-for="ac in album.artist_credit" v-for="ac in album.artist_credit"
:key="ac.artist.id" :key="ac.artist.id"
> >
<router-link <Link
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id }}" :to="{ name: 'library.artists.detail', params: { id: ac.artist.id }}"
> >
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }} {{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
</router-link> </Link>
<span>{{ ac.joinphrase }}</span> <span>{{ ac.joinphrase }}</span>
</template> </template>
<template #action> <template #footer>
<Spacer :size="8" />
<span v-if="album.release_date"> <span v-if="album.release_date">
{{ momentFormat(new Date(album.release_date), 'Y') }} {{ momentFormat(new Date(album.release_date), 'Y') }}
</span> </span>
<i class="bi bi-dot"/>
<span> <span>
<i class="bi bi-music-note-list" />
{{ t('components.audio.album.Card.meta.tracks', album.tracks_count) }} {{ t('components.audio.album.Card.meta.tracks', album.tracks_count) }}
</span> </span>
<Spacer h grow /> <Spacer h grow />
<PlayButton <PlayButton
id="playmenu" id="playmenu"
:dropdown-only="true" :dropdown-only="true"
discrete
:is-playable="album.is_playable" :is-playable="album.is_playable"
:album="album" :album="album"
/> />
@ -92,8 +95,7 @@ const imageUrl = computed(() => props.album.cover?.urls.original
<style lang="scss" scoped> <style lang="scss" scoped>
.play-button { .play-button {
position: absolute; top: 16px;
top: 214px;
right: 16px; right: 16px;
} }
</style> </style>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -8,10 +8,13 @@ import PlayButton from '~/components/audio/PlayButton.vue'
import Card from '~/components/ui/Card.vue' import Card from '~/components/ui/Card.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
import type { Artist } from '~/types' import type { Artist, Cover, Track } from '~/types'
const play = defineEmit<[artist: Artist]>() const play = defineEmit<[artist: Artist]>()
const albums = ref([] as Album[])
const tracks = ref([] as Track[])
interface Props { interface Props {
artist: Artist; artist: Artist;
} }
@ -30,25 +33,45 @@ if (import.meta.env.PROD) {
router.push({ name: 'library.artists.detail', params: { id: artist.id } }) router.push({ name: 'library.artists.detail', params: { id: artist.id } })
} }
const cover = computed(() => !props.artist.cover?.urls.large_square_crop
? props.artist.albums.find(album => !!album.cover?.urls.large_square_crop)?.cover
: props.artist.cover
)
const store = useStore() const store = useStore()
const imageUrl = computed(() => cover.value?.urls.original
? store.getters['instance/absoluteUrl'](cover.value.urls.large_square_crop)
: `${import.meta.env.BASE_URL}embed-default-cover.jpeg` const cover = computed(() => {
) const artistCover: Cover | undefined = artist.cover
const albumCover: Cover | undefined = artist.albums
.find(album => album.cover?.urls.medium_square_crop)?.cover
const trackCover: Cover | undefined =
tracks.value?.find(
track => track.cover
)
?.cover
const fallback : Cover = {
uuid: '',
urls: {
original: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
medium_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
large_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`
}
}
return artistCover
|| albumCover
|| trackCover
|| fallback
})
</script> </script>
<template> <template>
<Card <Card
:title="artist.name" :title="artist.name"
:image="imageUrl"
class="artist-card" class="artist-card"
:tags="artist.tags" :tags="artist.tags"
:to="{name: 'library.artists.detail', params: {id: artist.id}}" :to="{name: 'library.artists.detail', params: {id: artist.id}}"
small
> >
<template #topright> <template #topright>
<PlayButton <PlayButton
@ -58,9 +81,15 @@ const imageUrl = computed(() => cover.value?.urls.original
/> />
</template> </template>
<template #action> <template #image>
<Spacer :size="8" /> <img
<i class="bi bi-music-note-list" /> v-lazy="cover.urls.medium_square_crop"
:alt="artist.name"
class="channel-image"
/>
</template>
<template #footer>
<span v-if="artist.content_category === 'music'"> <span v-if="artist.content_category === 'music'">
{{ t('components.audio.artist.Card.meta.tracks', artist.tracks_count) }} {{ t('components.audio.artist.Card.meta.tracks', artist.tracks_count) }}
</span> </span>
@ -73,6 +102,7 @@ const imageUrl = computed(() => cover.value?.urls.original
:dropdown-only="true" :dropdown-only="true"
:is-playable="artist.is_playable" :is-playable="artist.is_playable"
:artist="artist" :artist="artist"
discrete
/> />
</template> </template>
@ -80,9 +110,15 @@ const imageUrl = computed(() => cover.value?.urls.original
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.channel-image {
border-radius: 50%;
width: 168px;
height: 168px;
margin: 16px;
}
.play-button { .play-button {
position: absolute; top: 16px;
top: 214px;
right: 16px; right: 16px;
} }
</style> </style>

View File

@ -22,7 +22,7 @@ const store = useStore()
const router = useRouter() const router = useRouter()
const imageUrl = computed(() => props.object.artist?.cover const imageUrl = computed(() => props.object.artist?.cover
? store.getters['instance/absoluteUrl'](props.object.artist.cover.urls.large_square_crop) ? store.getters['instance/absoluteUrl'](props.object.artist.cover.urls.medium_square_crop)
: null : null
) )
@ -46,10 +46,10 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
<template> <template>
<Card <Card
:title="object.artist?.name" :title="object.artist?.name"
:image="imageUrl"
:tags="object.artist?.tags ?? []" :tags="object.artist?.tags ?? []"
class="artist-card" class="artist-card"
:to="{name: 'channels.detail', params: {id: urlId}}" :to="{name: 'channels.detail', params: {id: urlId}}"
small
> >
<template #topright> <template #topright>
<PlayButton <PlayButton
@ -59,26 +59,35 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
/> />
</template> </template>
<template #default> <template #image>
<span <img
v-if="object.artist?.content_category === 'podcast'" v-if="imageUrl"
> v-lazy="imageUrl"
<i class="bi bi-music-note-list" /> :alt="object.artist?.name"
{{ t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }} class="channel-image"
</span> />
<span v-else>
<i class="bi bi-music-note-list" />
{{ t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
</span>
</template> </template>
<template #action> <template #default>
<Spacer :size="8"/>
</template>
<template #footer>
<time <time
:datetime="object.artist?.modification_date" :datetime="object.artist?.modification_date"
:title="updatedTitle" :title="updatedTitle"
> >
{{ updatedAgo }} {{ updatedAgo }}
</time> </time>
<i class="bi bi-dot" />
<span
v-if="object.artist?.content_category === 'podcast'"
>
{{ t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
</span>
<span v-else>
{{ t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
</span>
<Spacer h grow /> <Spacer h grow />
<PlayButton <PlayButton
:dropdown-only="true" :dropdown-only="true"
@ -86,15 +95,22 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
:artist="object.artist" :artist="object.artist"
:channel="object" :channel="object"
:account="object.attributed_to" :account="object.attributed_to"
discrete
/> />
</template> </template>
</Card> </Card>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.channel-image {
border-radius: 50%;
width: 168px;
height: 168px;
margin: 16px;
}
.play-button { .play-button {
position: absolute; top: 16px;
top: 214px;
right: 16px; right: 16px;
} }
</style> </style>

View File

@ -8,7 +8,10 @@ import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import ChannelCard from '~/components/audio/ChannelCard.vue' import ChannelCard from '~/components/audio/ChannelCard.vue'
import Layout from '~/components/ui/Layout.vue'
import Loader from '~/components/ui/Loader.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Spacer from '../ui/Spacer.vue'
interface Events { interface Events {
(e: 'fetched', channels: BackendResponse<Channel>): void (e: 'fetched', channels: BackendResponse<Channel>): void
@ -60,22 +63,16 @@ fetchData()
<template> <template>
<div> <div>
<slot /> <slot />
<div class="ui hidden divider" /> <Layout grid>
<div class="ui app-cards cards"> <Loader v-if="isLoading" />
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<channel-card <channel-card
v-for="object in channels" v-for="object in channels"
:key="object.uuid" :key="object.uuid"
:object="object" :object="object"
/> />
</div> </Layout>
<template v-if="nextPage"> <template v-if="nextPage">
<div class="ui hidden divider" /> <Spacer />
<Button <Button
v-if="nextPage" v-if="nextPage"
@click="fetchData(nextPage)" @click="fetchData(nextPage)"

View File

@ -101,13 +101,13 @@ const isOpen = ref(false)
<template> <template>
<Popover <Popover
v-if="split || (!discrete && !iconOnly && dropdownOnly)" v-if="split || (!iconOnly && dropdownOnly)"
v-model:open="isOpen" v-model:open="isOpen"
> >
<OptionsButton <OptionsButton
v-if="dropdownOnly" v-if="dropdownOnly"
@click="isOpen = !isOpen" @click="isOpen = !isOpen"
ghost :isGhost="discrete"
/> />
<Button <Button
v-else v-else

View File

@ -67,7 +67,7 @@ const url = computed(() => {
v-if="avatar" v-if="avatar"
:actor="actor" :actor="actor"
/> />
<slot>@{{ repr }}</slot> <slot>{{ repr }}</slot>
</span> </span>
</Link> </Link>
</template> </template>

View File

@ -34,33 +34,22 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${userColor.valu
:solid="!discrete" :solid="!discrete"
:round="!discrete" :round="!discrete"
> >
<span class="center"> <template v-if="avatar">
<template v-if="avatar"> <img
<img v-if="user.avatar && user.avatar.urls.medium_square_crop"
v-if="user.avatar && user.avatar.urls.medium_square_crop" v-lazy="store.getters['instance/absoluteUrl'](user.avatar.urls.medium_square_crop)"
v-lazy="store.getters['instance/absoluteUrl'](user.avatar.urls.medium_square_crop)" class="ui avatar tiny circular image"
class="ui avatar tiny circular image" alt=""
alt="" >
> <span
<span v-else
v-else :style="defaultAvatarStyle"
:style="defaultAvatarStyle" class="ui tiny avatar circular label"
class="ui tiny avatar circular label" >
> {{ user.username[0] }}
{{ user.username[0] }} </span>
</span> &nbsp;
&nbsp; </template>
</template> {{ t('components.common.UserLink.link.username', {username: user.username}) }}
{{ t('components.common.UserLink.link.username', {username: user.username}) }}
</span>
</Link> </Link>
</template> </template>
<style lang="scss" scoped>
a.username {
span.center {
display: flex;
align-items: center;
}
}
</style>

View File

@ -58,6 +58,7 @@ const goToPlaylist = () => {
<Card <Card
:title="playlist.name" :title="playlist.name"
:to="{ name: 'library.playlists.detail', params: { id: playlist.id } }" :to="{ name: 'library.playlists.detail', params: { id: playlist.id } }"
small
> >
<template #topright> <template #topright>
<PlayButton <PlayButton
@ -81,14 +82,16 @@ const goToPlaylist = () => {
<template #default> <template #default>
<div class="playlist-meta"> <div class="playlist-meta">
<span>{{ t('vui.by-user') }}</span>
<ActorLink <ActorLink
:actor="playlist.actor" :actor="playlist.actor"
:avatar="false"
discrete discrete
/> />
</div> </div>
</template> </template>
<template #action> <template #footer>
<div class="playlist-action"> <div class="playlist-action">
<span>{{ t('components.playlists.Card.meta.tracks', playlist.tracks_count) }}</span> <span>{{ t('components.playlists.Card.meta.tracks', playlist.tracks_count) }}</span>
<PlayButton <PlayButton
@ -117,14 +120,12 @@ const goToPlaylist = () => {
} }
.play-button { .play-button {
position: absolute; top: 16px;
top: 214px;
right: 16px; right: 16px;
} }
.playlist-meta { .playlist-meta {
display: flex; display: flex;
align-self: center;
} }
.playlist-action { .playlist-action {

View File

@ -81,9 +81,9 @@ const toggleRadio = () => {
<template> <template>
<Button <Button
:is-active="running" :is-active="running"
:primary="!playOnly" primary
:secondary="playOnly"
:round="playOnly" :round="playOnly"
class="play-button"
icon="bi-play-fill" icon="bi-play-fill"
:square="store.state.auth.authenticated && type === 'custom'" :square="store.state.auth.authenticated && type === 'custom'"
@click="toggleRadio" @click="toggleRadio"

View File

@ -38,7 +38,7 @@ const customRadioId = computed(() => props.customRadio?.id ?? null)
<template> <template>
<Card <Card
v-if="radio.id" v-if="radio.id"
large small
:title="radio.name" :title="radio.name"
:to="{name: 'library.radios.detail', params: {id: radio.id}}" :to="{name: 'library.radios.detail', params: {id: radio.id}}"
> >
@ -55,9 +55,10 @@ const customRadioId = computed(() => props.customRadio?.id ?? null)
<user-link <user-link
v-if="radio.user" v-if="radio.user"
:user="radio.user" :user="radio.user"
:avatar="false"
discrete discrete
class="left floated"
/> />
<Spacer />
<div <div
class="description" class="description"
:class="{expanded: isDescriptionExpanded}" :class="{expanded: isDescriptionExpanded}"
@ -65,10 +66,9 @@ const customRadioId = computed(() => props.customRadio?.id ?? null)
> >
{{ radio.description }} {{ radio.description }}
</div> </div>
<Spacer />
</template> </template>
<template #action> <template #footer>
<Button <Button
v-if="store.state.auth.authenticated && type === 'custom' && radio.user.id === store.state.auth.profile?.id" v-if="store.state.auth.authenticated && type === 'custom' && radio.user.id === store.state.auth.profile?.id"
primary primary
@ -81,7 +81,7 @@ const customRadioId = computed(() => props.customRadio?.id ?? null)
</Card> </Card>
<Card <Card
v-else v-else
large small
:title="radio.name" :title="radio.name"
> >
<template #topright> <template #topright>
@ -97,7 +97,9 @@ const customRadioId = computed(() => props.customRadio?.id ?? null)
v-if="radio.user" v-if="radio.user"
discrete discrete
:user="radio.user" :user="radio.user"
:avatar="false"
/> />
<Spacer />
<div <div
class="description" class="description"
:class="{expanded: isDescriptionExpanded}" :class="{expanded: isDescriptionExpanded}"
@ -108,7 +110,7 @@ const customRadioId = computed(() => props.customRadio?.id ?? null)
<Spacer /> <Spacer />
</template> </template>
<template #action> <template #footer>
<Button <Button
v-if="store.state.auth.authenticated && type === 'custom' && radio.user.id === store.state.auth.profile?.id" v-if="store.state.auth.authenticated && type === 'custom' && radio.user.id === store.state.auth.profile?.id"
primary primary
@ -123,8 +125,8 @@ const customRadioId = computed(() => props.customRadio?.id ?? null)
<style lang="scss" scoped> <style lang="scss" scoped>
a.username { .play-button {
display: flex; top: 16px;
align-self: center; right: 16px;
} }
</style> </style>

View File

@ -146,10 +146,6 @@ const attributes = computed(() =>
} }
} }
&:has(>.image) {
text-align: center;
}
>.image { >.image {
overflow: hidden; overflow: hidden;
border-radius: var(--fw-border-radius) var(--fw-border-radius) 0 0; border-radius: var(--fw-border-radius) var(--fw-border-radius) 0 0;
@ -225,7 +221,6 @@ const attributes = computed(() =>
>.tags { >.tags {
padding: 0 var(--fw-card-padding); padding: 0 var(--fw-card-padding);
margin-top: 8px; margin-top: 8px;
align-self: center;
} }
>.content { >.content {
@ -233,14 +228,16 @@ const attributes = computed(() =>
/* Consider making all line height, vertical paddings, margins and borders, /* Consider making all line height, vertical paddings, margins and borders,
a multiple of a global vertical rhythm so that side-by-side lines coincide */ a multiple of a global vertical rhythm so that side-by-side lines coincide */
line-height: 24px; line-height: 24px;
margin-top: 16px; margin-top: 8px;
} }
>.footer { >.footer {
padding: calc(var(--fw-card-padding) - 4px); padding: calc(var(--fw-card-padding) - 4px);
display: flex; display: flex;
align-items: center; gap: 4px;
align-items: end;
font-size: 0.8125rem; font-size: 0.8125rem;
margin-top: 16px;
>.options-button { >.options-button {
margin-left: auto; margin-left: auto;

View File

@ -3,18 +3,21 @@ import Button from '../Button.vue'
interface Props { interface Props {
isSquare?: boolean isSquare?: boolean
isGhost?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
isSquare: false isSquare: false,
isGhost: false
}) })
</script> </script>
<template> <template>
<Button <Button
icon="bi-three-dots-vertical" icon="bi-three-dots-vertical"
class="options-button" :class="['options-button', {'is-ghost': isGhost}]"
secondary :secondary="!isGhost"
:ghost="isGhost"
:round="!isSquare" :round="!isSquare"
/> />
</template> </template>

View File

@ -3,6 +3,11 @@
will-change: transform; will-change: transform;
transition: all .2s ease; transition: all .2s ease;
font-size: 0.6rem !important; font-size: 0.6rem !important;
padding: 0.6em !important; padding: 0.6em;
&.is-ghost {
position: absolute;
bottom: 0px;
right: 0px;
}
} }
} }