fix(front): consistent pixel perfect header with description modal on all detail pages

This commit is contained in:
ArneBo 2025-04-08 05:39:38 +02:00
parent a05e509d36
commit dcb664162c
18 changed files with 1223 additions and 885 deletions

View File

@ -66,5 +66,6 @@ const getRoute = (ac: ArtistCredit) => {
<style lang="scss" scoped>
a.username {
text-decoration: none;
height: 25px;
}
</style>

View File

@ -1,79 +1,78 @@
<script setup lang="ts">
import type { Album } from '~/types'
import { computed, ref } from 'vue'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import { momentFormat } from '~/utils/filters'
import defaultCover from '~/assets/audio/default-cover.png'
import PlayButton from '~/components/audio/PlayButton.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import { useRouter } from 'vue-router'
import Layout from '~/components/ui/Layout.vue'
import Card from '~/components/ui/Card.vue'
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
import Spacer from '~/components/ui/Spacer.vue'
const { t } = useI18n()
const store = useStore()
const router = useRouter()
import { type Album, type ArtistCredit } from '~/types'
interface Props {
serie: Album
}
const { t } = useI18n()
const props = defineProps<Props>()
const cover = computed(() => props.serie?.cover ?? null)
const { serie } = props
const artistCredit = serie.artist_credit || []
const store = useStore()
const imageUrl = computed(() => serie?.cover?.urls.original
? store.getters['instance/absoluteUrl'](serie.cover?.urls.medium_square_crop)
: defaultCover
)
</script>
<template>
<div class="channel-serie-card">
<div class="two-images">
<img
v-if="cover && cover.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
alt=""
class="channel-image"
@click="router.push({name: 'library.albums.detail', params: {id: serie.id}})"
<Card
:title="serie?.title"
:image="imageUrl"
:tags="serie?.tags"
:to="{name: 'library.albums.detail', params: {id: serie?.id}}"
small
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
@click="router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
<img
v-if="cover && cover.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
alt=""
class="channel-image"
@click="router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
@click="router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
</div>
<div class="content ellipsis">
<strong>
<router-link
class="discrete link"
:to="{name: 'library.albums.detail', params: {id: serie.id}}"
>
{{ serie.title }}
</router-link>
</strong>
<div class="description">
<span>
{{ t('components.audio.ChannelSerieCard.meta.episodes', serie.tracks_count) }}
</span>
</div>
</div>
<div class="controls">
<play-button
:icon-only="true"
:is-playable="true"
:button-classes="['ui', 'circular', 'vibrant', 'icon', 'button']"
<template #topright>
<PlayButton
icon-only
:is-playable="serie?.is_playable"
:album="serie"
/>
</div>
</div>
</template>
<template #footer>
<span v-if="serie?.release_date">
{{ momentFormat(new Date(serie?.release_date), 'Y') }}
</span>
<i class="bi bi-dot" />
<span>
{{ t('components.audio.album.Card.meta.tracks', serie?.tracks_count) }}
</span>
<Spacer
h
grow
/>
<PlayButton
:dropdown-only="true"
discrete
:is-playable="serie?.is_playable"
:album="serie"
/>
</template>
</Card>
</template>
<style lang="scss" scoped>
.play-button {
top: 16px;
right: 16px;
}
</style>

View File

@ -25,6 +25,7 @@ interface Props extends PlayOptionsProps {
iconOnly?: boolean
playing?: boolean
paused?: boolean
lowHeight?: boolean
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
isPlayable?: boolean
@ -56,7 +57,8 @@ const props = withDefaults(defineProps<Props>(), {
iconOnly: () => false,
isPlayable: () => false,
playing: () => false,
paused: () => false
paused: () => false,
lowHeight: () => false
})
// (1) Create a PlayButton
@ -125,6 +127,8 @@ const isOpen = ref(false)
:class="[...buttonClasses, 'play-button']"
:isloading="isLoading"
:dropdown-only="dropdownOnly"
:low-height="lowHeight || undefined"
style="align-self: start;"
@click.stop.prevent="replacePlay()"
@split-click="isOpen = !isOpen"
>
@ -238,6 +242,7 @@ const isOpen = ref(false)
:round="iconOnly"
:primary="iconOnly && !discrete"
:ghost="discrete"
:low-height="lowHeight || undefined"
@click.stop.prevent="replacePlay()"
>
<template v-if="!discrete && !iconOnly">

View File

@ -110,8 +110,8 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
{{ generateTrackCreditString(track) }}
<span class="middle middledot symbol" />
<human-duration
v-if="track.uploads[0] && track.uploads[0].duration"
:duration="track.uploads[0].duration"
v-if="track.uploads?.[0]?.duration"
:duration="track.uploads[0]?.duration"
/>
</p>
</div>

View File

@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
import axios from 'axios'
import clip from 'text-clipper'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue'
import Alert from '~/components/ui/Alert.vue'
@ -23,6 +24,7 @@ interface Props {
fetchHtml?: boolean
permissive?: boolean
truncateLength?: number
moreLink?: boolean
}
const { t } = useI18n()
@ -35,7 +37,8 @@ const props = withDefaults(defineProps<Props>(), {
canUpdate: true,
fetchHtml: false,
permissive: false,
truncateLength: 200
truncateLength: 200,
moreLink: true
})
const preview = ref('')
@ -89,34 +92,40 @@ const submit = async () => {
</script>
<template>
<template v-if="content && !isUpdating">
<Layout
v-if="content && !isUpdating"
flex
gap-4
>
<!-- Render the truncated or full description -->
<sanitized-html :html="html" />
<sanitized-html
:html="html"
:class="['description', isTruncated ? 'truncated' : '']"
/>
<!-- Display the `show more` / `show less` button -->
<template v-if="isTruncated">
<a
v-if="showMore === false"
v-if="showMore === false && props.moreLink !== false"
class="more"
style="align-self: end; color: var(--fw-primary);"
style="align-self: flex-end; color: var(--fw-primary);"
href=""
@click.stop.prevent="showMore = true"
>
{{ t('components.common.RenderedDescription.button.more') }}
</a>
<a
v-else
v-else-if="props.moreLink !== false"
class="more"
style="align-self: end; color: var(--fw-primary);"
style="align-self: center; color: var(--fw-primary);"
href=""
@click.stop.prevent="showMore = false"
>
{{ t('components.common.RenderedDescription.button.less') }}
</a>
</template>
</template>
</Layout>
<span v-else-if="!isUpdating">
{{ t('components.common.RenderedDescription.empty.noDescription') }}
</span>
@ -166,3 +175,19 @@ const submit = async () => {
</Button>
</form>
</template>
<style lang="scss" scoped>
.description {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
white-space: normal;
&.truncated {
-webkit-line-clamp: 1; /* Number of lines to show */
line-clamp: 1;
max-height: 72px;
flex-shrink: 1;
}
}
</style>

View File

@ -17,6 +17,7 @@ import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import PlayButton from '~/components/audio/PlayButton.vue'
import AlbumDropdown from './AlbumDropdown.vue'
import Layout from '~/components/ui/Layout.vue'
import Header from '~/components/ui/Header.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Loader from '~/components/ui/Loader.vue'
import Button from '~/components/ui/Button.vue'
@ -147,16 +148,16 @@ const remove = async () => {
</script>
<template>
<Layout
stack
main
>
<Loader
v-if="isLoading"
v-title="labels.title"
/>
<template v-if="object">
<Layout flex>
<Header
v-if="object"
:h1="object.title"
page-heading
>
<template #image>
<img
v-if="object.cover && object.cover.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.large_square_crop)"
@ -169,20 +170,20 @@ const remove = async () => {
class="channel-image"
src="../../assets/audio/default-cover.png"
>
<Layout
stack
no-gap
style="flex: 1;"
>
<h1 style="margin-top: 64px; margin-bottom: 8px;">
{{ object.title }}
</h1>
</template>
<artist-credit-label
v-if="artistCredit"
:artist-credit="artistCredit"
/>
<!-- Metadata: -->
<div class="meta">
<Layout
gap-4
class="meta"
>
<Layout
flex
gap-4
>
<template v-if="object.release_date">
{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }}
<i class="bi bi-dot" />
@ -204,25 +205,26 @@ const remove = async () => {
:duration="totalDuration"
/>
<!--TODO: License -->
</div>
<Layout flex>
<rendered-description
v-if="object.description"
:content="object.description"
:can-update="true"
/>
</Layout>
</Layout>
<RenderedDescription
v-if="object.description"
:content="{ html: object.description.html }"
:truncate-length="50"
/>
<Layout flex>
<PlayButton
v-if="object.tracks"
split
:tracks="object.tracks"
low-height
:is-playable="object.is_playable"
/>
<Button
v-if="object?.tracks?.length && object?.tracks?.length > 2"
primary
icon="bi-shuffle"
low-height
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()"
>
@ -236,6 +238,7 @@ const remove = async () => {
&& artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername
*/"
:is-loading="isLoading"
low-height
icon="bi-trash"
@confirm="remove()"
>
@ -247,10 +250,12 @@ const remove = async () => {
/>
<TrackFavoriteIcon
v-if="store.state.auth.authenticated"
square-small
:album="object"
/>
<TrackPlaylistIcon
v-if="store.state.auth.authenticated"
square-small
:album="object"
/>
<!-- TODO: Share Button -->
@ -265,8 +270,7 @@ const remove = async () => {
@remove="remove"
/>
</Layout>
</Layout>
</Layout>
</Header>
<div style="flex 1;">
<router-view
@ -282,12 +286,16 @@ const remove = async () => {
@libraries-loaded="libraries = $event"
/>
</div>
</template>
</Layout>
</template>
<style scopen>
<style scoped lang="scss">
.meta {
line-height: 48px;
font-size: 15px;
@include light-theme {
color: var(--fw-gray-700);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
</style>

View File

@ -16,10 +16,6 @@ import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
interface Events {
(e: 'remove'): void
}
interface Props {
isLoading: boolean
artistCredit: ArtistCredit[]
@ -28,10 +24,10 @@ interface Props {
isAlbum: boolean
isChannel: boolean
isSerie: boolean
}
const store = useStore()
const emit = defineEmits<Events>()
const props = defineProps<Props>()
const { report, getReportableObjects } = useReport()
@ -75,7 +71,7 @@ const open = ref(false)
<template #default="{ toggleOpen }">
<OptionsButton
:title="labels.more"
is-square
is-square-small
@click="toggleOpen()"
/>
</template>

View File

@ -11,11 +11,14 @@ import { useStore } from '~/store'
import axios from 'axios'
import useReport from '~/composables/moderation/useReport'
import useLogger from '~/composables/useLogger'
import { useModal } from '~/ui/composables/useModal.ts'
import HumanDuration from '~/components/common/HumanDuration.vue'
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import Loader from '~/components/ui/Loader.vue'
import Header from '~/components/ui/Header.vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
import PlayButton from '~/components/audio/PlayButton.vue'
import RadioButton from '~/components/radios/Button.vue'
@ -24,6 +27,7 @@ import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import Layout from '~/components/ui/Layout.vue'
import Modal from '~/components/ui/Modal.vue'
import Spacer from '~/components/ui/Spacer.vue'
import RenderedDescription from '../common/RenderedDescription.vue'
interface Props {
id: number | string
@ -125,33 +129,29 @@ const fetchData = async () => {
const totalDuration = computed(() => sum((tracks.value ?? []).map(track => track.uploads[0]?.duration ?? 0)))
watch(() => props.id, fetchData, { immediate: true })
const isOpen = useModal('artist-description').isOpen
</script>
<template>
<Layout
v-title="labels.title"
stack
main
>
<Loader v-if="isLoading" />
<template v-if="object && !isLoading">
<Layout flex>
<Header
v-if="object && !isLoading"
v-title="labels.title"
:h1="object.name"
page-heading
>
<template #image>
<img
v-lazy="cover.urls.large_square_crop"
:alt="object.name"
class="channel-image"
>
<Layout
stack
style="flex: 1; gap: 8px;"
>
<h1 style="margin-top: 64px; margin-bottom: 8px;">
{{ object.name }}
</h1>
</template>
<Layout
flex
class="meta"
style="gap: 0;"
no-gap
>
<div
v-if="albums"
@ -167,23 +167,64 @@ watch(() => props.id, fetchData, { immediate: true })
/>
</div>
</Layout>
<Spacer />
<Layout
flex
gap-4
>
<RenderedDescription
v-if="object.description"
class="description"
:content="{ ...object.description, text: object.description.text ?? undefined }"
:truncate-length="100"
:more-link="false"
/>
<Spacer grow />
<Link
v-if="object.description"
:to="useModal('artist-description').to"
style="color: var(--fw-primary); text-decoration: underline;"
thin-font
force-underline
>
{{ t('components.common.RenderedDescription.button.more') }}
</Link>
</Layout>
<Modal
v-if="object.description"
v-model="isOpen"
:title="object.name"
>
<img
v-if="object.cover"
v-lazy="object.cover.urls.original"
:alt="object.name"
style="object-fit: cover; width: 100%; height: 100%;"
>
<sanitized-html
v-if="object.description"
:html="object.description.html"
/>
</Modal>
<Layout flex>
<PlayButton
:is-playable="isPlayable"
split
:artist="object"
low-height
>
{{ t('components.library.ArtistBase.button.play') }}
</PlayButton>
<radio-button
type="artist"
:object-id="object.id"
low-height
/>
<Spacer grow />
<Popover>
<template #default="{ toggleOpen }">
<OptionsButton
is-square-small
@click="toggleOpen"
/>
</template>
@ -275,8 +316,6 @@ watch(() => props.id, fetchData, { immediate: true })
</template>
</Popover>
</Layout>
</Layout>
</Layout>
<Modal
v-if="publicLibraries.length > 0"
@ -293,6 +332,7 @@ watch(() => props.id, fetchData, { immediate: true })
</Button>
</template>
</Modal>
</Header>
<hr>
<router-view
:key="route.fullPath"
@ -305,12 +345,30 @@ watch(() => props.id, fetchData, { immediate: true })
object-type="artist"
@libraries-loaded="libraries = $event"
/>
</template>
</Layout>
</template>
<style scoped>
<style scoped lang="scss">
.channel-image {
border-radius: 50%;
}
.meta {
font-size: 15px;
@include light-theme {
color: var(--fw-gray-700);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
.description {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
white-space: normal;
-webkit-line-clamp: 1; /* Number of lines to show */
line-clamp: 1;
}
</style>

View File

@ -242,8 +242,8 @@ const trackDetails: {
<style lang="scss">
.channel-image {
width: 300px;
height: 300px;
width: 200px;
height: 200px;
border: none;
}

View File

@ -9,6 +9,7 @@ import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler'
import OptionsButton from '~/components/ui/button/Options.vue'
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import Modal from '~/components/ui/Modal.vue'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
@ -104,7 +105,10 @@ const showDeleteModal = ref(false)
<span>
<Popover v-model="open">
<template #default="{ toggleOpen }">
<OptionsButton @click="toggleOpen" />
<OptionsButton
is-square-small
@click="toggleOpen"
/>
</template>
<template #items>
<PopoverItem

View File

@ -15,6 +15,7 @@ const props = defineProps<{
alignLeft?: boolean
action?: { text: string } & (ComponentProps<typeof Link> | ComponentProps<typeof Button>)
icon?: string
noGap?: false
} & {
[H in `h${ '1' | '2' | '3' | '4' | '5' | '6' }`]? : string
} & {
@ -35,7 +36,8 @@ const props = defineProps<{
</div>
<Layout
stack
gap-8
:gap-8="!(props.noGap as boolean)"
:no-gap="props.noGap"
style="flex-grow: 1;"
>
<Layout
@ -46,7 +48,7 @@ const props = defineProps<{
<!-- Set distance between baseline and previous row -->
<Spacer
v
:size="68"
:size="53"
style="align-self: baseline;"
/>
<div
@ -66,7 +68,7 @@ const props = defineProps<{
v-bind="props"
style="
align-self: baseline;
padding: 0 0 24px 0;
padding: 0 0 0 0;
margin: 0;
"
/>
@ -93,6 +95,7 @@ const props = defineProps<{
<slot />
</Layout>
</Layout>
<Spacer />
</template>
<style module lang="scss">

View File

@ -4,6 +4,7 @@ import Button from '../Button.vue'
defineProps<{
isSquare?: boolean
isGhost?: boolean
isSquareSmall?: boolean
}>()
</script>
@ -14,7 +15,8 @@ defineProps<{
:class="['options-button', {'is-ghost': isGhost}]"
:secondary="!isGhost"
:ghost="isGhost"
:round="!isSquare"
:round="!isSquare && !isSquareSmall"
:square-small="isSquareSmall"
/>
</template>

View File

@ -2824,6 +2824,40 @@
"tracks": "No tracks | {n} track | {n} tracks"
}
},
"Detail": {
"button": {
"cancel": "Cancel",
"confirm": "Delete playlist",
"delete": "Delete",
"edit": "Edit",
"embed": "Embed",
"playAll": "Play all",
"stopEdit": "Stop Editing"
},
"empty": {
"noTracks": "There are no tracks in this playlist yet"
},
"header": {
"tracks": "Tracks"
},
"meta": {
"attribution": "by",
"tracks": "Playlist containing {n} track, by {username} | Playlist containing {n} tracks, by {username}",
"updated": "updated"
},
"modal": {
"delete": {
"content": {
"warning": "This will completely delete this playlist and cannot be undone."
},
"header": "Do you want to delete the playlist {playlist}?"
},
"embed": {
"header": "Embed this playlist on your website"
}
},
"title": "Playlist"
},
"Editor": {
"button": {
"addDuplicate": "Add anyway",
@ -2877,6 +2911,50 @@
"name": "My awesome playlist"
}
},
"List": {
"button": {
"create": "Create a playlist",
"manage": "Manage your playlists",
"search": "Search"
},
"empty": {
"noResults": "No results matching your query"
},
"header": {
"browse": "Browsing playlists",
"playlists": "Playlists"
},
"label": {
"search": "Search"
},
"ordering": {
"direction": {
"ascending": "Ascending",
"descending": "Descending",
"label": "Order"
},
"label": "Ordering"
},
"pagination": {
"results": "Results per page"
},
"placeholder": {
"search": "Enter playlist name…"
}
},
"PlaylistDropdown": {
"button": {
"import": {
"header": "Rebuild playlist",
"description": "This will update the playlist with the content of the xspf file. Existing playlist tracks will be deleted"
},
"export": {
"header": "Download playlist",
"description": "This will provide an xspf file with the playlist data"
}
},
"more": "More"
},
"PlaylistModal": {
"button": {
"addDuplicate": "Add anyway",

View File

@ -2925,19 +2925,6 @@
"placeholder": {
"noPlaylists": "No playlists have been created yet"
}
},
"PlaylistDropdown": {
"button": {
"import": {
"header": "Rebuild playlist",
"description": "This will update the playlist with the content of the xspf file. Existing playlist tracks will be deleted"
},
"export": {
"header": "Download playlist",
"description": "This will provide an xspf file with the playlist data"
}
},
"more": "More"
}
},
"radios": {
@ -4581,6 +4568,12 @@
}
},
"playlists": {
"Card": {
"title": "Updated on {date}",
"meta": {
"tracks": "No tracks | {n} track | {n} tracks"
}
},
"Detail": {
"button": {
"cancel": "Cancel",
@ -4615,6 +4608,59 @@
},
"title": "Playlist"
},
"Editor": {
"button": {
"addDuplicate": "Add anyway",
"clear": "Clear playlist",
"copy": "Copy the current queue to this playlist",
"insertFromQueue": "Insert from queue ({n} track) | Insert from queue ({n} tracks"
},
"error": {
"sync": "An error occurred while saving your changes"
},
"header": {
"editor": "Playlist editor"
},
"help": {
"reorder": "Drag and drop rows to reorder tracks in the playlist"
},
"loading": {
"sync": "Syncing changes to server…"
},
"message": {
"sync": "Changes synced with server"
},
"modal": {
"clearPlaylist": {
"content": {
"warning": "This will remove all tracks from this playlist and cannot be undone."
},
"header": "Do you want to clear the playlist \"{playlist}\"?"
}
},
"warning": {
"duplicate": "Some tracks in your queue are already in this playlist:"
}
},
"Form": {
"button": {
"create": "Create playlist",
"update": "Update playlist"
},
"header": {
"createFailure": "The playlist could not be created",
"createPlaylist": "Create a new playlist",
"createSuccess": "Playlist created",
"updateSuccess": "Playlist updated"
},
"label": {
"name": "Playlist name",
"visibility": "Playlist visibility"
},
"placeholder": {
"name": "My awesome playlist"
}
},
"List": {
"button": {
"create": "Create a playlist",
@ -4645,6 +4691,72 @@
"placeholder": {
"search": "Enter playlist name…"
}
},
"PlaylistModal": {
"button": {
"addDuplicate": "Add anyway",
"addToPlaylist": "Add to this playlist",
"addTrack": "Add track",
"cancel": "Cancel",
"edit": "Edit"
},
"empty": {
"noPlaylists": "No playlists have been created yet"
},
"header": {
"addFailure": "The track can't be added to a playlist",
"addToPlaylist": "Add to playlist",
"available": "Available playlists",
"manage": "Manage playlists",
"noResults": "No results matching your filter",
"track": "{title}, by {artist}"
},
"label": {
"filter": "Filter"
},
"placeholder": {
"filterPlaylist": "Enter playlist name"
},
"table": {
"edit": {
"header": {
"edit": "Edit",
"lastModification": "Last modification",
"name": "Name",
"tracks": "Tracks"
}
}
},
"warning": {
"duplicate": "{ 0 } is already in { 1 }."
}
},
"TrackPlaylistIcon": {
"button": {
"add": "Add to playlist…"
}
},
"Widget": {
"button": {
"create": "Create playlist",
"more": "Show more"
},
"placeholder": {
"noPlaylists": "No playlists have been created yet"
}
},
"PlaylistDropdown": {
"button": {
"import": {
"header": "Rebuild playlist",
"description": "This will update the playlist with the content of the xspf file. Existing playlist tracks will be deleted"
},
"export": {
"header": "Download playlist",
"description": "This will provide an xspf file with the playlist data"
}
},
"more": "More"
}
},
"radios": {

View File

@ -113,8 +113,8 @@
}
}
.channel-image {
width: 300px;
height: 300px;
width: 200px;
height: 200px;
&.large {
width: 8em !important;

View File

@ -9,16 +9,21 @@ import { useStore } from '~/store'
import { hashCode, intToRGB } from '~/utils/color'
import UserFollowButton from '~/components/federation/UserFollowButton.vue'
import { useModal } from '~/ui/composables/useModal.ts'
import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler'
import RenderedDescription from '~/components/common/RenderedDescription.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Header from '~/components/ui/Header.vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import Nav from '~/components/ui/Nav.vue'
import Alert from '~/components/ui/Alert.vue'
import Modal from '~/components/ui/Modal.vue'
interface Events {
(e: 'updated', value: components['schemas']['FullActor']): void
@ -96,6 +101,8 @@ const tabs = ref([{
}]
: []
)])
const isOpen = useModal('artist-description').isOpen
</script>
<template>
@ -103,6 +110,7 @@ const tabs = ref([{
v-title="labels.usernameProfile"
stack
main
no-gap
>
<!-- TODO: Translate Edit Link -->
<!-- TODO: `yarn lint:tsc` doesn't understand the `Prop` type for `Header` while the language server does. It may be a question of typescript version... Investigate and fix! -->
@ -118,9 +126,11 @@ const tabs = ref([{
// @ts-ignore
solid: true,
// @ts-ignore
icon: 'bi-pencil-fill'
icon: 'bi-pencil-fill',
// @ts-ignore
lowHeight: true
}"
style="margin-top: 58px;"
no-gap
page-heading
>
<template #image>
@ -148,6 +158,7 @@ const tabs = ref([{
:title="t('components.common.CopyInput.button.copy')"
ghost
secondary
low-height
@click="copy(fullUsername)"
/>
</span>
@ -174,19 +185,43 @@ const tabs = ref([{
flex
no-gap
>
<!-- TODO: Fix error with `$event` not being the right type -->
<!-- @vue-ignore -->
<RenderedDescription
:content="{ html: object?.summary?.html || '' }"
:field-name="'summary'"
:update-url="`users/${store.state.auth.username}/`"
:can-update="store.state.auth.authenticated && object?.full_username === store.state.auth.fullUsername"
v-if="object?.summary"
class="description"
:content="{ html: object?.summary.html || '' }"
:truncate-length="100"
@updated="emit('updated', $event)"
:more-link="false"
/>
<Spacer grow />
<Link
v-if="object?.summary"
:to="useModal('artist-description').to"
style="color: var(--fw-primary); text-decoration: underline;"
thin-font
force-underline
>
{{ t('components.common.RenderedDescription.button.more') }}
</Link>
</Layout>
<Modal
v-if="object?.summary"
v-model="isOpen"
:title="object?.name"
>
<img
v-if="object?.user.avatar"
v-lazy="object?.user.avatar.urls.original"
:alt="object?.name"
style="object-fit: cover; width: 100%; height: 100%;"
>
<sanitized-html
v-if="object?.summary"
:html="object?.summary.html"
/>
</Modal>
<UserFollowButton
v-if="store.state.auth.authenticated && object && object.full_username !== store.state.auth.fullUsername"
low-height
:actor="object"
/>
</Header>
@ -201,8 +236,8 @@ const tabs = ref([{
<style scoped lang="scss">
img.avatar {
width: 300px;
height: 300px;
width: 200px;
height: 200px;
border-radius: 50%;
}
@ -215,8 +250,8 @@ const tabs = ref([{
align-content: center;
background-color: var(--fw-gray-500);
border-radius: 50%;
width: 300px;
height: 300px;
width: 200px;
height: 200px;
}
h1 {

View File

@ -22,13 +22,14 @@ import TagsList from '~/components/tags/List.vue'
import RadioButton from '~/components/radios/Button.vue'
import Loader from '~/components/ui/Loader.vue'
import Layout from '~/components/ui/Layout.vue'
import Header from '~/components/ui/Header.vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import Nav from '~/components/ui/Nav.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Modal from '~/components/ui/Modal.vue'
@ -166,14 +167,17 @@ const tabs = ref([
<Layout
v-title="labels.title"
stack
no-gap
main
>
<Loader v-if="isLoading" />
<template v-if="object && !isLoading">
<section
<Header
v-if="object && !isLoading"
v-title="object.artist?.name"
:h1="object.artist?.name"
page-heading
>
<Layout flex>
<template #image>
<img
v-if="object.artist?.cover"
alt=""
@ -185,13 +189,7 @@ const tabs = ref([
class="bi bi-person-circle"
style="font-size: 300px; margin-top: -32px;"
/>
<Layout
stack
style="flex: 1; gap: 16px;"
>
<h1 style="margin-top: 64px; margin-bottom: 8px;">
{{ object.artist?.name }}
</h1>
</template>
<Layout
stack
class="meta"
@ -259,7 +257,7 @@ const tabs = ref([
</span>
<Spacer
h
:size="4"
:size="8"
/>
<ActorLink
v-if="object.actor"
@ -284,6 +282,7 @@ const tabs = ref([
v-if="isOwner"
solid
primary
low-height
icon="bi-upload"
:to="useModal('upload').to"
>
@ -292,6 +291,7 @@ const tabs = ref([
<PlayButton
:is-playable="isPlayable"
split
low-height
class="vibrant"
:artist="object.artist"
>
@ -300,11 +300,13 @@ const tabs = ref([
<RadioButton
type="artist"
:object-id="object.artist.id"
low-height
/>
<Popover>
<template #default="{ toggleOpen }">
<OptionsButton
is-square-small
@click="toggleOpen"
/>
</template>
@ -378,6 +380,7 @@ const tabs = ref([
/>
<subscribe-button
v-if="store.state.auth.authenticated && object?.attributed_to.full_username !== store.state.auth.fullUsername"
low-height
:channel="object"
@subscribed="updateSubscriptionCount(1)"
@unsubscribed="updateSubscriptionCount(-1)"
@ -426,6 +429,7 @@ const tabs = ref([
<Button
primary
autofocus
low-height
:is-loading="edit.loading"
:disabled="!edit.submittable"
@click.stop="editForm?.submit"
@ -437,6 +441,7 @@ const tabs = ref([
<Button
secondary
icon="bi-rss"
square-small
@click.stop.prevent="showSubscribeModal = true"
/>
<Modal
@ -474,12 +479,11 @@ const tabs = ref([
</div>
</Modal>
</Layout>
</Layout>
</Layout>
</Header>
<hr>
<TagsList
v-if="object.artist?.tags && object.artist?.tags.length > 0"
:tags="object.artist.tags"
v-if="object?.artist?.tags && object?.artist?.tags.length > 0"
:tags="object?.artist.tags"
:limit="5"
:show-more="true"
/>
@ -490,21 +494,24 @@ const tabs = ref([
:object="object"
@tracks-loaded="totalTracks = $event"
/>
</section>
</template>
</Layout>
</template>
<style scoped>
<style scoped lang="scss">
.channel-image {
border-radius: 50%;
}
.huge {
width: 300px;
height: 300px;
width: 200px;
height: 200px;
}
.meta {
line-height: 24px;
font-size: 15px;
@include light-theme {
color: var(--fw-gray-700);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
</style>

View File

@ -8,7 +8,6 @@ import { useStore } from '~/store'
import axios from 'axios'
import defaultCover from '~/assets/audio/default-cover.png'
import ActorLink from '~/components/common/ActorLink.vue'
import PlaylistEditor from '~/components/playlists/Editor.vue'
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import HumanDate from '~/components/common/HumanDate.vue'
@ -134,11 +133,6 @@ const shuffle = () => {}
</script>
<template>
<Layout
v-title="playlist?.name"
stack
main
>
<Loader
v-if="isLoading"
v-title="labels.playlist"
@ -159,31 +153,35 @@ const shuffle = () => {}
>
</div>
</template>
<div class="meta">
<Layout
gap-4
class="meta"
>
<Layout
flex
gap-4
>
{{ playlist.tracks_count }}
{{ t('views.playlists.Detail.header.tracks') }}
<i class="bi bi-dot" />
<Duration :seconds="playlist.duration" />
</div>
</Layout>
<Layout
flex
gap-8
gap-4
>
{{ t('views.playlists.Detail.meta.attribution') }}
<ActorLink
:actor="playlist.actor"
:avatar="false"
:discrete="true"
/>
{{ playlist.actor.full_username }}
<i class="bi bi-dot" />
{{ t('views.playlists.Detail.meta.updated') }}
<HumanDate
:date="playlist.modification_date"
/>
</Layout>
</Layout>
<RenderedDescription
:content="{ html: playlist.description }"
:truncate-length="200"
:truncate-length="100"
:show-more="true"
/>
<Layout
@ -192,7 +190,8 @@ const shuffle = () => {}
>
<PlayButton
split
:is-playable="playlist.is_playable"
low-height
:is-playable="true"
:tracks="tracks"
>
{{ t('views.playlists.Detail.button.playAll') }}
@ -201,6 +200,7 @@ const shuffle = () => {}
v-if="playlist.tracks_count > 1"
primary
icon="bi-shuffle"
low-height
:aria-label="t('components.audio.Player.label.shuffleQueue')"
@click.prevent.stop="shuffle()"
>
@ -209,6 +209,7 @@ const shuffle = () => {}
<Button
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
secondary
low-height
icon="bi-pencil"
@click="edit = !edit"
>
@ -287,7 +288,6 @@ const shuffle = () => {}
</Button>
</template>
</Modal>
</Layout>
</template>
<style lang="scss" scoped>
@ -297,8 +297,8 @@ const shuffle = () => {}
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 2px;
width: 300px;
height: 300px;
width: 200px;
height: 200px;
}
.playlist-grid img {
@ -307,9 +307,14 @@ const shuffle = () => {}
object-fit: cover;
}
.playlist-meta {
display: flex;
align-items: center;
.meta {
font-size: 15px;
@include light-theme {
color: var(--fw-gray-700);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
.playlist-action {