refactor: hero featured item is a plain link like all other cards

Remove the separate Play and Info buttons from the CollageHero featured
item. All collage items (including the featured one) are now uniform
<a href> links that navigate to the detail page.

- CollageHero: remove hasResumePosition prop, play/info emits, and
  associated play button logic
- App.vue: remove @play and @info bindings from CollageHero
- Delete unused HeroSection.vue component and its styles
This commit is contained in:
2026-05-25 14:25:58 +00:00
parent 76c426546b
commit cfa1781192
4 changed files with 10 additions and 325 deletions
-9
View File
@@ -84,9 +84,6 @@
:key="`movie-hero-${movieCollageItems.length}-${movieFeaturedItem?.id || 'none'}`"
:items="movieCollageItems"
:featured-item="movieFeaturedItem"
:has-resume-position="hasResumePosition"
@play="handlePlay"
@info="showDetail"
@select="showDetail"
/>
<!-- Spacer for header overlay -->
@@ -110,9 +107,6 @@
:key="`series-hero-${seriesCollageItems.length}-${seriesFeaturedItem?.id || 'none'}`"
:items="seriesCollageItems"
:featured-item="seriesFeaturedItem"
:has-resume-position="hasResumePosition"
@play="handlePlay"
@info="showDetail"
@select="showDetail"
/>
<!-- Spacer for header overlay -->
@@ -140,9 +134,6 @@
:key="`search-hero-${searchCollageItems.length}-${searchFeaturedItem?.id || 'none'}`"
:items="searchCollageItems"
:featured-item="searchFeaturedItem"
:has-resume-position="hasResumePosition"
@play="handlePlay"
@info="showDetail"
@select="showDetail"
/>
<!-- Spacer for header overlay -->
+9 -68
View File
@@ -3,9 +3,8 @@
<!-- Diagonal collage grid -->
<div class="collage-grid">
<template v-for="(item, index) in collageItems" :key="item.id">
<component
:is="index !== 0 ? 'a' : 'div'"
:ref="(el: HTMLElement | null) => setItemRef(el, index)"
<a
:ref="(el: unknown) => setItemRef(el as HTMLElement | null, index)"
class="collage-item"
:class="[
`collage-item-${index}`,
@@ -17,8 +16,8 @@
},
]"
v-bind="getItemAttrs(index)"
:href="index !== 0 ? getItemHref(item) : undefined"
@click="handleItemClick($event, item, index)"
:href="getItemHref(item)"
@click="handleItemClick($event, item)"
@focus="focusedIndex = index"
>
<!-- Featured item with hexagonal clip -->
@@ -107,18 +106,6 @@
<span v-if="getResolution(item)" class="meta-quality">{{ getResolution(item) }}</span>
</div>
<p v-if="getOverview(item)" class="collage-overview">{{ getOverview(item) }}</p>
<div class="collage-buttons">
<button class="btn btn-primary" @click.stop="handlePlay(item)">
{{ getPlayLabel(item) }}
</button>
<a
class="btn btn-secondary"
:href="getItemHref(item)"
@click.prevent="$emit('info', item)"
>
Info
</a>
</div>
</div>
<div class="collage-item-hover" v-else>
<span class="hover-title">{{ item.title }}</span>
@@ -126,7 +113,7 @@
> {{ getRating(item)?.toFixed(1) }}</span
>
</div>
</component>
</a>
</template>
</div>
</section>
@@ -433,7 +420,7 @@ function handleKeyDown(e: KeyboardEvent) {
const item = collageItems.value[focusedIndex.value]
e.preventDefault()
e.stopPropagation()
if (item) activateItem(item, focusedIndex.value)
if (item) activateItem(item)
}
return
}
@@ -476,12 +463,9 @@ function isItemVisible(index: number): boolean {
const props = defineProps<{
items: MediaItem[]
featuredItem?: MediaItem | null
hasResumePosition: (filePath: string | null) => boolean
}>()
const emit = defineEmits<{
play: [string]
info: [MediaItem]
select: [MediaItem]
}>()
@@ -589,48 +573,15 @@ function getOverview(item: MediaItem): string | null {
return overview.length > 150 ? overview.slice(0, 150) + "..." : overview
}
function getPlayableFile(item: MediaItem): string | null {
if (item.type === "movies") {
const movie = item.data as Movie
return Object.values(movie.torrents || {})[0]?.playable_file ?? null
}
const series = item.data as Series
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.torrents || {})) {
if (torrent.playable_file) return torrent.playable_file
}
}
}
return null
}
function handlePlay(item: MediaItem) {
const file = getPlayableFile(item)
if (file) emit("play", file)
}
function getPlayLabel(item: MediaItem): string {
return props.hasResumePosition(getPlayableFile(item)) ? "Continue" : "Play"
}
function getItemHref(item: MediaItem): string {
return `#/${item.type}/${item.id}`
}
function activateItem(item: MediaItem, index: number) {
if (index === 0) {
emit("info", item)
} else {
emit("select", item)
}
function activateItem(item: MediaItem) {
emit("select", item)
}
function handleItemClick(event: MouseEvent, item: MediaItem, index: number) {
if (index === 0) {
emit("info", item)
return
}
function handleItemClick(event: MouseEvent, item: MediaItem) {
// Let modified clicks navigate natively
if (
event.button !== 0 ||
@@ -1089,16 +1040,6 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
max-width: 450px;
}
.collage-buttons {
display: flex;
gap: 10px;
}
.collage-buttons .btn {
padding: 10px 24px;
font-size: 0.95rem;
}
/* Hover info for non-featured items */
.collage-item-hover {
position: absolute;
-153
View File
@@ -1,153 +0,0 @@
<template>
<section class="hero">
<div
v-if="coverUrl"
class="hero-background"
:style="{ backgroundImage: `url('${coverUrl}')` }"
></div>
<div class="hero-content">
<h1 class="hero-title">{{ item.title }}</h1>
<div class="hero-meta">
<span v-if="item.year" class="hero-year">{{ item.year }}</span>
<span v-if="rating" class="hero-rating" :class="ratingClass">
{{ rating.toFixed(1) }}
</span>
<span v-if="resolution" class="hero-quality">{{ resolution }}</span>
<span v-if="quality" class="hero-quality">{{ quality }}</span>
</div>
<p v-if="overview" class="hero-overview">{{ overview }}</p>
<div class="hero-buttons">
<button class="btn btn-primary" @click="handlePlay" :disabled="!playableFile">
Play
</button>
<a
class="btn btn-secondary"
:href="detailHref"
@click.prevent="$emit('info', item)"
>
More Info
</a>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from "vue"
import type { MediaItem, Movie, Series } from "../types"
import { getCoverUrl } from "../api"
const props = defineProps<{
item: MediaItem
}>()
const emit = defineEmits<{
play: [string]
info: [MediaItem]
}>()
const coverUrl = computed(() => {
return getCoverUrl(props.item.cover_path, props.item.root_id)
})
const resolution = computed(() => {
if (props.item.type === "movies") {
const movie = props.item.data as Movie
const torrents = Object.values(movie.torrents || {})
return torrents.length > 0 ? torrents[0].resolution : null
}
return null
})
const quality = computed(() => {
if (props.item.type === "movies") {
const movie = props.item.data as Movie
const torrents = Object.values(movie.torrents || {})
return torrents.length > 0 ? torrents[0].quality : null
}
return null
})
const rating = computed(() => {
if (props.item.type === "movies") {
return (props.item.data as Movie).info?.rating
}
return (props.item.data as Series).info?.rating
})
const ratingClass = computed(() => {
if (!rating.value) return ""
if (rating.value >= 7.5) return "rating-high"
if (rating.value >= 6) return "rating-medium"
return "rating-low"
})
const overview = computed(() => {
if (props.item.type === "movies") {
const o = (props.item.data as Movie).info?.overview
return o ? (o.length > 200 ? o.slice(0, 200) + "..." : o) : null
}
const o = (props.item.data as Series).info?.overview
return o ? (o.length > 200 ? o.slice(0, 200) + "..." : o) : null
})
const detailHref = computed(() => {
return `#/${props.item.type}/${props.item.id}`
})
const playableFile = computed(() => {
if (props.item.type === "movies") {
const movie = props.item.data as Movie
const torrents = Object.values(movie.torrents || {})
return torrents.length > 0 ? torrents[0].playable_file : null
}
// For series, get first available file from episodes
const series = props.item.data as Series
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
const torrents = Object.values(episode.torrents || {})
for (const torrent of torrents) {
if (torrent.playable_file) {
return torrent.playable_file
}
}
}
}
return null
})
function handlePlay() {
if (playableFile.value) {
emit("play", playableFile.value)
}
}
</script>
<style scoped>
.hero-rating {
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
}
.rating-high {
color: #46d369;
}
.rating-medium {
color: #f9a825;
}
.rating-low {
color: #e53935;
}
.hero-overview {
max-width: 500px;
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
margin-top: 12px;
}
</style>
+1 -95
View File
@@ -251,91 +251,6 @@ html.mouse-active .header-settings-btn:hover {
padding-top: 40px;
}
/* Hero section - kept for backwards compatibility but not used */
.hero {
position: relative;
height: 70vh;
max-height: 600px;
min-height: 400px;
display: flex;
align-items: flex-end;
padding: 0 4% 8%;
background-color: var(--bg-primary);
overflow: hidden;
}
.hero-background {
position: absolute;
top: 0;
right: 0;
width: 60%;
height: 100%;
background-size: contain;
background-position: right top;
background-repeat: no-repeat;
mask-image: linear-gradient(
to left,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.3) 60%,
transparent 100%
);
-webkit-mask-image: linear-gradient(
to left,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.3) 60%,
transparent 100%
);
}
.hero::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 200px;
background: var(--gradient-fade);
}
.hero-content {
position: relative;
z-index: 1;
max-width: 600px;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 16px;
text-shadow: 2px 2px 4px var(--shadow-color);
}
.hero-meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
font-size: 1rem;
}
.hero-year {
color: var(--text-secondary);
}
.hero-quality {
background: var(--bg-secondary);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.hero-buttons {
display: flex;
gap: 12px;
margin-top: 24px;
}
/* Blinking animation for button focus */
@keyframes btn-outline-blink {
0%,
@@ -853,16 +768,7 @@ html.mouse-active .release-item:hover {
--section-padding: 3%;
}
.hero {
height: 50vh;
min-height: 300px;
}
.hero-title {
font-size: 1.5rem;
}
.header-nav {
.header-nav {
gap: 10px;
}