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:
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user