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,
+4 -4
View File
@@ -474,7 +474,7 @@ async def _process_movies(
display_title = tmdb_info.title
content_hash = hashlib.md5(f"movie:{tmdb_id}".encode()).hexdigest()[:12]
item_id = f"{root_id}:{content_hash}" if root_id else content_hash
item_id = content_hash
media_folder = get_media_folder_path(display_title, year, "movie", cover_dir)
# Find/download cover
@@ -562,7 +562,7 @@ async def _process_movies(
title = group_data["title"]
year = group_data["year"]
content_hash = hashlib.md5(f"movie:{title}:{year}".encode()).hexdigest()[:12]
item_id = f"{root_id}:{content_hash}" if root_id else content_hash
item_id = content_hash
cover_path = (
await find_cover_image(title, year, "movie", cover_dir)
@@ -721,7 +721,7 @@ async def _process_series(
display_title = tmdb_info.title
content_hash = hashlib.md5(f"series:{tmdb_id}".encode()).hexdigest()[:12]
series_id = f"{root_id}:{content_hash}" if root_id else content_hash
series_id = content_hash
logger.debug(" [%d/%d] %s", series_idx, len(tmdb_groups), display_title)
@@ -795,7 +795,7 @@ async def _process_series(
items = group_data["items"]
title = group_data["title"]
content_hash = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12]
series_id = f"{root_id}:{content_hash}" if root_id else content_hash
series_id = content_hash
cover_path = (
await find_cover_image(title, None, "series", cover_dir)
+7 -8
View File
@@ -73,15 +73,10 @@ class IndexStore:
# ------------------------------------------------------------------
def _maybe_migrate_id(self, item_id: str) -> str:
"""Normalize item ID to this store's current root_id namespace."""
if not self.root_id:
return item_id
"""Strip any legacy root_id prefix, leaving only the content hash."""
if ":" in item_id:
# If snapshot was created under a different root_id prefix,
# remap to current root_id while preserving content hash.
_, content_hash = item_id.split(":", 1)
return f"{self.root_id}:{content_hash}"
return f"{self.root_id}:{item_id}"
return item_id.split(":", 1)[1]
return item_id
async def load_snapshot(self) -> None:
"""Load index from disk snapshot (recovery on startup)."""
@@ -216,6 +211,7 @@ class IndexStore:
def upsert_movie(self, item: Movie) -> bool:
"""Insert or update a movie. Returns True if it was a real change."""
item.id = self._maybe_migrate_id(item.id)
if not item.root_id and self.root_id:
item.root_id = self.root_id
existing = self.movies.get(item.id)
@@ -229,6 +225,7 @@ class IndexStore:
def upsert_series(self, item: Series) -> bool:
"""Insert or update a series. Returns True if it was a real change."""
item.id = self._maybe_migrate_id(item.id)
if not item.root_id and self.root_id:
item.root_id = self.root_id
existing = self.series.get(item.id)
@@ -242,12 +239,14 @@ class IndexStore:
def remove_movie(self, item_id: str) -> None:
"""Remove a movie from the index and broadcast."""
item_id = self._maybe_migrate_id(item_id)
self.movies.pop(item_id, None)
self._schedule_snapshot()
self._broadcast(Remove(kind="movie", id=item_id))
def remove_series(self, item_id: str) -> None:
"""Remove a series from the index and broadcast."""
item_id = self._maybe_migrate_id(item_id)
self.series.pop(item_id, None)
self._schedule_snapshot()
self._broadcast(Remove(kind="series", id=item_id))