Files
AlgerMusicPlayer/src/renderer/views/music/MusicListPage.vue
T

771 lines
26 KiB
Vue
Raw Normal View History

<template>
<div class="music-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
<n-scrollbar class="h-full" @scroll="handleScroll">
<div class="music-list-content pb-32">
<!-- Hero Section Action Bar -->
<n-spin :show="loading">
<!-- Hero Section -->
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
<!-- Background Image with Blur -->
<div class="hero-bg absolute inset-0 -top-20">
<div
class="absolute inset-0 bg-cover bg-center scale-110 blur-3xl opacity-40 dark:opacity-30"
:style="{
backgroundImage: `url(${getImgUrl(getCoverImgUrl, '800y800')})`
}"
></div>
<div
class="absolute inset-0 bg-gradient-to-b from-transparent via-white/80 to-white dark:via-black/80 dark:to-black"
></div>
</div>
<!-- Hero Content -->
<div class="hero-content relative z-10 px-4 md:px-8 pt-4 md:pt-10 pb-8">
<div class="flex flex-col md:flex-row gap-8 md:gap-12 items-center md:items-end">
<!-- Playlist Cover -->
<div class="cover-wrapper relative group">
<div
class="cover-glow absolute -inset-2 rounded-2xl bg-gradient-to-br from-primary/30 via-primary/10 to-transparent blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"
></div>
<div
class="cover-container relative w-48 h-48 md:w-64 md:h-64 rounded-2xl overflow-hidden shadow-2xl ring-4 ring-white/50 dark:ring-neutral-800/50"
>
<n-image
:src="getImgUrl(getCoverImgUrl, '500y500')"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
preview-disabled
/>
<!-- Play overlay on cover -->
<div
class="absolute inset-0 flex items-center justify-center bg-transparent group-hover:bg-black/30 transition-all duration-300 cursor-pointer"
@click="handlePlayAll"
>
<div
class="play-icon w-16 h-16 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl hover:scale-110 active:scale-95"
>
<i class="ri-play-fill text-3xl text-neutral-900 ml-1" />
</div>
</div>
</div>
</div>
<!-- Playlist Info -->
<div class="playlist-info flex-1 text-center md:text-left">
<div class="playlist-badge mb-3">
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 dark:bg-primary/20 text-primary text-xs font-semibold uppercase tracking-wider"
>
{{ isAlbum ? 'Album' : 'Playlist' }}
</span>
</div>
<h1
class="playlist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight mb-4"
>
{{ name }}
</h1>
<!-- Meta Info -->
<div
class="flex flex-wrap items-center justify-center md:justify-start gap-4 mb-6"
>
<div v-if="isAlbum && listInfo?.artist" class="flex items-center gap-2">
<n-avatar
round
:size="28"
:src="getImgUrl(listInfo.artist.picUrl, '50y50')"
/>
<span
class="text-sm font-semibold text-neutral-700 dark:text-neutral-200 hover:text-primary cursor-pointer transition-colors"
>{{ listInfo.artist.name }}</span
>
</div>
<div v-else-if="!isAlbum && listInfo?.creator" class="flex items-center gap-2">
<n-avatar
round
:size="28"
:src="getImgUrl(listInfo.creator.avatarUrl, '50y50')"
/>
<span class="text-sm font-semibold text-neutral-700 dark:text-neutral-200">{{
listInfo.creator.nickname
}}</span>
</div>
<div class="h-1 w-1 rounded-full bg-neutral-300 dark:bg-neutral-700"></div>
<span class="text-sm text-neutral-500 dark:text-neutral-400">
{{ t('player.songNum', { num: total }) }}
</span>
</div>
<p
v-if="listInfo?.description"
class="text-sm md:text-base text-neutral-500 dark:text-neutral-400 line-clamp-2 leading-relaxed max-w-3xl"
>
{{ listInfo.description }}
</p>
</div>
</div>
</div>
</section>
</n-spin>
<!-- Action Bar (Sticky) -->
<section
v-if="songList.length > 0"
class="action-bar sticky top-0 z-20 px-4 md:px-8 py-3 md:py-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border-b border-neutral-100 dark:border-neutral-800/50"
>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<!-- Play All Button -->
<button
class="play-all-btn flex items-center gap-2 px-6 py-2.5 rounded-full bg-primary hover:bg-primary/90 text-white font-semibold text-sm transition-all duration-200 hover:scale-105 active:scale-95 shadow-lg shadow-primary/25"
@click="handlePlayAll"
>
<i class="ri-play-circle-line text-lg" />
<span>{{ t('comp.musicList.playAll') }}</span>
</button>
<!-- Collect Button -->
<button
v-if="canCollect"
class="action-btn-pill flex items-center gap-2 px-6 py-2.5 rounded-full font-semibold text-sm transition-all duration-200 hover:scale-105 active:scale-95 shadow-sm border"
:class="
isCollected
? 'bg-neutral-100 dark:bg-neutral-800 text-red-500 border-neutral-200 dark:border-neutral-700'
: 'bg-neutral-50 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 border-neutral-200 dark:border-neutral-800'
"
@click="toggleCollect"
>
<i :class="isCollected ? 'ri-heart-fill' : 'ri-heart-line'" class="text-lg" />
<span>{{
isCollected ? t('comp.musicList.cancelCollect') : t('comp.musicList.collect')
}}</span>
</button>
<!-- Batch Actions -->
<div
v-if="filteredSongs.length > 0 && isElectron"
class="h-8 w-[1px] bg-neutral-200 dark:bg-neutral-800 mx-1 hidden md:block"
></div>
<button
v-if="!isSelecting && isElectron"
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
@click="startSelect"
>
<i class="ri-checkbox-multiple-line text-lg" />
</button>
<div
v-if="isSelecting"
class="flex items-center gap-2 animate-in fade-in slide-in-from-left-2"
>
<n-checkbox
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@update:checked="handleSelectAll"
>
{{ t('common.selectAll') }}
</n-checkbox>
<button
class="px-4 py-1.5 rounded-full bg-primary/10 text-primary text-xs font-bold hover:bg-primary/20 transition-all"
:disabled="selectedSongs.length === 0 || isDownloading"
@click="handleBatchDownload"
>
<i class="ri-download-line mr-1" />
{{ t('favorite.download', { count: selectedSongs.length }) }}
</button>
<button
class="text-xs text-neutral-400 hover:text-neutral-600"
@click="cancelSelect"
>
{{ t('common.cancel') }}
</button>
</div>
</div>
2025-10-22 21:52:22 +08:00
<!-- Right Tools -->
<div class="flex items-center gap-3">
<!-- Search within list -->
<div class="relative group hidden sm:block">
<n-input
v-model:value="searchKeyword"
:placeholder="t('comp.musicList.searchSongs')"
round
clearable
size="small"
class="w-48 focus:w-64 transition-all duration-300 !bg-neutral-100 dark:!bg-neutral-900 border-none"
>
<template #prefix>
<i class="ri-search-line text-neutral-400"></i>
</template>
</n-input>
</div>
<!-- Layout Toggle -->
<button
v-if="!isMobile"
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
@click="toggleLayout"
>
<i :class="isCompactLayout ? 'ri-list-check-2' : 'ri-grid-line'" class="text-lg" />
</button>
</div>
</div>
</section>
<!-- List Content -->
<section class="song-list-section px-4 md:px-8 mt-6">
<n-spin :show="loadingList">
<div
v-if="filteredSongs.length === 0 && searchKeyword"
class="empty-state py-20 text-center text-neutral-400"
>
<i class="ri-search-line text-4xl mb-4 opacity-20" />
<p>{{ t('comp.musicList.noSearchResults') }}</p>
</div>
<div v-else class="song-list-container">
<div
v-for="(item, index) in filteredSongs"
:key="item.id"
class="mb-2 animate-item"
:style="{ animationDelay: calculateAnimationDelay(index % 20, 0.03) }"
>
<song-item
:index="index"
:compact="isCompactLayout"
:item="formatSong(item)"
:can-remove="canRemove"
:selectable="isSelecting"
:selected="selectedSongs.includes(item.id as number)"
@play="handlePlayItem(item)"
@remove-song="handleRemoveSong"
@select="(id, selected) => handleSelect(id, selected)"
/>
</div>
</div>
</n-spin>
</section>
</div>
</n-scrollbar>
<play-bottom />
</div>
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui';
import PinyinMatch from 'pinyin-match';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { getAlbum, getListDetail } from '@/api/list';
2025-10-22 21:50:20 +08:00
import {
getMusicDetail,
subscribeAlbum,
subscribePlaylist,
updatePlaylistTracks
} from '@/api/music';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
import { useAlbumHistory } from '@/hooks/AlbumHistoryHook';
import { usePlaylistHistory } from '@/hooks/PlaylistHistoryHook';
import { useDownload } from '@/hooks/useDownload';
2025-10-22 21:50:20 +08:00
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
2025-08-07 22:57:17 +08:00
import { SongResult } from '@/types/music';
import { calculateAnimationDelay, getImgUrl, isElectron, isMobile } from '@/utils';
2025-09-14 00:34:35 +08:00
import { getLoginErrorMessage, hasPermission } from '@/utils/auth';
defineOptions({
name: 'MusicList'
});
const { t } = useI18n();
const route = useRoute();
const playerStore = usePlayerStore();
const musicStore = useMusicStore();
2025-09-10 00:29:50 +08:00
const recommendStore = useRecommendStore();
2025-10-22 21:50:20 +08:00
const userStore = useUserStore();
const message = useMessage();
const { addPlaylist } = usePlaylistHistory();
const { addAlbum } = useAlbumHistory();
const loading = ref(false);
const fetchData = async () => {
const id = route.params.id;
const type = route.query.type;
if (!id || type === 'dailyRecommend') return;
// 检查是否需要加载数据
if (
musicStore.currentListInfo?.id?.toString() === id.toString() &&
musicStore.currentMusicList &&
musicStore.currentMusicList.length > 0
) {
return;
}
loading.value = true;
try {
let data: any;
if (type === 'album') {
const res = await getAlbum(Number(id));
data = res.data;
if (data.code === 200) {
musicStore.setCurrentMusicList(
data.songs,
data.album.name,
{ ...data.album, picUrl: data.album.picUrl },
false
);
} else {
message.error(t('common.loadFailed'));
}
} else if (type === 'playlist') {
const res = await getListDetail(id.toString());
data = res.data;
if (data.code === 200) {
const playlist = data.playlist;
musicStore.setCurrentMusicList(
playlist.tracks || [],
playlist.name,
playlist,
playlist.creator?.userId === userStore.user?.userId
);
} else {
message.error(t('common.loadFailed'));
}
}
} catch (error) {
console.error('加载列表数据失败:', error);
message.error(t('common.loadFailed'));
} finally {
loading.value = false;
}
};
watch(
() => route.fullPath,
() => {
fetchData();
},
{ immediate: true }
);
2025-09-10 00:29:50 +08:00
const isDailyRecommend = computed(() => route.query.type === 'dailyRecommend');
2025-10-22 21:50:20 +08:00
const isAlbum = computed(() => route.query.type === 'album');
2025-09-10 00:29:50 +08:00
const name = computed(() => {
if (isDailyRecommend.value) return t('comp.recommendSinger.songlist');
return musicStore.currentMusicListName || '';
2025-09-10 00:29:50 +08:00
});
2025-09-10 00:29:50 +08:00
const songList = computed(() => {
if (isDailyRecommend.value) return recommendStore.dailyRecommendSongs;
2025-09-10 00:29:50 +08:00
return musicStore.currentMusicList || [];
});
2025-09-10 00:29:50 +08:00
const listInfo = computed(() => {
if (isDailyRecommend.value) return null;
2025-09-10 00:29:50 +08:00
return musicStore.currentListInfo || null;
});
2025-09-10 00:29:50 +08:00
const canRemove = computed(() => {
if (isDailyRecommend.value) return false;
2025-09-10 00:29:50 +08:00
return musicStore.canRemoveSong || false;
});
const canCollect = ref(false);
const isCollected = ref(false);
const pageSize = 40;
const displayedSongs = ref<SongResult[]>([]);
const loadingList = ref(false);
const loadedIds = ref(new Set<number>());
const isPlaylistLoading = ref(false);
const completePlaylist = ref<SongResult[]>([]);
const hasMore = ref(true);
const searchKeyword = ref('');
const isFullPlaylistLoaded = ref(false);
const isSelecting = ref(false);
const selectedSongs = ref<number[]>([]);
const { isDownloading, batchDownloadMusic } = useDownload();
const isCompactLayout = ref(
isMobile.value ? false : localStorage.getItem('musicListLayout') === 'compact'
);
const total = computed(() => {
if (listInfo.value?.trackIds) return listInfo.value.trackIds.length;
return songList.value.length;
});
const getCoverImgUrl = computed(() => {
2025-10-22 21:50:20 +08:00
const coverImgUrl = listInfo.value?.coverImgUrl || listInfo.value?.picUrl;
if (coverImgUrl) return coverImgUrl;
const song = songList.value[0];
return song?.picUrl || song?.al?.picUrl || song?.album?.picUrl || '';
});
const filteredSongs = computed(() => {
const sourceList = isDailyRecommend.value ? songList.value : displayedSongs.value;
const dislikeFilteredList = sourceList.filter((s) => !playerStore.dislikeList.includes(s.id));
2025-09-10 00:29:50 +08:00
if (!searchKeyword.value) return dislikeFilteredList;
2025-09-10 00:29:50 +08:00
const keyword = searchKeyword.value.toLowerCase().trim();
2025-09-10 00:29:50 +08:00
return dislikeFilteredList.filter((song) => {
const songName = song.name?.toLowerCase() || '';
const albumName = song.al?.name?.toLowerCase() || '';
const artists = song.ar || song.artists || [];
return (
songName.includes(keyword) ||
albumName.includes(keyword) ||
artists.some((a: any) => a.name?.toLowerCase().includes(keyword)) ||
PinyinMatch.match(songName, keyword)
);
});
});
2025-09-10 00:29:50 +08:00
const resetListState = () => {
loadedIds.value.clear();
displayedSongs.value = [];
completePlaylist.value = [];
hasMore.value = true;
isFullPlaylistLoaded.value = false;
};
const formatSong = (item: any) => {
if (!item) return null;
return {
...item,
picUrl: item.al?.picUrl || item.picUrl,
song: {
artists: item.ar || item.artists,
name: item.name,
id: item.id
}
};
};
const loadSongs = async (ids: number[], appendToList = true, updateComplete = false) => {
if (ids.length === 0) return [];
try {
const { data } = await getMusicDetail(ids);
if (data?.songs) {
const { songs } = data;
songs.forEach((song: any) => loadedIds.value.add(song.id));
if (appendToList) displayedSongs.value.push(...songs);
if (updateComplete) completePlaylist.value.push(...songs);
return songs;
}
} catch (error) {
console.error('加载歌曲失败:', error);
}
return [];
};
const loadFullPlaylist = async () => {
if (isPlaylistLoading.value || isFullPlaylistLoaded.value) return;
isPlaylistLoading.value = true;
try {
if (!listInfo.value?.trackIds) {
isFullPlaylistLoaded.value = true;
return;
}
const allIds = listInfo.value.trackIds.map((item) => item.id);
const loadedSongIds = new Set(displayedSongs.value.map((s) => s.id as number));
completePlaylist.value = [...displayedSongs.value];
const unloadedIds = allIds.filter((id) => !loadedSongIds.has(id));
if (unloadedIds.length === 0) {
isFullPlaylistLoaded.value = true;
return;
}
const batchSize = 500;
for (let i = 0; i < unloadedIds.length; i += batchSize) {
const batchIds = unloadedIds.slice(i, i + batchSize);
const loadedBatch = await loadSongs(batchIds, false, false);
if (loadedBatch.length > 0) {
displayedSongs.value = [...displayedSongs.value, ...loadedBatch];
completePlaylist.value = [...completePlaylist.value, ...loadedBatch];
}
}
isFullPlaylistLoaded.value = true;
hasMore.value = false;
} catch (error) {
console.error('加载完整播放列表失败:', error);
} finally {
isPlaylistLoading.value = false;
}
};
const handlePlayAll = () => {
if (displayedSongs.value.length === 0) return;
saveHistory();
const list = searchKeyword.value
? filteredSongs.value
: isFullPlaylistLoaded.value
? completePlaylist.value
: displayedSongs.value;
playerStore.setPlayList(list.map(formatSong));
playerStore.setPlay(formatSong(list[0]));
if (!isFullPlaylistLoaded.value) loadFullPlaylist();
};
const handlePlayItem = (item: any) => {
playerStore.setPlay(formatSong(item));
if (!playerStore.playList.some((s) => s.id === item.id)) {
playerStore.addToNextPlay(formatSong(item));
}
};
const handleRemoveSong = async (songId: number) => {
if (!listInfo.value?.id || !canRemove.value) return;
try {
const res = await updatePlaylistTracks({
op: 'del',
pid: listInfo.value.id,
tracks: songId.toString()
});
if (res.status === 200) {
message.success(t('user.message.deleteSuccess'));
displayedSongs.value = displayedSongs.value.filter((s) => s.id !== songId);
completePlaylist.value = completePlaylist.value.filter((s) => s.id !== songId);
musicStore.removeSongFromList(songId);
}
} catch (error) {
console.error('删除歌曲失败:', error);
message.error(t('user.message.deleteFailed'));
}
};
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
const { scrollTop, scrollHeight, clientHeight } = target;
// 懒加载:滚动到底部时加载更多
if (
scrollHeight - scrollTop - clientHeight < 200 &&
!loadingList.value &&
hasMore.value &&
!searchKeyword.value
) {
loadMoreSongs();
}
};
const loadMoreSongs = async () => {
if (
isFullPlaylistLoaded.value ||
searchKeyword.value ||
displayedSongs.value.length >= total.value
)
return;
loadingList.value = true;
try {
const start = displayedSongs.value.length;
const end = Math.min(start + pageSize, total.value);
if (listInfo.value?.trackIds) {
const ids = listInfo.value.trackIds
.slice(start, end)
.map((i) => i.id)
.filter((id) => !loadedIds.value.has(id));
if (ids.length > 0) await loadSongs(ids);
} else {
const newSongs = songList.value.slice(start, end);
newSongs.forEach((s) => {
if (!loadedIds.value.has(s.id)) {
loadedIds.value.add(s.id);
displayedSongs.value.push(s);
}
});
}
hasMore.value = displayedSongs.value.length < total.value;
} finally {
loadingList.value = false;
}
};
const saveHistory = () => {
if (!listInfo.value?.id) return;
if (isAlbum.value) {
addAlbum({
id: listInfo.value.id,
name: listInfo.value.name || '',
picUrl: getCoverImgUrl.value,
size: total.value,
artist: listInfo.value.artist
});
} else if (route.query.type === 'playlist') {
addPlaylist({
id: listInfo.value.id,
name: listInfo.value.name || '',
coverImgUrl: getCoverImgUrl.value,
trackCount: total.value,
playCount: listInfo.value.playCount,
creator: listInfo.value.creator
});
}
};
const toggleCollect = async () => {
if (!listInfo.value?.id || !hasPermission(true)) {
if (!listInfo.value?.id) return;
2025-09-14 00:34:35 +08:00
message.error(getLoginErrorMessage(true));
return;
}
2025-10-22 21:50:20 +08:00
const type = route.query.type as string;
try {
const tVal = isCollected.value ? 2 : 1;
2025-10-22 21:50:20 +08:00
const response =
type === 'album'
? await subscribeAlbum({ t: tVal, id: listInfo.value.id })
: await subscribePlaylist({ t: tVal, id: listInfo.value.id });
if (response.data.code === 200) {
isCollected.value = !isCollected.value;
message.success(
t(
isCollected.value
? 'comp.musicList.collectSuccess'
: 'comp.musicList.cancelCollectSuccess'
)
);
2025-10-22 21:50:20 +08:00
if (type === 'album') {
isCollected.value
? userStore.addCollectedAlbum(listInfo.value.id)
: userStore.removeCollectedAlbum(listInfo.value.id);
2025-10-22 21:50:20 +08:00
} else {
listInfo.value.subscribed = isCollected.value;
}
}
} catch (error) {
console.error('操作收藏失败:', error);
message.error(t('comp.musicList.operationFailed'));
}
};
const startSelect = () => {
isSelecting.value = true;
selectedSongs.value = [];
};
const cancelSelect = () => {
isSelecting.value = false;
selectedSongs.value = [];
};
const handleSelect = (id: number, selected: boolean) => {
selected
? selectedSongs.value.push(id)
: (selectedSongs.value = selectedSongs.value.filter((i) => i !== id));
};
const isAllSelected = computed(
() => filteredSongs.value.length > 0 && selectedSongs.value.length === filteredSongs.value.length
);
const isIndeterminate = computed(
() => selectedSongs.value.length > 0 && selectedSongs.value.length < filteredSongs.value.length
);
const handleSelectAll = (checked: boolean) => {
selectedSongs.value = checked ? filteredSongs.value.map((s) => s.id as number) : [];
};
const handleBatchDownload = async () => {
const list = selectedSongs.value
.map((id) => filteredSongs.value.find((s) => s.id === id))
.filter((s) => s) as SongResult[];
await batchDownloadMusic(list);
cancelSelect();
};
2025-10-22 21:52:22 +08:00
const toggleLayout = () => {
isCompactLayout.value = !isCompactLayout.value;
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
2025-10-22 21:52:22 +08:00
};
const checkCollectionStatus = () => {
const type = route.query.type as string;
if (type === 'playlist' && listInfo.value?.id) {
canCollect.value = true;
isCollected.value = listInfo.value.subscribed || false;
} else if (type === 'album' && listInfo.value?.id) {
canCollect.value = true;
isCollected.value = userStore.isAlbumCollected(listInfo.value.id);
} else {
canCollect.value = false;
}
};
watch(
songList,
(newSongs) => {
resetListState();
if (newSongs.length > 0) {
displayedSongs.value = [...newSongs];
newSongs.forEach((s) => loadedIds.value.add(s.id));
}
hasMore.value = displayedSongs.value.length < total.value;
checkCollectionStatus();
},
{ immediate: true }
);
onMounted(checkCollectionStatus);
</script>
<style scoped lang="scss">
.music-list-page {
position: relative;
}
.hero-section {
min-height: 300px;
}
.action-bar {
transition:
background-color 0.3s,
box-shadow 0.3s;
}
.animate-item {
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.action-btn-pill {
@apply transition-all border-neutral-200 dark:border-neutral-800;
&:hover:not(:disabled) {
@apply border-primary/30 bg-primary/5;
}
}
.action-btn-icon {
@apply transition-all;
&:hover {
@apply scale-110 text-primary bg-primary/10;
}
}
.song-list-container {
padding-bottom: 100px;
}
.mobile {
.hero-section {
min-height: auto;
}
.action-bar {
@apply py-2;
}
}
</style>