refactor: media cards use proper <a href> links instead of JS navigation

MediaCard, MediaRow, CollageHero, HeroSection, and MediaDetail cast
cards now render as <a> tags with real hrefs. Modified clicks
(ctrl+click, middle-click) navigate natively, enabling open-in-new-tab
and link copying. Plain left-clicks continue through the existing
showDetail flow for side-effects like focus-state saving.
This commit is contained in:
2026-05-25 14:21:13 +00:00
parent 1d3d682b60
commit 76c426546b
9 changed files with 104 additions and 29 deletions
+40 -7
View File
@@ -3,8 +3,9 @@
<!-- Diagonal collage grid -->
<div class="collage-grid">
<template v-for="(item, index) in collageItems" :key="item.id">
<div
:ref="(el) => setItemRef(el as HTMLElement, index)"
<component
:is="index !== 0 ? 'a' : 'div'"
:ref="(el: HTMLElement | null) => setItemRef(el, index)"
class="collage-item"
:class="[
`collage-item-${index}`,
@@ -16,7 +17,8 @@
},
]"
v-bind="getItemAttrs(index)"
@click="handleItemClick(item, index)"
:href="index !== 0 ? getItemHref(item) : undefined"
@click="handleItemClick($event, item, index)"
@focus="focusedIndex = index"
>
<!-- Featured item with hexagonal clip -->
@@ -109,7 +111,13 @@
<button class="btn btn-primary" @click.stop="handlePlay(item)">
{{ getPlayLabel(item) }}
</button>
<button class="btn btn-secondary" @click.stop="$emit('info', item)"> Info</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>
@@ -118,7 +126,7 @@
> {{ getRating(item)?.toFixed(1) }}</span
>
</div>
</div>
</component>
</template>
</div>
</section>
@@ -425,7 +433,7 @@ function handleKeyDown(e: KeyboardEvent) {
const item = collageItems.value[focusedIndex.value]
e.preventDefault()
e.stopPropagation()
if (item) handleItemClick(item, focusedIndex.value)
if (item) activateItem(item, focusedIndex.value)
}
return
}
@@ -606,13 +614,36 @@ function getPlayLabel(item: MediaItem): string {
return props.hasResumePosition(getPlayableFile(item)) ? "Continue" : "Play"
}
function handleItemClick(item: MediaItem, index: number) {
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 handleItemClick(event: MouseEvent, item: MediaItem, index: number) {
if (index === 0) {
emit("info", item)
return
}
// Let modified clicks navigate natively
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
emit("select", item)
}
</script>
<style scoped>
@@ -642,6 +673,8 @@ function handleItemClick(item: MediaItem, index: number) {
filter 0.3s ease,
opacity 0.3s ease,
visibility 0.3s ease;
text-decoration: none;
color: inherit;
}
/* Media (images and videos) fill the collage item */
+11 -1
View File
@@ -20,7 +20,13 @@
<button class="btn btn-primary" @click="handlePlay" :disabled="!playableFile">
Play
</button>
<button class="btn btn-secondary" @click="$emit('info', item)"> More Info</button>
<a
class="btn btn-secondary"
:href="detailHref"
@click.prevent="$emit('info', item)"
>
More Info
</a>
</div>
</div>
</section>
@@ -85,6 +91,10 @@ const overview = computed(() => {
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
+24 -4
View File
@@ -1,9 +1,11 @@
<template>
<div
<component
:is="href ? 'a' : 'div'"
class="media-card"
v-bind="navAttributes"
:data-item-id="item.id"
@click="$emit('click')"
:href="href || undefined"
@click="handleClick"
@keydown.enter.prevent="$emit('click')"
>
<div class="media-card-poster">
@@ -78,7 +80,7 @@
</div>
</template>
</div>
</div>
</component>
</template>
<script setup lang="ts">
@@ -91,12 +93,30 @@ const props = defineProps<{
item: MediaItem
navRow?: number
navCol?: number
href?: string
}>()
defineEmits<{
const emit = defineEmits<{
click: []
}>()
function handleClick(event: MouseEvent) {
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return
}
// Prevent default navigation for plain left-clicks and synthetic clicks
// so that parent handlers can manage side-effects and routing
event.preventDefault()
emit("click")
}
const navAttributes = computed(() => {
if (props.navRow !== undefined && props.navCol !== undefined) {
return navAttrs(props.navRow, props.navCol)
+4 -4
View File
@@ -106,14 +106,14 @@
</div>
<div v-if="limitedMovieCast.length > 0" class="cast-list" data-sync-scroll-row="true">
<div
<a
v-for="(castMember, castIndex) in limitedMovieCast"
:key="`${castMember.name}-${castMember.character || ''}`"
class="cast-card media-card"
v-bind="navAttrs(2, movieVersions.length + castIndex)"
role="button"
:href="`#/?q=${encodeURIComponent(castMember.name)}`"
:title="`Search for ${castMember.name}`"
@click="handleCastSelect(castMember.name)"
@click.prevent="handleCastSelect(castMember.name)"
>
<img
v-if="castMember.profile_path && !castMember.profile_path.startsWith('/')"
@@ -133,7 +133,7 @@
castMember.character
}}</span>
</div>
</div>
</a>
</div>
<!-- Main content -->
+10 -1
View File
@@ -10,13 +10,14 @@
:item="item"
:nav-row="rowIndex"
:nav-col="index"
:href="getItemHref(item)"
@click="$emit('select', item)"
/>
</div>
</template>
<script setup lang="ts">
import type { MediaItem } from "../types"
import type { MediaItem, EpisodeWithSeries } from "../types"
import MediaCard from "./MediaCard.vue"
defineProps<{
@@ -28,4 +29,12 @@ defineProps<{
defineEmits<{
select: [MediaItem]
}>()
function getItemHref(item: MediaItem): string | undefined {
if (item.type === "episode") {
const epData = item.data as EpisodeWithSeries
return `#/series/${epData.series.id}`
}
return `#/${item.type}/${item.id}`
}
</script>
@@ -89,6 +89,7 @@ export function useMediaWebSocket() {
const torrentsB = annotateTorrents(b.torrents, b.root_id)
return {
...a,
id: getContentHash(a.id),
torrents: mergeTorrentDicts(torrentsA, torrentsB),
info: a.info || b.info,
cover_path: a.cover_path || b.cover_path,
@@ -171,6 +172,7 @@ export function useMediaWebSocket() {
}
return {
...a,
id: getContentHash(a.id),
seasons: Array.from(seasonMap.values()).sort((a, b) => a.season_number - b.season_number),
info: a.info || b.info,
cover_path: a.cover_path || b.cover_path,
+2
View File
@@ -473,6 +473,8 @@ html:not(.mouse-active) .btn-secondary.nav-focused {
box-shadow var(--transition-medium);
position: relative;
outline: none;
text-decoration: none;
color: inherit;
}
html.mouse-active .media-card:hover,