Add missing file from previous commit
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="episode-release-menu"
|
||||
:style="menuStyle"
|
||||
tabindex="-1"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div class="episode-release-header">{{ episodeName }}</div>
|
||||
<div v-if="releases.length > 0" class="episode-release-list">
|
||||
<ReleaseVersionCard
|
||||
v-for="(release, index) in releases"
|
||||
:key="index"
|
||||
:torrent="release"
|
||||
:best="index === 0"
|
||||
:selectable="!!release.playable_file"
|
||||
:disabled="!release.playable_file"
|
||||
compact-flags
|
||||
variant="menu"
|
||||
inert-card
|
||||
show-actions
|
||||
:play-label="getPlayLabel(release.playable_file)"
|
||||
@play="emit('play', release.playable_file || '')"
|
||||
@open-folder="emit('openFolder', release.playable_file || '')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="episode-release-empty">No versions available</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
|
||||
import type { Torrent } from "../types"
|
||||
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
episodeName: string
|
||||
releases: Torrent[]
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [string]
|
||||
openFolder: [string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const menuLeft = ref(0)
|
||||
const menuTop = ref(0)
|
||||
const VIEWPORT_MARGIN = 12
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${menuLeft.value}px`,
|
||||
top: `${menuTop.value}px`,
|
||||
}))
|
||||
|
||||
function getPlayLabel(filePath: string | null | undefined): string {
|
||||
return props.hasResumePosition(filePath || null) ? "Continue" : "Play"
|
||||
}
|
||||
|
||||
function getFocusableElements(): HTMLElement[] {
|
||||
if (!menuRef.value) return []
|
||||
return Array.from(
|
||||
menuRef.value.querySelectorAll<HTMLElement>(
|
||||
'.ctx-btn:not(:disabled)'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function focusNext(delta: number) {
|
||||
const elements = getFocusableElements()
|
||||
if (elements.length === 0) return
|
||||
const currentIndex = elements.findIndex((el) => el === document.activeElement)
|
||||
const nextIndex =
|
||||
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
|
||||
elements[nextIndex].focus()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault()
|
||||
focusNext(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
focusNext(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
focusNext(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
emit("close")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function clampToViewport() {
|
||||
const menu = menuRef.value
|
||||
if (!menu) return
|
||||
|
||||
const width = menu.offsetWidth
|
||||
const height = menu.offsetHeight
|
||||
|
||||
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
|
||||
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
|
||||
|
||||
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
|
||||
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (!props.visible) return
|
||||
clampToViewport()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.x, props.y, props.episodeName, props.releases.length],
|
||||
async ([visible]) => {
|
||||
if (!visible) return
|
||||
await nextTick()
|
||||
clampToViewport()
|
||||
// Focus first action button for keyboard navigation
|
||||
const firstBtn = menuRef.value?.querySelector(
|
||||
".ctx-btn:not(:disabled)",
|
||||
) as HTMLElement | null
|
||||
firstBtn?.focus()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
window.addEventListener("resize", handleViewportChange)
|
||||
return
|
||||
}
|
||||
window.removeEventListener("resize", handleViewportChange)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleViewportChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.episode-release-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: rgba(20, 20, 30, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
min-width: 420px;
|
||||
max-width: min(820px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.episode-release-header {
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.episode-release-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.episode-release-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user