Files
MoviePilot-Frontend/src/components/dialog/SubscribeFilesDialog.vue
2026-06-30 22:26:11 +08:00

1352 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { copyToClipboard } from '@/@core/utils/navigator'
import api from '@/api'
import type { SubscribeDownloadFileInfo, SubscribeEpisodeInfo, SubscribeLibraryFileInfo, SubscrbieInfo } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
type SubscribeFileTab = 'download' | 'library'
type EpisodeStatus = 'library' | 'download' | 'missing'
interface SubscribeEpisodeGroup {
episodeNumber: number
episodeLabel: string
title: string
description?: string
backdrop?: string
download: SubscribeDownloadFileInfo[]
library: SubscribeLibraryFileInfo[]
status: EpisodeStatus
}
interface FileStatItem {
key: string
label: string
value: number
total: number
icon: string
color: 'primary' | 'info' | 'success' | 'warning'
}
// i18n
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 提示框
const $toast = useToast()
// 输入参数
const props = defineProps({
subid: Number,
})
// 定义触发的自定义事件
const emit = defineEmits(['close'])
// 当前文件类型页签
const activeTab = ref<SubscribeFileTab>('download')
// 当前选中的集数
const selectedEpisodeNumber = ref<number>()
// 订阅文件信息
const subScribeInfo = ref<SubscrbieInfo>()
// 是否加载中
const loading = ref(false)
/**
* 调用 API 查询订阅文件信息。
*/
async function loadSubscribeFilesInfo() {
try {
loading.value = true
subScribeInfo.value = await api.get(`subscribe/files/${props.subid}`)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
/**
* 将图片地址转换为当前全局缓存设置下可展示的地址。
*/
function resolveImageUrl(url?: string) {
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
}
/**
* 根据订阅媒体类型生成集数展示文案。
*/
function formatEpisodeLabel(episodeNumber: number) {
if (subScribeInfo.value?.subscribe?.type === '电影') return t('dialog.subscribeFiles.movieEpisode')
return `E${String(episodeNumber).padStart(2, '0')}`
}
/**
* 根据单集文件命中情况判断当前集状态。
*/
function resolveEpisodeStatus(download: SubscribeDownloadFileInfo[], library: SubscribeLibraryFileInfo[]): EpisodeStatus {
if (library.length) return 'library'
if (download.length) return 'download'
return 'missing'
}
/**
* 返回集状态对应的本地化展示文案。
*/
function getEpisodeStatusText(status: EpisodeStatus) {
const statusMap: Record<EpisodeStatus, string> = {
library: t('dialog.subscribeFiles.statusInLibrary'),
download: t('dialog.subscribeFiles.statusDownloaded'),
missing: t('dialog.subscribeFiles.statusMissing'),
}
return statusMap[status]
}
/**
* 返回集状态对应的 Vuetify 主题色。
*/
function getEpisodeStatusColor(status: EpisodeStatus) {
const colorMap: Record<EpisodeStatus, 'success' | 'info' | 'warning'> = {
library: 'success',
download: 'info',
missing: 'warning',
}
return colorMap[status]
}
/**
* 返回集状态对应的图标。
*/
function getEpisodeStatusIcon(status: EpisodeStatus) {
const iconMap: Record<EpisodeStatus, string> = {
library: 'mdi-check-circle-outline',
download: 'mdi-download-circle-outline',
missing: 'mdi-clock-outline',
}
return iconMap[status]
}
/**
* 返回订阅状态对应的本地化展示文案。
*/
function getSubscribeStateText(state?: string) {
const stateMap: Record<string, string> = {
R: t('dialog.subscribeFiles.subscribeRunning'),
P: t('dialog.subscribeFiles.subscribePending'),
S: t('dialog.subscribeFiles.subscribePaused'),
N: t('dialog.subscribeFiles.subscribeNew'),
}
return stateMap[state || ''] || t('common.unknown')
}
/**
* 返回订阅状态对应的 Vuetify 主题色。
*/
function getSubscribeStateColor(state?: string) {
if (state === 'R') return 'success'
if (state === 'S') return 'warning'
if (state === 'P') return 'info'
return 'primary'
}
/**
* 计算数值在总量中的百分比,保证进度条不会溢出。
*/
function calcPercent(value: number, total: number) {
if (!total) return 0
return Math.min(Math.max((value / total) * 100, 0), 100)
}
/**
* 从种子标题或文件路径中提取分辨率标签。
*/
function resolveResolutionLabel(file?: SubscribeDownloadFileInfo | SubscribeLibraryFileInfo) {
const text = 'torrent_title' in (file || {}) ? `${(file as SubscribeDownloadFileInfo).torrent_title || ''} ${file?.file_path || ''}` : file?.file_path || ''
const matched = text.match(/(?:2160|1080|720|480)p|4k|8k/i)
return matched?.[0]?.toUpperCase()
}
/**
* 复制文件路径到剪贴板并提示结果。
*/
async function copyPath(path?: string) {
if (!path) return
try {
const success = await copyToClipboard(path)
if (success) $toast.success(t('dialog.subscribeFiles.copySuccess'))
else $toast.error(t('dialog.subscribeFiles.copyFailed'))
} catch (e) {
console.log(e)
$toast.error(t('dialog.subscribeFiles.copyFailed'))
}
}
/**
* 切换当前选中的集数。
*/
function selectEpisode(episodeNumber: number) {
selectedEpisodeNumber.value = episodeNumber
}
// 订阅信息
const subscribe = computed(() => subScribeInfo.value?.subscribe)
// 所有集的展示数据
const episodeGroups = computed<SubscribeEpisodeGroup[]>(() => {
const episodes = subScribeInfo.value?.episodes ?? {}
return Object.keys(episodes)
.map(key => Number(key))
.sort((left, right) => left - right)
.map(episodeNumber => {
const item = episodes[episodeNumber] as SubscribeEpisodeInfo | undefined
const download = item?.download ?? []
const library = item?.library ?? []
return {
episodeNumber,
episodeLabel: formatEpisodeLabel(episodeNumber),
title: item?.title || formatEpisodeLabel(episodeNumber),
description: item?.description,
backdrop: item?.backdrop,
download,
library,
status: resolveEpisodeStatus(download, library),
}
})
})
// 总集数
const totalCount = computed(() => {
const subscribeTotal = subscribe.value?.total_episode ?? 0
if (subscribe.value?.type === '电影') return Math.max(episodeGroups.value.length, 1)
return Math.max(subscribeTotal, episodeGroups.value.length)
})
// 已下载集数
const downloadedCount = computed(() => episodeGroups.value.filter(item => item.download.length).length)
// 已入库集数
const libraryCount = computed(() => episodeGroups.value.filter(item => item.library.length).length)
// 缺失集数
const missingCount = computed(() => Math.max(totalCount.value - libraryCount.value, 0))
// 顶部统计卡片
const statItems = computed<FileStatItem[]>(() => [
{
key: 'total',
label: t('dialog.subscribeFiles.totalEpisodes'),
value: totalCount.value,
total: totalCount.value,
icon: 'mdi-view-grid-outline',
color: 'primary',
},
{
key: 'download',
label: t('dialog.subscribeFiles.downloadedCount'),
value: downloadedCount.value,
total: totalCount.value,
icon: 'mdi-cloud-download-outline',
color: 'info',
},
{
key: 'library',
label: t('dialog.subscribeFiles.libraryCount'),
value: libraryCount.value,
total: totalCount.value,
icon: 'mdi-server-network',
color: 'success',
},
])
// 当前选中集
const selectedEpisode = computed(() => {
return episodeGroups.value.find(item => item.episodeNumber === selectedEpisodeNumber.value) ?? episodeGroups.value[0]
})
// 当前选中集的当前页签文件列表
const selectedFiles = computed(() => {
if (!selectedEpisode.value) return []
return activeTab.value === 'download' ? selectedEpisode.value.download : selectedEpisode.value.library
})
// 顶部主背景图
const heroBackdropUrl = computed(() => {
return resolveImageUrl(selectedEpisode.value?.backdrop || subscribe.value?.backdrop || subscribe.value?.poster)
})
// 顶部海报图
const posterUrl = computed(() => resolveImageUrl(subscribe.value?.poster))
// 顶部背景图内联变量
const heroStyle = computed(() => ({
'--subscribe-files-backdrop': heroBackdropUrl.value ? `url("${heroBackdropUrl.value}")` : 'none',
}))
// 当前页签标题
const activeSectionTitle = computed(() => {
return activeTab.value === 'download' ? t('dialog.subscribeFiles.downloadTab') : t('dialog.subscribeFiles.libraryTab')
})
// 移动端逐集文件列表
const mobileEpisodeGroups = computed(() => {
return episodeGroups.value.map(item => ({
...item,
activeFiles: activeTab.value === 'download' ? item.download : item.library,
}))
})
watch(
episodeGroups,
episodes => {
if (!episodes.length) {
selectedEpisodeNumber.value = undefined
return
}
if (!episodes.some(item => item.episodeNumber === selectedEpisodeNumber.value)) {
selectedEpisodeNumber.value = episodes[0].episodeNumber
}
},
{ immediate: true },
)
onBeforeMount(() => {
loadSubscribeFilesInfo()
})
</script>
<template>
<VDialog
scrollable
max-width="74rem"
:fullscreen="!display.mdAndUp.value"
content-class="subscribe-files-overlay"
>
<VCard class="subscribe-files-dialog">
<VBtn
class="subscribe-files-dialog__close"
icon="mdi-close"
variant="text"
size="small"
:aria-label="t('common.close')"
@click="emit('close')"
/>
<LoadingBanner v-if="loading" />
<VCardText v-else class="subscribe-files-dialog__body">
<div v-if="subScribeInfo?.subscribe" class="subscribe-files-shell">
<section class="subscribe-files-hero" :style="heroStyle">
<div class="subscribe-files-hero__shade" />
<div class="subscribe-files-hero__content">
<div class="subscribe-files-poster-card">
<VImg :src="posterUrl" cover class="subscribe-files-poster-card__image">
<template #placeholder>
<VSkeletonLoader class="w-100 h-100" />
</template>
</VImg>
<div class="subscribe-files-poster-card__caption">
<span>{{ subscribe?.name }}</span>
</div>
</div>
<div class="subscribe-files-hero__meta">
<div class="subscribe-files-hero__eyebrow">
<VIcon icon="mdi-folder-play-outline" size="18" />
<span>{{ t('dialog.subscribeFiles.title') }}</span>
</div>
<h2 class="subscribe-files-hero__title">{{ subscribe?.name }}</h2>
<div class="subscribe-files-hero__chips">
<VChip v-if="subscribe?.season" color="primary" variant="flat" size="small">
{{ t('dialog.subscribeFiles.season', { number: subscribe.season }) }}
</VChip>
<VChip v-if="subscribe?.year" variant="tonal" size="small">{{ subscribe.year }}</VChip>
<VChip
:color="getSubscribeStateColor(subscribe?.state)"
variant="flat"
size="small"
prepend-icon="mdi-check-circle"
>
{{ getSubscribeStateText(subscribe?.state) }}
</VChip>
</div>
<p class="subscribe-files-hero__description">
{{ selectedEpisode?.description || subscribe?.description || t('dialog.subscribeFiles.noOverview') }}
</p>
</div>
<div class="subscribe-files-stats">
<div v-for="item in statItems" :key="item.key" class="subscribe-files-stat-card">
<div class="subscribe-files-stat-card__icon">
<VIcon :icon="item.icon" :color="item.color" size="22" />
</div>
<div class="subscribe-files-stat-card__content">
<div class="subscribe-files-stat-card__label">{{ item.label }}</div>
<div class="subscribe-files-stat-card__value">{{ item.value }}</div>
</div>
<VProgressLinear
:model-value="calcPercent(item.value, item.total)"
:color="item.color"
height="3"
rounded
/>
</div>
</div>
</div>
</section>
<section class="subscribe-files-content">
<aside v-if="display.mdAndUp.value" class="subscribe-files-episode-rail">
<div class="subscribe-files-episode-rail__header">
<div>
<span>{{ t('dialog.subscribeFiles.episodeList') }}</span>
<strong>{{ t('dialog.subscribeFiles.episodeTotal', { count: totalCount }) }}</strong>
</div>
<VChip size="small" color="warning" variant="tonal">
{{ t('dialog.subscribeFiles.missingCount', { count: missingCount }) }}
</VChip>
</div>
<div class="subscribe-files-episode-list">
<button
v-for="episode in episodeGroups"
:key="episode.episodeNumber"
class="subscribe-files-episode-item"
:class="{ 'subscribe-files-episode-item--active': selectedEpisode?.episodeNumber === episode.episodeNumber }"
type="button"
@click="selectEpisode(episode.episodeNumber)"
>
<span class="subscribe-files-episode-item__label">{{ episode.episodeLabel }}</span>
<span class="subscribe-files-episode-item__title">{{ episode.title }}</span>
<VChip :color="getEpisodeStatusColor(episode.status)" variant="tonal" size="x-small">
{{ getEpisodeStatusText(episode.status) }}
</VChip>
<VIcon
class="subscribe-files-episode-item__icon"
:icon="getEpisodeStatusIcon(episode.status)"
:color="getEpisodeStatusColor(episode.status)"
size="18"
/>
</button>
</div>
</aside>
<main class="subscribe-files-main">
<div class="subscribe-files-tabs">
<VBtnToggle
v-model="activeTab"
mandatory
divided
class="subscribe-files-tab-group"
selected-class="subscribe-files-tab-group__button--active"
>
<VBtn value="download" class="subscribe-files-tab-group__button">
<VIcon size="20" start icon="mdi-download" />
{{ t('dialog.subscribeFiles.downloadTab') }}
</VBtn>
<VBtn value="library" class="subscribe-files-tab-group__button">
<VIcon size="20" start icon="mdi-filmstrip-box-multiple" />
{{ t('dialog.subscribeFiles.libraryTab') }}
</VBtn>
</VBtnToggle>
</div>
<div v-if="display.mdAndUp.value" class="subscribe-files-detail">
<div class="subscribe-files-detail__header">
<div>
<div class="subscribe-files-detail__title">
<span>{{ selectedEpisode?.episodeLabel }}</span>
<strong>{{ selectedEpisode?.title }}</strong>
</div>
<div class="subscribe-files-detail__subtitle">
{{ activeSectionTitle }}
</div>
</div>
<VChip
v-if="selectedEpisode"
:color="getEpisodeStatusColor(selectedEpisode.status)"
variant="tonal"
size="small"
>
{{ getEpisodeStatusText(selectedEpisode.status) }}
</VChip>
</div>
<transition name="fade-slide" mode="out-in">
<div :key="activeTab" class="subscribe-files-file-list">
<template v-if="selectedFiles.length">
<article
v-for="(file, index) in selectedFiles"
:key="`${activeTab}-${file.file_path || index}`"
class="subscribe-files-file-card"
>
<div class="subscribe-files-file-card__media">
<VIcon
:icon="activeTab === 'download' ? 'mdi-play' : 'mdi-server-network'"
:color="activeTab === 'download' ? 'info' : 'success'"
size="20"
/>
</div>
<div class="subscribe-files-file-card__content">
<div class="subscribe-files-file-card__topline">
<VChip v-if="resolveResolutionLabel(file)" color="primary" variant="tonal" size="x-small">
{{ resolveResolutionLabel(file) }}
</VChip>
<VChip
v-if="activeTab === 'download' && (file as SubscribeDownloadFileInfo).site_name"
color="primary"
variant="tonal"
size="x-small"
>
{{ (file as SubscribeDownloadFileInfo).site_name }}
</VChip>
<VChip
v-if="activeTab === 'library' && (file as SubscribeLibraryFileInfo).storage"
color="success"
variant="tonal"
size="x-small"
>
{{ (file as SubscribeLibraryFileInfo).storage }}
</VChip>
<VChip
:color="activeTab === 'download' ? 'info' : 'success'"
variant="flat"
size="x-small"
>
{{ activeTab === 'download' ? t('dialog.subscribeFiles.statusDownloaded') : t('dialog.subscribeFiles.statusInLibrary') }}
</VChip>
</div>
<h3 class="subscribe-files-file-card__title">
{{ activeTab === 'download' ? ((file as SubscribeDownloadFileInfo).torrent_title || t('dialog.subscribeFiles.unknownTorrent')) : activeSectionTitle }}
</h3>
<div v-if="activeTab === 'download'" class="subscribe-files-file-card__meta">
<span v-if="(file as SubscribeDownloadFileInfo).downloader">
{{ t('dialog.subscribeFiles.downloader') }}{{ (file as SubscribeDownloadFileInfo).downloader }}
</span>
<span v-if="(file as SubscribeDownloadFileInfo).hash">
Hash{{ (file as SubscribeDownloadFileInfo).hash }}
</span>
</div>
<div class="subscribe-files-path-block">
<div class="subscribe-files-path-block__label">
<VIcon icon="mdi-folder-outline" size="16" />
{{ t('dialog.subscribeFiles.filePath') }}
</div>
<code>{{ file.file_path || t('dialog.subscribeFiles.noPath') }}</code>
<VBtn
icon="mdi-content-copy"
variant="tonal"
size="small"
:disabled="!file.file_path"
:aria-label="t('dialog.subscribeFiles.copyPath')"
@click="copyPath(file.file_path)"
/>
</div>
</div>
</article>
</template>
<div v-else class="subscribe-files-empty">
<VIcon icon="mdi-folder-search-outline" size="34" />
<div>{{ t('dialog.subscribeFiles.noTabFiles', { tab: activeSectionTitle }) }}</div>
</div>
</div>
</transition>
</div>
<div v-else class="subscribe-files-mobile-list">
<article
v-for="episode in mobileEpisodeGroups"
:key="episode.episodeNumber"
class="subscribe-files-mobile-card"
:class="`subscribe-files-mobile-card--${episode.status}`"
>
<div class="subscribe-files-mobile-card__header">
<div class="subscribe-files-mobile-card__episode">{{ episode.episodeLabel }}</div>
<div class="subscribe-files-mobile-card__title">
<strong>{{ episode.title }}</strong>
<span>{{ activeSectionTitle }}</span>
</div>
<VChip :color="getEpisodeStatusColor(episode.status)" variant="tonal" size="small">
{{ getEpisodeStatusText(episode.status) }}
</VChip>
</div>
<div v-if="episode.activeFiles.length" class="subscribe-files-mobile-card__files">
<div
v-for="(file, index) in episode.activeFiles"
:key="`${episode.episodeNumber}-${activeTab}-${file.file_path || index}`"
class="subscribe-files-mobile-file"
>
<div class="subscribe-files-mobile-file__chips">
<VChip v-if="resolveResolutionLabel(file)" color="primary" variant="tonal" size="x-small">
{{ resolveResolutionLabel(file) }}
</VChip>
<VChip
v-if="activeTab === 'download' && (file as SubscribeDownloadFileInfo).site_name"
color="primary"
variant="tonal"
size="x-small"
>
{{ (file as SubscribeDownloadFileInfo).site_name }}
</VChip>
<VChip
v-if="activeTab === 'library' && (file as SubscribeLibraryFileInfo).storage"
color="success"
variant="tonal"
size="x-small"
>
{{ (file as SubscribeLibraryFileInfo).storage }}
</VChip>
</div>
<div v-if="activeTab === 'download'" class="subscribe-files-mobile-file__title">
{{ (file as SubscribeDownloadFileInfo).torrent_title || t('dialog.subscribeFiles.unknownTorrent') }}
</div>
<div class="subscribe-files-path-block subscribe-files-path-block--mobile">
<code>{{ file.file_path || t('dialog.subscribeFiles.noPath') }}</code>
<VBtn
icon="mdi-content-copy"
variant="tonal"
size="small"
:disabled="!file.file_path"
:aria-label="t('dialog.subscribeFiles.copyPath')"
@click="copyPath(file.file_path)"
/>
</div>
</div>
</div>
<div v-else class="subscribe-files-mobile-card__empty">
{{ t('dialog.subscribeFiles.noTabFiles', { tab: activeSectionTitle }) }}
</div>
</article>
</div>
</main>
</section>
</div>
<div v-else class="subscribe-files-empty subscribe-files-empty--standalone">
<VIcon icon="mdi-folder-alert-outline" size="40" />
<div>{{ t('dialog.subscribeFiles.noData') }}</div>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.subscribe-files-dialog {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), var(--sfd-border-opacity));
backdrop-filter: blur(var(--sfd-blur)) saturate(1.18);
background:
linear-gradient(145deg, rgba(var(--v-theme-primary), var(--sfd-accent-opacity)), transparent 42%),
rgba(var(--v-theme-surface), var(--sfd-dialog-opacity)) !important;
color: rgb(var(--v-theme-on-surface));
--sfd-accent-opacity: 0.1;
--sfd-blur: 18px;
--sfd-border-opacity: 0.14;
--sfd-dialog-opacity: 0.9;
--sfd-hero-end-opacity: 0.88;
--sfd-hero-mid-opacity: 0.62;
--sfd-hero-shade-opacity: 0.92;
--sfd-hero-start-opacity: 0.98;
--sfd-panel-opacity: 0.12;
--sfd-panel-strong-opacity: 0.18;
--sfd-code-opacity: 0.16;
--sfd-muted-opacity: 0.7;
}
.subscribe-files-dialog__close {
position: absolute;
z-index: 8;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
background: rgba(var(--v-theme-surface), 0.22);
inset-block-start: 1rem;
inset-inline-end: 1rem;
}
.subscribe-files-dialog__body {
overflow: hidden;
padding: 0 !important;
}
.subscribe-files-shell {
display: flex;
flex-direction: column;
min-block-size: min(86vh, 56rem);
}
.subscribe-files-hero {
position: relative;
overflow: hidden;
min-block-size: 21rem;
background:
linear-gradient(
90deg,
rgba(var(--v-theme-surface), var(--sfd-hero-start-opacity)),
rgba(var(--v-theme-surface), var(--sfd-hero-mid-opacity)) 48%,
rgba(var(--v-theme-surface), var(--sfd-hero-end-opacity))
),
var(--subscribe-files-backdrop) center / cover;
}
.subscribe-files-hero__shade {
position: absolute;
background:
radial-gradient(circle at 24% 8%, rgba(var(--v-theme-primary), 0.24), transparent 34%),
linear-gradient(180deg, transparent, rgba(var(--v-theme-surface), var(--sfd-hero-shade-opacity)) 94%);
inset: 0;
}
.subscribe-files-hero__content {
position: relative;
z-index: 1;
display: grid;
align-items: end;
gap: 2rem;
grid-template-columns: 15rem minmax(0, 1fr) 14rem;
padding: 3.5rem 2.25rem 2rem;
}
.subscribe-files-poster-card {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.14);
border-radius: calc(var(--app-surface-radius) + 0.25rem);
aspect-ratio: 2 / 3;
background: rgba(var(--v-theme-surface), var(--sfd-panel-strong-opacity));
}
.subscribe-files-poster-card__image {
block-size: 100%;
}
.subscribe-files-poster-card__caption {
position: absolute;
display: flex;
align-items: flex-end;
padding: 1.25rem 1rem 1rem;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
color: white;
font-size: 1.35rem;
font-weight: 700;
inset-block-end: 0;
inset-inline: 0;
line-height: 1.25;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.45);
}
.subscribe-files-hero__meta {
min-inline-size: 0;
}
.subscribe-files-hero__eyebrow {
display: flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
font-size: 0.85rem;
font-weight: 600;
gap: 0.35rem;
letter-spacing: 0.04em;
}
.subscribe-files-hero__title {
overflow: hidden;
margin-block: 0.75rem 0.5rem;
font-size: clamp(2.1rem, 4vw, 3.25rem);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.08;
text-overflow: ellipsis;
}
.subscribe-files-hero__chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.subscribe-files-hero__description {
display: -webkit-box;
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.76);
font-size: 0.95rem;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
line-height: 1.75;
margin-block: 1rem 0;
max-inline-size: 42rem;
}
.subscribe-files-stats {
display: grid;
gap: 0.8rem;
}
.subscribe-files-stat-card {
display: grid;
align-items: center;
padding: 0.9rem;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
gap: 0.65rem;
grid-template-columns: auto 1fr;
}
.subscribe-files-stat-card > .v-progress-linear {
grid-column: 1 / -1;
}
.subscribe-files-stat-card__content {
min-inline-size: 0;
}
.subscribe-files-stat-card__icon {
display: grid;
border-radius: 50%;
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 2.4rem;
inline-size: 2.4rem;
place-items: center;
}
.subscribe-files-stat-card__label {
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
font-size: 0.78rem;
}
.subscribe-files-stat-card__value {
font-size: 1.45rem;
font-weight: 800;
line-height: 1.15;
}
.subscribe-files-content {
display: grid;
flex: 1 1 auto;
gap: 1rem;
grid-template-columns: 19rem minmax(0, 1fr);
min-block-size: 0;
padding: 0 1.25rem 1.25rem;
}
.subscribe-files-episode-rail,
.subscribe-files-main {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
}
.subscribe-files-episode-rail {
display: flex;
flex-direction: column;
min-block-size: 0;
}
.subscribe-files-episode-rail__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
gap: 0.75rem;
}
.subscribe-files-episode-rail__header div {
display: grid;
gap: 0.15rem;
}
.subscribe-files-episode-rail__header span {
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
font-size: 0.85rem;
}
.subscribe-files-episode-list {
display: flex;
overflow: auto;
flex: 1 1 auto;
flex-direction: column;
gap: 0.35rem;
min-block-size: 0;
padding: 0.75rem;
}
.subscribe-files-episode-item {
display: grid;
align-items: center;
padding: 0.75rem;
border: 1px solid transparent;
border-radius: calc(var(--app-surface-radius) - 0.25rem);
background: transparent;
color: rgb(var(--v-theme-on-surface));
column-gap: 0.65rem;
cursor: pointer;
grid-template-columns: 2.75rem minmax(0, 1fr) auto auto;
text-align: start;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.subscribe-files-episode-item:hover,
.subscribe-files-episode-item--active {
border-color: rgba(var(--v-theme-primary), 0.38);
background: rgba(var(--v-theme-primary), 0.12);
}
.subscribe-files-episode-item--active {
transform: translateX(2px);
}
.subscribe-files-episode-item__label {
display: grid;
border-radius: 0.75rem;
background: rgba(var(--v-theme-primary), 0.18);
block-size: 2.4rem;
color: rgb(var(--v-theme-primary));
font-weight: 800;
inline-size: 2.4rem;
place-items: center;
}
.subscribe-files-episode-item__title {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-files-episode-item__icon {
opacity: 0.9;
}
.subscribe-files-main {
display: flex;
flex-direction: column;
min-block-size: 0;
}
.subscribe-files-tabs {
padding: 1rem 1rem 0;
}
.subscribe-files-tab-group {
display: grid;
overflow: hidden;
inline-size: min(28rem, 100%);
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: var(--app-control-radius);
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
gap: 0.35rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0.25rem;
}
.subscribe-files-tab-group :deep(.v-btn) {
min-inline-size: 0;
}
.subscribe-files-tab-group :deep(.v-btn__content) {
flex-wrap: nowrap;
white-space: nowrap;
}
.subscribe-files-tab-group :deep(.v-icon) {
flex: 0 0 auto;
}
.subscribe-files-tab-group__button {
border-radius: calc(var(--app-control-radius) - 0.15rem) !important;
color: rgba(var(--v-theme-on-surface), 0.72) !important;
font-weight: 700;
letter-spacing: 0;
}
.subscribe-files-tab-group__button--active {
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.92), rgba(var(--v-theme-primary), 0.68)) !important;
color: rgb(var(--v-theme-on-primary)) !important;
}
.subscribe-files-detail {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
padding: 1rem;
}
.subscribe-files-detail__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-block-end: 1rem;
gap: 1rem;
}
.subscribe-files-detail__title {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.subscribe-files-detail__title span {
color: rgb(var(--v-theme-primary));
font-weight: 800;
}
.subscribe-files-detail__title strong {
font-size: 1.1rem;
}
.subscribe-files-detail__subtitle {
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
font-size: 0.85rem;
margin-block-start: 0.2rem;
}
.subscribe-files-file-list {
display: flex;
overflow: auto;
flex: 1 1 auto;
flex-direction: column;
gap: 0.8rem;
min-block-size: 0;
padding-block-end: 0.25rem;
}
.subscribe-files-file-card {
display: grid;
padding: 0.9rem;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
gap: 0.8rem;
grid-template-columns: auto minmax(0, 1fr);
}
.subscribe-files-file-card__media {
display: grid;
border-radius: 0.85rem;
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 2.5rem;
inline-size: 2.5rem;
place-items: center;
}
.subscribe-files-file-card__content {
min-inline-size: 0;
}
.subscribe-files-file-card__topline,
.subscribe-files-mobile-file__chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.subscribe-files-file-card__title {
overflow: hidden;
margin-block: 0.6rem 0.35rem;
font-size: 0.96rem;
font-weight: 700;
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-files-file-card__meta {
display: flex;
flex-wrap: wrap;
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
font-size: 0.78rem;
gap: 0.4rem 0.8rem;
margin-block-end: 0.65rem;
}
.subscribe-files-path-block {
display: grid;
align-items: center;
padding: 0.65rem;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: calc(var(--app-surface-radius) - 0.25rem);
background: rgba(var(--v-theme-on-surface), var(--sfd-code-opacity));
gap: 0.55rem;
grid-template-columns: minmax(0, 1fr) auto;
}
.subscribe-files-path-block__label {
display: flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
font-size: 0.78rem;
gap: 0.25rem;
grid-column: 1 / -1;
}
.subscribe-files-path-block code {
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.88);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 0.8rem;
line-height: 1.55;
overflow-wrap: anywhere;
}
.subscribe-files-empty {
display: grid;
align-content: center;
justify-items: center;
min-block-size: 12rem;
border: 1px dashed rgba(var(--v-theme-on-surface), 0.16);
border-radius: var(--app-surface-radius);
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
gap: 0.5rem;
}
.subscribe-files-empty--standalone {
min-block-size: 24rem;
margin: 1.5rem;
}
.subscribe-files-mobile-list {
display: flex;
overflow: auto;
flex: 1 1 auto;
flex-direction: column;
gap: 0.85rem;
padding: 1rem;
}
.subscribe-files-mobile-card {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
}
.subscribe-files-mobile-card__header {
display: grid;
align-items: center;
padding: 0.85rem;
gap: 0.65rem;
grid-template-columns: auto minmax(0, 1fr) auto;
}
.subscribe-files-mobile-card__episode {
display: grid;
border-radius: 0.8rem;
background: rgba(var(--v-theme-primary), 0.18);
block-size: 3rem;
color: rgb(var(--v-theme-primary));
font-size: 1.05rem;
font-weight: 800;
inline-size: 3rem;
place-items: center;
}
.subscribe-files-mobile-card__title {
display: grid;
min-inline-size: 0;
}
.subscribe-files-mobile-card__title strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-files-mobile-card__title span,
.subscribe-files-mobile-card__empty {
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
font-size: 0.8rem;
}
.subscribe-files-mobile-card__files {
display: grid;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
gap: 0.75rem;
padding: 0.85rem;
}
.subscribe-files-mobile-file {
display: grid;
gap: 0.55rem;
}
.subscribe-files-mobile-file__title {
overflow: hidden;
font-size: 0.86rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-files-path-block--mobile {
grid-template-columns: minmax(0, 1fr) auto;
}
.subscribe-files-mobile-card__empty {
padding: 0 0.85rem 0.85rem;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: opacity 0.16s ease, transform 0.16s ease;
}
.fade-slide-enter-from,
.fade-slide-leave-to {
opacity: 0;
transform: translateY(0.25rem);
}
.subscribe-files-dialog.v-theme--light {
--sfd-accent-opacity: 0.08;
--sfd-border-opacity: 0.11;
--sfd-dialog-opacity: 0.96;
--sfd-panel-opacity: 0.62;
--sfd-panel-strong-opacity: 0.76;
--sfd-code-opacity: 0.05;
--sfd-muted-opacity: 0.68;
}
@media (width <= 960px) {
.subscribe-files-dialog {
border: 0;
border-radius: 0 !important;
}
.subscribe-files-shell {
min-block-size: 100dvh;
}
.subscribe-files-hero {
min-block-size: auto;
}
.subscribe-files-hero__content {
display: grid;
gap: 1rem;
grid-template-columns: 8rem minmax(0, 1fr);
padding: 1rem;
}
.subscribe-files-poster-card {
border-radius: 1rem;
}
.subscribe-files-poster-card__caption {
display: none;
}
.subscribe-files-hero__title {
font-size: 1.55rem;
}
.subscribe-files-hero__description {
display: none;
}
.subscribe-files-stats {
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.subscribe-files-stat-card {
padding: 0.75rem;
gap: 0.45rem;
grid-template-columns: auto minmax(0, 1fr);
}
.subscribe-files-stat-card__content {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.subscribe-files-stat-card__label,
.subscribe-files-stat-card__value {
white-space: nowrap;
}
.subscribe-files-stat-card__icon {
block-size: 2rem;
inline-size: 2rem;
}
.subscribe-files-stat-card__value {
font-size: 1.2rem;
}
.subscribe-files-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
padding: 0;
}
.subscribe-files-main {
flex: 1 1 auto;
border: 0;
border-radius: 0;
background: transparent;
}
.subscribe-files-tabs {
padding: 0.9rem 1rem 0;
}
.subscribe-files-tab-group {
inline-size: 100%;
}
}
@media (width <= 560px) {
.subscribe-files-hero__content {
grid-template-columns: 6.5rem minmax(0, 1fr);
}
.subscribe-files-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.subscribe-files-stat-card:first-child {
grid-column: 1 / -1;
}
.subscribe-files-mobile-card__header {
grid-template-columns: auto minmax(0, 1fr);
}
.subscribe-files-mobile-card__header .v-chip {
justify-self: start;
grid-column: 2;
}
}
</style>