mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-24 09:49:42 +08:00
perf: reduce frontend memory pressure and startup cost
Limit long-lived page and component retention while virtualizing large card views to keep runtime memory lower. Defer heavy editor, chart, workflow, calendar, and icon code so the app loads less JavaScript up front.
This commit is contained in:
@@ -14,6 +14,12 @@ import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -123,6 +129,22 @@ function getMediaId() {
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
function getExistsStatusKey() {
|
||||
return [
|
||||
props.media?.tmdb_id ?? '',
|
||||
props.media?.title ?? '',
|
||||
props.media?.year ?? '',
|
||||
props.media?.season ?? '',
|
||||
props.media?.type ?? '',
|
||||
props.media?.mediaid_prefix ?? '',
|
||||
props.media?.media_id ?? '',
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -167,6 +189,7 @@ async function addSubscribe(season: number | null = null, best_version: number =
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
@@ -213,6 +236,7 @@ async function removeSubscribe() {
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
@@ -227,8 +251,10 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season ?? null)
|
||||
if (result) isSubscribed.value = true
|
||||
const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () =>
|
||||
checkSubscribe(props.media?.season ?? null),
|
||||
)
|
||||
isSubscribed.value = subscribed
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -237,17 +263,22 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.success)
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
isExists.value = exists
|
||||
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -265,12 +296,14 @@ async function checkSubscribe(season: number | null) {
|
||||
},
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
|
||||
@@ -148,7 +148,12 @@ const transferItems = ref<FileItem[]>([])
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
function revokeCurrentImgLink() {
|
||||
if (!currentImgLink.value) return
|
||||
|
||||
URL.revokeObjectURL(currentImgLink.value)
|
||||
currentImgLink.value = ''
|
||||
}
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
@@ -287,6 +292,9 @@ async function download(item: FileItem) {
|
||||
if (result) {
|
||||
const downloadUrl = URL.createObjectURL(result)
|
||||
window.open(downloadUrl, '_blank')
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}, 60000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +312,7 @@ async function getImgLink(item: FileItem) {
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
if (result) {
|
||||
// 创建图片地址
|
||||
revokeCurrentImgLink()
|
||||
currentImgLink.value = URL.createObjectURL(result)
|
||||
}
|
||||
}
|
||||
@@ -314,7 +323,10 @@ watch(
|
||||
async () => {
|
||||
if (isImage.value && isFile.value) {
|
||||
await getImgLink(inProps.item)
|
||||
return
|
||||
}
|
||||
|
||||
revokeCurrentImgLink()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -597,6 +609,11 @@ function stopLoadingProgress() {
|
||||
onMounted(() => {
|
||||
list_files()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeCurrentImgLink()
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
184
src/components/misc/VirtualCardGrid.vue
Normal file
184
src/components/misc/VirtualCardGrid.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
minItemWidth?: number
|
||||
itemAspectRatio?: number
|
||||
gap?: number
|
||||
overscanRows?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
}>(),
|
||||
{
|
||||
minItemWidth: 144,
|
||||
itemAspectRatio: 1.5,
|
||||
gap: 16,
|
||||
overscanRows: 4,
|
||||
getItemKey: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const columnCount = ref(1)
|
||||
const itemWidth = ref(props.minItemWidth)
|
||||
const itemHeight = ref(props.minItemWidth * props.itemAspectRatio)
|
||||
const startIndex = ref(0)
|
||||
const endIndex = ref(0)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let animationFrameId: number | null = null
|
||||
|
||||
const rowStep = computed(() => itemHeight.value + props.gap)
|
||||
const totalRows = computed(() => Math.ceil(props.items.length / columnCount.value))
|
||||
|
||||
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
||||
|
||||
const renderedRowCount = computed(() => {
|
||||
if (!visibleItems.value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.ceil(visibleItems.value.length / columnCount.value)
|
||||
})
|
||||
|
||||
const totalContentHeight = computed(() => {
|
||||
if (!totalRows.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return totalRows.value * rowStep.value - props.gap
|
||||
})
|
||||
|
||||
const topPadding = computed(() => {
|
||||
if (!startIndex.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.floor(startIndex.value / columnCount.value) * rowStep.value
|
||||
})
|
||||
|
||||
const renderedHeight = computed(() => {
|
||||
if (!renderedRowCount.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return renderedRowCount.value * rowStep.value - props.gap
|
||||
})
|
||||
|
||||
const bottomPadding = computed(() => {
|
||||
return Math.max(totalContentHeight.value - topPadding.value - renderedHeight.value, 0)
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
columnGap: `${props.gap}px`,
|
||||
gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))`,
|
||||
paddingBottom: `${bottomPadding.value}px`,
|
||||
paddingTop: `${topPadding.value}px`,
|
||||
rowGap: `${props.gap}px`,
|
||||
}))
|
||||
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
}
|
||||
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
function syncVisibleRange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const container = containerRef.value
|
||||
if (!container || props.items.length === 0) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth
|
||||
if (!containerWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
const columns = Math.max(1, Math.floor((containerWidth + props.gap) / (props.minItemWidth + props.gap)))
|
||||
columnCount.value = columns
|
||||
itemWidth.value = (containerWidth - props.gap * (columns - 1)) / columns
|
||||
itemHeight.value = itemWidth.value * props.itemAspectRatio
|
||||
|
||||
const rowHeight = rowStep.value || 1
|
||||
const containerTop = window.scrollY + container.getBoundingClientRect().top
|
||||
const viewportTop = window.scrollY - containerTop
|
||||
const viewportBottom = viewportTop + window.innerHeight
|
||||
const startRow = Math.max(0, Math.floor(viewportTop / rowHeight) - props.overscanRows)
|
||||
const endRow = Math.min(totalRows.value, Math.ceil(viewportBottom / rowHeight) + props.overscanRows)
|
||||
const endRowExclusive = Math.max(startRow + 1, endRow)
|
||||
|
||||
startIndex.value = Math.min(props.items.length, startRow * columns)
|
||||
endIndex.value = Math.min(props.items.length, endRowExclusive * columns)
|
||||
}
|
||||
|
||||
function queueSyncVisibleRange() {
|
||||
if (typeof window === 'undefined' || animationFrameId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
animationFrameId = null
|
||||
syncVisibleRange()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queueSyncVisibleRange()
|
||||
window.addEventListener('scroll', queueSyncVisibleRange, { passive: true })
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
queueSyncVisibleRange()
|
||||
})
|
||||
|
||||
if (containerRef.value) {
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('scroll', queueSyncVisibleRange)
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
}
|
||||
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items.length,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
queueSyncVisibleRange()
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="virtual-card-grid">
|
||||
<div class="grid" :style="gridStyle">
|
||||
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
|
||||
<slot :item="item" :index="startIndex + index" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.virtual-card-grid {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
414
src/components/slide/VirtualSlideView.vue
Normal file
414
src/components/slide/VirtualSlideView.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
itemWidth?: number
|
||||
itemGap?: number
|
||||
overscanItems?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
}>(),
|
||||
{
|
||||
itemWidth: 144,
|
||||
itemGap: 16,
|
||||
overscanItems: 4,
|
||||
getItemKey: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const display = useDisplay()
|
||||
const isTouch = computed(() => display.mobile.value)
|
||||
const injectedProps: any = inject('rankingPropsKey', { linkurl: '', title: '' })
|
||||
|
||||
const slideContentRef = ref<HTMLElement | null>(null)
|
||||
const disabled = ref(0)
|
||||
const slideScrollLeft = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const startIndex = ref(0)
|
||||
const endIndex = ref(0)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scrollTimeoutDuration = 1500
|
||||
const itemStep = computed(() => props.itemWidth + props.itemGap)
|
||||
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
||||
|
||||
const leadingSpaceWidth = computed(() => startIndex.value * itemStep.value)
|
||||
|
||||
const visibleItemsWidth = computed(() => {
|
||||
if (!visibleItems.value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return visibleItems.value.length * props.itemWidth + Math.max(visibleItems.value.length - 1, 0) * props.itemGap
|
||||
})
|
||||
|
||||
const totalContentWidth = computed(() => {
|
||||
if (!props.items.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return props.items.length * props.itemWidth + Math.max(props.items.length - 1, 0) * props.itemGap
|
||||
})
|
||||
|
||||
const trailingSpaceWidth = computed(() => {
|
||||
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
|
||||
})
|
||||
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
}
|
||||
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
function resetScrollIndicatorTimer() {
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
function updateVisibleRange() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const viewportWidth = element.clientWidth
|
||||
if (!viewportWidth || !props.items.length) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = Math.min(props.items.length, props.overscanItems)
|
||||
return
|
||||
}
|
||||
|
||||
const firstVisible = Math.max(0, Math.floor(element.scrollLeft / itemStep.value) - props.overscanItems)
|
||||
const lastVisible = Math.min(
|
||||
props.items.length,
|
||||
Math.ceil((element.scrollLeft + viewportWidth) / itemStep.value) + props.overscanItems,
|
||||
)
|
||||
|
||||
startIndex.value = firstVisible
|
||||
endIndex.value = Math.max(firstVisible + 1, lastVisible)
|
||||
}
|
||||
|
||||
function updateDisabledState() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
|
||||
slideScrollLeft.value = element.scrollLeft
|
||||
|
||||
if (!props.items.length || totalContentWidth.value <= element.clientWidth) {
|
||||
disabled.value = 3
|
||||
} else if (element.scrollLeft === 0) {
|
||||
disabled.value = 0
|
||||
} else if (element.scrollLeft >= element.scrollWidth - element.clientWidth - 2) {
|
||||
disabled.value = 2
|
||||
} else {
|
||||
disabled.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
function syncLayoutState() {
|
||||
updateVisibleRange()
|
||||
updateDisabledState()
|
||||
}
|
||||
|
||||
function slideNext(next: boolean) {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
|
||||
const visibleCount = Math.max(1, Math.trunc(element.clientWidth / itemStep.value))
|
||||
const currentIndex = element.scrollLeft === 0 ? 0 : Math.trunc((element.scrollLeft + itemStep.value / 2) / itemStep.value)
|
||||
let targetLeft = 0
|
||||
|
||||
if (next) {
|
||||
targetLeft = Math.min((currentIndex + visibleCount) * itemStep.value, element.scrollWidth - element.clientWidth)
|
||||
} else {
|
||||
targetLeft = Math.max((currentIndex - visibleCount) * itemStep.value, 0)
|
||||
}
|
||||
|
||||
element.scrollTo({
|
||||
behavior: 'smooth',
|
||||
left: targetLeft,
|
||||
top: 0,
|
||||
})
|
||||
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
function handleContentScroll() {
|
||||
syncLayoutState()
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncLayoutState()
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncLayoutState()
|
||||
})
|
||||
|
||||
if (slideContentRef.value) {
|
||||
resizeObserver.observe(slideContentRef.value)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', syncLayoutState)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
scrollTimeout = null
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', syncLayoutState)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (slideContentRef.value && slideScrollLeft.value !== 0) {
|
||||
slideContentRef.value.scrollLeft = slideScrollLeft.value
|
||||
}
|
||||
|
||||
nextTick(syncLayoutState)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items.length,
|
||||
() => {
|
||||
nextTick(syncLayoutState)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="slider-container" :class="{ 'is-scrolling': isScrolling }">
|
||||
<div class="slider-header">
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<RouterLink v-if="injectedProps.linkurl" :to="injectedProps.linkurl" class="view-all-button">
|
||||
<span>更多</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="slider-content-wrapper">
|
||||
<div class="slider-content-container">
|
||||
<div ref="slideContentRef" class="slider-content" tabindex="0" @scroll="handleContentScroll">
|
||||
<div class="virtual-track" :style="{ width: `${totalContentWidth}px` }">
|
||||
<div v-if="leadingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${leadingSpaceWidth}px` }" />
|
||||
|
||||
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
|
||||
<div
|
||||
class="virtual-slide-item"
|
||||
:style="{
|
||||
marginInlineEnd: index === visibleItems.length - 1 ? '0px' : `${itemGap}px`,
|
||||
width: `${itemWidth}px`,
|
||||
}"
|
||||
>
|
||||
<slot name="item" :item="item" :index="startIndex + index" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="trailingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${trailingSpaceWidth}px` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-left"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
@click.stop="slideNext(false)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-right"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
@click.stop="slideNext(true)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-block-end: 8px;
|
||||
padding-block: 0;
|
||||
padding-inline: 8px;
|
||||
|
||||
& > :first-child {
|
||||
flex-grow: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-all-button {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
padding-block: 5px;
|
||||
padding-inline: 12px;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
.arrow-svg {
|
||||
fill: currentcolor;
|
||||
margin-inline-start: 2px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.arrow-svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-content-wrapper {
|
||||
position: relative;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content {
|
||||
overflow: scroll hidden !important;
|
||||
-ms-overflow-style: none !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-track {
|
||||
display: flex;
|
||||
inline-size: max-content;
|
||||
}
|
||||
|
||||
.virtual-slide-item,
|
||||
.virtual-spacer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
|
||||
box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button-left {
|
||||
inset-inline-start: 8px;
|
||||
}
|
||||
|
||||
.nav-button-right {
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
|
||||
.slider-container.is-scrolling .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.slider-container:hover .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user