Merge mediahive.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run -A npm:vite",
|
||||
"build": "deno run -A npm:vue-tsc --noEmit && deno run -A npm:vite build",
|
||||
"preview": "deno run -A npm:vite preview"
|
||||
},
|
||||
"imports": {
|
||||
"vue": "npm:vue@^3.4.0"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"nodeModulesDir": "auto"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/webp" sizes="32x32" href="/src/assets/mediahive-32.webp" />
|
||||
<link rel="icon" type="image/webp" sizes="192x192" href="/src/assets/mediahive.webp" />
|
||||
<link rel="apple-touch-icon" href="/src/assets/mediahive.webp" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MediaHive</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1405
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "mediahive-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Netflix-style media streaming for your collection",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import type { MediaIndex } from './types';
|
||||
|
||||
/**
|
||||
* Load the media index from the server
|
||||
*/
|
||||
export async function loadMediaIndex(): Promise<MediaIndex> {
|
||||
const response = await fetch('/api/index');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load media index: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a media file with the system's default player
|
||||
*/
|
||||
export async function playMedia(filePath: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: filePath }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || response.statusText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Play media error:', e);
|
||||
alert(`Failed to play: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a folder in Windows Explorer
|
||||
*/
|
||||
export async function openFolder(folderPath: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/open-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_path: folderPath }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || response.statusText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Open folder error:', e);
|
||||
alert(`Failed to open folder: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a cover path to a displayable URL.
|
||||
* Uses FastAPI server for async file serving.
|
||||
*
|
||||
* The path comes from the server already converted to Windows format (Z:\...)
|
||||
* Paths starting with '/' are TMDB relative paths that weren't fetched - ignore them
|
||||
*/
|
||||
export function getCoverUrl(coverPath: string | null): string {
|
||||
if (!coverPath) {
|
||||
return '';
|
||||
}
|
||||
// Ignore TMDB relative paths (start with /) - these are bugs in the index
|
||||
if (coverPath.startsWith('/')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert relative path to URL path for FastAPI server
|
||||
// .mediahive/covers/Movies/... -> /media/.mediahive/covers/Movies/...
|
||||
let urlPath = coverPath;
|
||||
|
||||
// Remove drive letter (Z:) and convert backslashes to forward slashes
|
||||
if (urlPath.match(/^[A-Za-z]:/)) {
|
||||
urlPath = urlPath.substring(2);
|
||||
}
|
||||
urlPath = urlPath.replace(/\\/g, '/');
|
||||
|
||||
// Ensure path starts with /
|
||||
if (!urlPath.startsWith('/')) {
|
||||
urlPath = '/' + urlPath;
|
||||
}
|
||||
|
||||
// Encode URI components but preserve slashes
|
||||
const encodedPath = urlPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||
|
||||
return `/api/media${encodedPath}`;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 874 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<header class="header" :class="[`header-${position}`]">
|
||||
<div class="header-left">
|
||||
<img :src="logoUrl" alt="MediaHive" class="header-logo" />
|
||||
<nav class="header-nav">
|
||||
<!-- Browse mode: show both Movies and Series -->
|
||||
<template v-if="!isDetailPage">
|
||||
<button
|
||||
class="header-nav-item"
|
||||
:class="{ active: !isSearchActive && currentView === 'movies' }"
|
||||
v-bind="navAttrs(navRow, 0)"
|
||||
:data-nav-entry-col="!isSearchActive && currentView === 'movies' ? 0 : undefined"
|
||||
@focus="switchToMovies"
|
||||
>
|
||||
Movies
|
||||
</button>
|
||||
<button
|
||||
class="header-nav-item"
|
||||
:class="{ active: !isSearchActive && currentView === 'series' }"
|
||||
v-bind="navAttrs(navRow, 1)"
|
||||
:data-nav-entry-col="!isSearchActive && currentView === 'series' ? 1 : undefined"
|
||||
@focus="switchToSeries"
|
||||
>
|
||||
Series
|
||||
</button>
|
||||
</template>
|
||||
<!-- Detail mode: show current category + Details -->
|
||||
<template v-else>
|
||||
<button
|
||||
class="header-nav-item"
|
||||
v-bind="navAttrs(navRow, 0)"
|
||||
@focus="goToCategory"
|
||||
>
|
||||
{{ currentView === 'movies' ? 'Movies' : 'Series' }}
|
||||
</button>
|
||||
<button
|
||||
class="header-nav-item active"
|
||||
v-bind="navAttrs(navRow, 1, 1)"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="header-search">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
v-model="localSearch"
|
||||
v-bind="navAttrs(navRow, 2)"
|
||||
:data-nav-entry-col="localSearch ? 2 : undefined"
|
||||
@focus="handleSearchFocus"
|
||||
@keydown.escape="handleEscape"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import logoUrl from '../assets/mediahive.webp';
|
||||
|
||||
const props = defineProps<{
|
||||
currentView: 'movies' | 'series';
|
||||
searchQuery: string;
|
||||
navRow: number;
|
||||
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [string];
|
||||
clearSearch: [];
|
||||
goBack: [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
const localSearch = ref(props.searchQuery);
|
||||
|
||||
// Check if we're on a detail page
|
||||
const isDetailPage = computed(() => {
|
||||
return props.position === 'after-movie-header' || props.position === 'after-series-hero';
|
||||
});
|
||||
|
||||
// Check if search is active (has query and not on detail page)
|
||||
const isSearchActive = computed(() => {
|
||||
return !isDetailPage.value && !!localSearch.value;
|
||||
});
|
||||
|
||||
// Switch views on focus (no Enter required) - only in browse mode
|
||||
function switchToMovies() {
|
||||
if (!isDetailPage.value && props.currentView !== 'movies') {
|
||||
emit('clearSearch');
|
||||
router.push('/movies');
|
||||
}
|
||||
}
|
||||
|
||||
function switchToSeries() {
|
||||
if (!isDetailPage.value && props.currentView !== 'series') {
|
||||
emit('clearSearch');
|
||||
router.push('/series');
|
||||
}
|
||||
}
|
||||
|
||||
// Go back to category list from detail page
|
||||
function goToCategory() {
|
||||
// Emit goBack to let App.vue handle navigation and focus restoration
|
||||
emit('goBack');
|
||||
}
|
||||
|
||||
// Handle search input focus - navigate to search if we have a query
|
||||
function handleSearchFocus() {
|
||||
// If on detail page, go back to browse first
|
||||
if (isDetailPage.value) {
|
||||
goToCategory();
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local search to parent
|
||||
watch(localSearch, (val) => {
|
||||
emit('search', val);
|
||||
});
|
||||
|
||||
// Sync parent search to local (for external clears)
|
||||
watch(() => props.searchQuery, (val) => {
|
||||
if (val !== localSearch.value) {
|
||||
localSearch.value = val;
|
||||
}
|
||||
});
|
||||
|
||||
function handleEscape() {
|
||||
// Clear search and blur
|
||||
localSearch.value = '';
|
||||
searchInputRef.value?.blur();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
searchInputRef.value?.focus();
|
||||
searchInputRef.value?.select();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<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>
|
||||
<button class="btn btn-secondary" @click="$emit('info', item)">
|
||||
ℹ More Info
|
||||
</button>
|
||||
</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);
|
||||
});
|
||||
|
||||
const resolution = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
return movie.versions && movie.versions.length > 0 ? movie.versions[0].resolution : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const quality = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
return movie.versions && movie.versions.length > 0 ? movie.versions[0].quality : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rating = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
return (props.item.data as Movie).rating;
|
||||
}
|
||||
return (props.item.data as Series).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).overview;
|
||||
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
|
||||
}
|
||||
const o = (props.item.data as Series).overview;
|
||||
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
|
||||
});
|
||||
|
||||
const playableFile = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
// Get the first version's playable file
|
||||
if (movie.versions && movie.versions.length > 0) {
|
||||
return movie.versions[0].playable_file;
|
||||
}
|
||||
return 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 || []) {
|
||||
for (const release of episode.releases || []) {
|
||||
if (release.playable_file) {
|
||||
return release.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>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div
|
||||
class="media-card"
|
||||
v-bind="navAttributes"
|
||||
:data-item-id="item.id"
|
||||
@click="$emit('click')"
|
||||
@keydown.enter.prevent="$emit('click')"
|
||||
>
|
||||
<div class="media-card-poster">
|
||||
<!-- SVG focus outline -->
|
||||
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
|
||||
<rect x="0" y="0" width="100" height="150" />
|
||||
</svg>
|
||||
<img
|
||||
v-if="coverUrl && !imageError"
|
||||
:src="coverUrl"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<div v-else class="media-card-placeholder">
|
||||
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }}
|
||||
</div>
|
||||
<div v-if="rating" class="media-card-rating" :class="ratingClass">
|
||||
★ {{ rating.toFixed(1) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-card-info">
|
||||
<div class="media-card-title-row">
|
||||
<span class="media-card-title">{{ displayTitle }}</span>
|
||||
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
|
||||
</div>
|
||||
<!-- Search match info (when searching) -->
|
||||
<template v-if="item.searchMatchInfo">
|
||||
<div v-if="matchedPeople && matchedPeople.length > 0" class="media-card-detail match-reason">
|
||||
<template v-for="(person, idx) in matchedPeople" :key="person.name">
|
||||
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{ person.name }}</span>
|
||||
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'">({{ person.roles }})</span><span v-if="idx < matchedPeople.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0" class="media-card-episodes">
|
||||
<div v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)" :key="ep.name" class="matched-episode">
|
||||
<span class="match-name">{{ ep.name }}</span>
|
||||
<span class="match-roles"> ({{ ep.location }})</span>
|
||||
</div>
|
||||
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Default display (browsing) -->
|
||||
<template v-else>
|
||||
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||
<div v-if="directorAndCast" class="media-card-detail">
|
||||
<span v-if="director" class="director-name">{{ director }}</span><span v-if="director && filteredCastNames">, </span>{{ filteredCastNames }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types';
|
||||
import { getCoverUrl } from '../api';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
navRow?: number;
|
||||
navCol?: number;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
// Navigation attributes for keyboard navigation
|
||||
const navAttributes = computed(() => {
|
||||
if (props.navRow !== undefined && props.navCol !== undefined) {
|
||||
return navAttrs(props.navRow, props.navCol);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const imageError = ref(false);
|
||||
|
||||
const coverUrl = computed(() => {
|
||||
if (imageError.value) return null;
|
||||
return getCoverUrl(props.item.cover_path);
|
||||
});
|
||||
|
||||
const rating = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
return (props.item.data as Movie).rating;
|
||||
}
|
||||
if (props.item.type === 'episode') {
|
||||
const epData = props.item.data as EpisodeWithSeries;
|
||||
return epData.episode.rating ?? epData.series.rating;
|
||||
}
|
||||
return (props.item.data as Series).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 displayTitle = computed(() => {
|
||||
if (props.item.type === 'episode') {
|
||||
const epData = props.item.data as EpisodeWithSeries;
|
||||
return epData.episode.name || `Episode ${epData.episode.episode_number}`;
|
||||
}
|
||||
return props.item.title;
|
||||
});
|
||||
|
||||
const subtitle = computed(() => {
|
||||
if (props.item.type === 'episode') {
|
||||
const epData = props.item.data as EpisodeWithSeries;
|
||||
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`;
|
||||
}
|
||||
// For series, show creators
|
||||
if (props.item.type === 'series') {
|
||||
const creators = (props.item.data as Series).creators;
|
||||
return creators && creators.length > 0 ? creators.join(', ') : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Director for movies
|
||||
const director = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).director;
|
||||
});
|
||||
|
||||
// Check if we have director and/or cast to display
|
||||
const directorAndCast = computed(() => {
|
||||
if (props.item.type !== 'movies') return false;
|
||||
return director.value || filteredCastNames.value;
|
||||
});
|
||||
|
||||
// Cast names, excluding director if they appear in cast
|
||||
const filteredCastNames = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
const cast = (props.item.data as Movie).cast;
|
||||
if (!cast || cast.length === 0) return null;
|
||||
|
||||
const directorName = director.value?.toLowerCase();
|
||||
const filteredCast = directorName
|
||||
? cast.filter(c => c.name.toLowerCase() !== directorName)
|
||||
: cast;
|
||||
|
||||
if (filteredCast.length === 0) return null;
|
||||
|
||||
// Show first 3 cast members
|
||||
const names = filteredCast.slice(0, 3).map(c => c.name);
|
||||
return names.join(', ');
|
||||
});
|
||||
|
||||
// Matched people from search (from searchMatchInfo)
|
||||
const matchedPeople = computed(() => {
|
||||
const info = props.item.searchMatchInfo;
|
||||
if (!info || !info.matchedPeople) return null;
|
||||
return info.matchedPeople;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Blinking animation for focus outline */
|
||||
@keyframes card-outline-blink {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* SVG focus outline styles */
|
||||
.card-focus-outline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.card-focus-outline rect {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.9);
|
||||
stroke-width: 4;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
/* Show outline on hover and focus */
|
||||
.media-card:hover .card-focus-outline,
|
||||
.media-card.nav-focused .card-focus-outline {
|
||||
opacity: 1;
|
||||
animation: card-outline-blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Brighter outline for keyboard focus */
|
||||
.media-card.nav-focused .card-focus-outline rect {
|
||||
stroke: #ffffff;
|
||||
stroke-width: 5;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
|
||||
.media-card-rating {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rating-high {
|
||||
color: #46d369;
|
||||
}
|
||||
|
||||
.rating-medium {
|
||||
color: #f9a825;
|
||||
}
|
||||
|
||||
.rating-low {
|
||||
color: #e53935;
|
||||
}
|
||||
|
||||
.media-card-detail {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
/* Allow up to 2 lines with ellipsis */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.director-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Search match styles */
|
||||
.match-reason {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.match-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.match-roles {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* When character name matched - highlight the role, dim the name */
|
||||
.match-dim {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.match-highlight {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.media-card-episodes {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.matched-episode {
|
||||
font-size: 0.6rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.matched-episode-more {
|
||||
font-size: 0.55rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="media-row" :class="{ 'media-row-wrap': wrap }">
|
||||
<MediaCard
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:nav-row="rowIndex"
|
||||
:nav-col="index"
|
||||
@click="$emit('select', item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaItem } from '../types';
|
||||
import MediaCard from './MediaCard.vue';
|
||||
|
||||
defineProps<{
|
||||
items: MediaItem[];
|
||||
wrap?: boolean;
|
||||
rowIndex?: number;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
select: [MediaItem];
|
||||
}>();
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export interface FocusableElement {
|
||||
element: HTMLElement;
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
// Global focus state
|
||||
const focusedElement = ref<HTMLElement | null>(null);
|
||||
const isNavigating = ref(false);
|
||||
// Track the "desired" column when moving vertically (to maintain column position across rows of different lengths)
|
||||
const desiredCol = ref<number | null>(null);
|
||||
// Track if global handlers are installed
|
||||
let handlersInstalled = false;
|
||||
|
||||
// Data attribute names
|
||||
const FOCUSABLE_ATTR = 'data-nav-focusable';
|
||||
const ROW_ATTR = 'data-nav-row';
|
||||
const COL_ATTR = 'data-nav-col';
|
||||
const ENTRY_COL_ATTR = 'data-nav-entry-col';
|
||||
|
||||
/**
|
||||
* Get all focusable elements in the DOM, grouped by row
|
||||
*/
|
||||
function getFocusableElements(): FocusableElement[] {
|
||||
const elements = document.querySelectorAll(`[${FOCUSABLE_ATTR}]`);
|
||||
const result: FocusableElement[] = [];
|
||||
|
||||
elements.forEach((el) => {
|
||||
const htmlEl = el as HTMLElement;
|
||||
// Skip hidden elements
|
||||
if (htmlEl.offsetParent === null) return;
|
||||
|
||||
const rect = htmlEl.getBoundingClientRect();
|
||||
// Skip elements not in viewport or zero-sized
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
const row = parseInt(htmlEl.getAttribute(ROW_ATTR) || '0', 10);
|
||||
const col = parseInt(htmlEl.getAttribute(COL_ATTR) || '0', 10);
|
||||
|
||||
result.push({
|
||||
element: htmlEl,
|
||||
row,
|
||||
col,
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get elements grouped by row
|
||||
*/
|
||||
function getElementsByRow(): Map<number, FocusableElement[]> {
|
||||
const elements = getFocusableElements();
|
||||
const byRow = new Map<number, FocusableElement[]>();
|
||||
|
||||
for (const el of elements) {
|
||||
if (!byRow.has(el.row)) {
|
||||
byRow.set(el.row, []);
|
||||
}
|
||||
byRow.get(el.row)!.push(el);
|
||||
}
|
||||
|
||||
// Sort each row by column
|
||||
for (const [, rowElements] of byRow) {
|
||||
rowElements.sort((a, b) => a.col - b.col);
|
||||
}
|
||||
|
||||
return byRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find element by row and col indices
|
||||
* @param useEntryCol - if true, check for entry-col override on elements
|
||||
*/
|
||||
function findElementAt(row: number, col: number, useEntryCol: boolean = false): FocusableElement | null {
|
||||
const byRow = getElementsByRow();
|
||||
const rowElements = byRow.get(row);
|
||||
if (!rowElements || rowElements.length === 0) return null;
|
||||
|
||||
// Check if any element in this row has an entry-col override
|
||||
if (useEntryCol) {
|
||||
for (const el of rowElements) {
|
||||
const entryCol = el.element.getAttribute(ENTRY_COL_ATTR);
|
||||
if (entryCol !== null) {
|
||||
const overrideCol = parseInt(entryCol, 10);
|
||||
const entryTarget = rowElements.find(e => e.col === overrideCol);
|
||||
if (entryTarget) return entryTarget;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find exact match or nearest col
|
||||
const exact = rowElements.find(e => e.col === col);
|
||||
if (exact) return exact;
|
||||
|
||||
// Find nearest col in this row
|
||||
let nearest = rowElements[0];
|
||||
let nearestDist = Math.abs(nearest.col - col);
|
||||
|
||||
for (const el of rowElements) {
|
||||
const dist = Math.abs(el.col - col);
|
||||
if (dist < nearestDist) {
|
||||
nearest = el;
|
||||
nearestDist = dist;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find next element in direction using row/col indices
|
||||
*/
|
||||
function findNextElement(
|
||||
current: HTMLElement,
|
||||
direction: 'up' | 'down' | 'left' | 'right'
|
||||
): HTMLElement | null {
|
||||
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || '0', 10);
|
||||
const currentCol = parseInt(current.getAttribute(COL_ATTR) || '0', 10);
|
||||
const byRow = getElementsByRow();
|
||||
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
// Horizontal: move within same row by col index
|
||||
desiredCol.value = null; // Reset desired col on horizontal movement
|
||||
|
||||
const rowElements = byRow.get(currentRow);
|
||||
if (!rowElements) return null;
|
||||
|
||||
const delta = direction === 'right' ? 1 : -1;
|
||||
const targetCol = currentCol + delta;
|
||||
|
||||
// Find element with target col in this row
|
||||
const target = rowElements.find(e => e.col === targetCol);
|
||||
return target?.element || null;
|
||||
} else {
|
||||
// Vertical: move to adjacent row, try to maintain column
|
||||
const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b);
|
||||
const currentRowIdx = sortedRows.indexOf(currentRow);
|
||||
|
||||
if (currentRowIdx === -1) return null;
|
||||
|
||||
const delta = direction === 'down' ? 1 : -1;
|
||||
const targetRowIdx = currentRowIdx + delta;
|
||||
|
||||
if (targetRowIdx < 0 || targetRowIdx >= sortedRows.length) return null;
|
||||
|
||||
const targetRow = sortedRows[targetRowIdx];
|
||||
|
||||
// Use desired col if set, otherwise use current col
|
||||
const targetCol = desiredCol.value ?? currentCol;
|
||||
|
||||
// Set desired col if not already set (first vertical move in a sequence)
|
||||
if (desiredCol.value === null) {
|
||||
desiredCol.value = currentCol;
|
||||
}
|
||||
|
||||
// Use entry column hook for vertical navigation
|
||||
const target = findElementAt(targetRow, targetCol, true);
|
||||
return target?.element || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus an element and scroll it into view
|
||||
*/
|
||||
function focusElement(element: HTMLElement | null) {
|
||||
if (!element) return;
|
||||
|
||||
// Remove focus from previous element
|
||||
if (focusedElement.value && focusedElement.value !== element) {
|
||||
focusedElement.value.classList.remove('nav-focused');
|
||||
focusedElement.value.blur();
|
||||
}
|
||||
|
||||
// Add focus to new element
|
||||
element.classList.add('nav-focused');
|
||||
element.focus({ preventScroll: true });
|
||||
|
||||
// Smooth scroll for vertical (block), instant for horizontal (inline)
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
|
||||
focusedElement.value = element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current focus state (row, col) for saving
|
||||
*/
|
||||
function getFocusState(): { row: number; col: number } | null {
|
||||
if (!focusedElement.value) return null;
|
||||
const row = parseInt(focusedElement.value.getAttribute(ROW_ATTR) || '0', 10);
|
||||
const col = parseInt(focusedElement.value.getAttribute(COL_ATTR) || '0', 10);
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore focus to element with given row/col
|
||||
*/
|
||||
function restoreFocusState(state: { row: number; col: number } | null) {
|
||||
if (!state) return;
|
||||
|
||||
const target = findElementAt(state.row, state.col);
|
||||
if (target) {
|
||||
setTimeout(() => {
|
||||
focusElement(target.element);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus element at specific row/col after a delay (for page transitions)
|
||||
*/
|
||||
function focusAt(row: number, col: number, delay: number = 100) {
|
||||
setTimeout(() => {
|
||||
const target = findElementAt(row, col);
|
||||
if (target) {
|
||||
focusElement(target.element);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should allow navigation from an input element
|
||||
*/
|
||||
function shouldAllowNavigationFromInput(target: HTMLElement, direction: string): boolean {
|
||||
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable) {
|
||||
return true; // Not an input, allow navigation
|
||||
}
|
||||
|
||||
// Always allow up/down navigation from inputs
|
||||
if (direction === 'up' || direction === 'down') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For left/right, only capture if input is empty
|
||||
const inputEl = target as HTMLInputElement | HTMLTextAreaElement;
|
||||
const value = inputEl.value || '';
|
||||
return value.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation
|
||||
*/
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const direction = {
|
||||
ArrowUp: 'up',
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
}[event.key] as 'up' | 'down' | 'left' | 'right' | undefined;
|
||||
|
||||
if (!direction) return;
|
||||
|
||||
// Check if we should allow navigation from this element
|
||||
if (!shouldAllowNavigationFromInput(target, direction)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
isNavigating.value = true;
|
||||
|
||||
// Get current focused element or find the first one
|
||||
let current = focusedElement.value;
|
||||
|
||||
// If no element is focused, try to get the currently focused element from DOM
|
||||
if (!current) {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && activeElement.hasAttribute(FOCUSABLE_ATTR)) {
|
||||
current = activeElement;
|
||||
}
|
||||
}
|
||||
|
||||
// If still no current, focus the first available element
|
||||
if (!current) {
|
||||
const elements = getFocusableElements();
|
||||
if (elements.length > 0) {
|
||||
focusElement(elements[0].element);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Find and focus the next element using index-based navigation
|
||||
const next = findNextElement(current, direction);
|
||||
if (next) {
|
||||
focusElement(next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Enter key to activate focused element
|
||||
*/
|
||||
function handleEnterKey(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter') return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedElement.value) {
|
||||
event.preventDefault();
|
||||
focusedElement.value.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install global keyboard navigation handlers
|
||||
* Should be called once at app initialization
|
||||
*/
|
||||
export function installKeyboardNavigation() {
|
||||
if (handlersInstalled) return;
|
||||
handlersInstalled = true;
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keydown', handleEnterKey);
|
||||
|
||||
// Handle mouse clicks to update focus state
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null;
|
||||
if (focusable) {
|
||||
desiredCol.value = null; // Reset desired col on mouse click
|
||||
focusElement(focusable);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle focus events from tab navigation
|
||||
document.addEventListener('focusin', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.hasAttribute(FOCUSABLE_ATTR)) {
|
||||
if (focusedElement.value && focusedElement.value !== target) {
|
||||
focusedElement.value.classList.remove('nav-focused');
|
||||
}
|
||||
focusedElement.value = target;
|
||||
target.classList.add('nav-focused');
|
||||
desiredCol.value = null; // Reset desired col on focus change
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to access keyboard navigation state
|
||||
* @deprecated Use installKeyboardNavigation() at app init instead
|
||||
*/
|
||||
export function useKeyboardNavigation() {
|
||||
return {
|
||||
focusedElement,
|
||||
isNavigating,
|
||||
focusElement,
|
||||
focusAt,
|
||||
getFocusState,
|
||||
restoreFocusState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to generate navigation attributes for a focusable element
|
||||
* @param entryCol - optional column to focus when entering this row vertically
|
||||
*/
|
||||
export function navAttrs(row: number, col: number, entryCol?: number) {
|
||||
const attrs: Record<string, string | number> = {
|
||||
[FOCUSABLE_ATTR]: 'true',
|
||||
[ROW_ATTR]: String(row),
|
||||
[COL_ATTR]: String(col),
|
||||
tabindex: 0,
|
||||
};
|
||||
if (entryCol !== undefined) {
|
||||
attrs[ENTRY_COL_ATTR] = String(entryCol);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
export { FOCUSABLE_ATTR, ROW_ATTR, COL_ATTR, ENTRY_COL_ATTR };
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.css'
|
||||
import { installKeyboardNavigation } from './composables/useKeyboardNavigation'
|
||||
|
||||
// Install global keyboard navigation handlers immediately
|
||||
installKeyboardNavigation()
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
// Empty component - App.vue handles all rendering based on route meta
|
||||
const EmptyRouteComponent = defineComponent({
|
||||
render() {
|
||||
return h('div');
|
||||
}
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
scrollBehavior() {
|
||||
// Always scroll to top on navigation
|
||||
return { top: 0 };
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/movies',
|
||||
},
|
||||
{
|
||||
path: '/movies',
|
||||
name: 'movies',
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: 'movies' },
|
||||
},
|
||||
{
|
||||
path: '/movies/:id',
|
||||
name: 'movie-detail',
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: 'movies' },
|
||||
},
|
||||
{
|
||||
path: '/series',
|
||||
name: 'series',
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: 'series' },
|
||||
},
|
||||
{
|
||||
path: '/series/:id',
|
||||
name: 'series-detail',
|
||||
component: EmptyRouteComponent,
|
||||
meta: { view: 'series' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,810 @@
|
||||
/* Netflix-style dark theme */
|
||||
:root {
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #141414;
|
||||
--bg-card: #1f1f1f;
|
||||
--bg-card-hover: #2a2a2a;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b3b3b3;
|
||||
--text-muted: #808080;
|
||||
--accent-red: #e50914;
|
||||
--accent-red-hover: #f40612;
|
||||
--border-color: #333333;
|
||||
--shadow-color: rgba(0, 0, 0, 0.75);
|
||||
--gradient-fade: linear-gradient(to top, var(--bg-primary) 0%, transparent 100%);
|
||||
|
||||
--header-height: 56px;
|
||||
--card-width: 160px;
|
||||
--card-aspect-ratio: 2/3;
|
||||
--section-padding: 2.5%;
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-medium: 300ms ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
-ms-hyphens: auto;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Header - persistent overlay that scrolls with content */
|
||||
.header {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--header-height);
|
||||
background: transparent;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.header-top {
|
||||
top: 40px;
|
||||
}
|
||||
|
||||
.header-after-hero {
|
||||
/* Position after browse collage-hero (70vh, max 600px) */
|
||||
top: clamp(450px, 70vh, 600px);
|
||||
}
|
||||
|
||||
.header-after-movie-header {
|
||||
/* Position after movie detail collage-header (300px) */
|
||||
top: 320px;
|
||||
}
|
||||
|
||||
.header-after-series-hero {
|
||||
/* Position after series detail hero (70vh, 450-600px) */
|
||||
top: clamp(450px, 70vh, 600px);
|
||||
}
|
||||
|
||||
/* Spacer to reserve space for header in layout */
|
||||
.header-spacer {
|
||||
height: calc(var(--header-height) + 20px);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
margin-right: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.header-nav-item {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-nav-item:hover,
|
||||
.header-nav-item.active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-nav-item:focus,
|
||||
.header-nav-item.nav-focused {
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.header-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: rgba(20, 20, 20, 0.9);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.6rem;
|
||||
width: 6em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--text-secondary);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main-content {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.main-content-no-hero {
|
||||
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%, 100% {
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 28px;
|
||||
border-radius: 4px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn.nav-focused,
|
||||
.btn:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary.nav-focused {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
animation: btn-outline-blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(109, 109, 110, 0.7);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.btn-secondary.nav-focused {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
animation: btn-outline-blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* View transitions - cinematic zoom effect */
|
||||
.view-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.view-zoom-enter-active {
|
||||
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
||||
}
|
||||
|
||||
.view-zoom-leave-active {
|
||||
transition: opacity 0.3s ease-in, transform 0.3s ease-in;
|
||||
}
|
||||
|
||||
.view-zoom-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(1.02) translateY(-10px);
|
||||
}
|
||||
|
||||
.view-zoom-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98) translateY(10px);
|
||||
}
|
||||
|
||||
/* Media rows */
|
||||
.media-section {
|
||||
padding: 0 var(--section-padding);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.media-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
padding-bottom: 8px;
|
||||
margin: 0 -2px;
|
||||
padding: 4px 2px 8px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.media-row::-webkit-scrollbar {
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media-row-wrap {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: visible;
|
||||
overflow-y: visible;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Media cards */
|
||||
.media-card {
|
||||
flex-shrink: 0;
|
||||
width: var(--card-width);
|
||||
cursor: pointer;
|
||||
transition: z-index 0s, box-shadow var(--transition-medium);
|
||||
position: relative;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.media-card:hover,
|
||||
.media-card.nav-focused {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.media-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.media-card:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.media-card-poster {
|
||||
width: 100%;
|
||||
aspect-ratio: var(--card-aspect-ratio);
|
||||
background: var(--bg-card);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.media-card:hover .media-card-poster {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.media-card-poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.media-card-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(100, 100, 100, 0.4),
|
||||
rgba(100, 100, 100, 0.4) 10px,
|
||||
rgba(80, 80, 80, 0.4) 10px,
|
||||
rgba(80, 80, 80, 0.4) 20px
|
||||
),
|
||||
linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
|
||||
color: #888;
|
||||
font-size: 3rem;
|
||||
border: 2px dashed #555;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.media-card-placeholder::after {
|
||||
content: "No Cover";
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.media-card-info {
|
||||
padding: 4px 2px 2px;
|
||||
opacity: 1;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.media-card:hover .media-card-info {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-card-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.media-card-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-card-year {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-card-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Modal / Detail view */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px var(--shadow-color);
|
||||
animation: modalIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.modal-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 200px;
|
||||
background: linear-gradient(to top, var(--bg-secondary) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-primary);
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.modal-header-content {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px 32px 32px;
|
||||
}
|
||||
|
||||
.modal-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-meta-badge {
|
||||
background: var(--bg-card);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* Season selector for series */
|
||||
.season-selector {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.season-selector label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.season-selector select {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.season-selector select:focus {
|
||||
outline: none;
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Release list */
|
||||
.release-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.release-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 4px;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.release-item:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.release-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.release-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.release-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.release-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--bg-card);
|
||||
border-top-color: var(--accent-red);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 16px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--text-secondary);
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--card-width: 120px;
|
||||
--section-padding: 3%;
|
||||
}
|
||||
|
||||
.hero {
|
||||
height: 50vh;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--card-width: 100px;
|
||||
--header-height: 48px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.media-card-info {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Type definitions for the media browser
|
||||
|
||||
export interface MovieVersion {
|
||||
path: string;
|
||||
playable_file: string | null;
|
||||
resolution: string | null;
|
||||
quality: string | null;
|
||||
codec: string | null;
|
||||
audio: string | null;
|
||||
encoder: string | null;
|
||||
size: number | null;
|
||||
in_rtorrent: boolean | null;
|
||||
torrent_path: string | null;
|
||||
}
|
||||
|
||||
export interface TmdbPerson {
|
||||
name: string;
|
||||
character?: string | null;
|
||||
profile_path: string | null;
|
||||
}
|
||||
|
||||
export interface SimilarMedia {
|
||||
id: number;
|
||||
title: string;
|
||||
poster_path: string | null;
|
||||
}
|
||||
|
||||
export interface Movie {
|
||||
id: string;
|
||||
title: string;
|
||||
original_title: string | null;
|
||||
alternative_titles: string[] | null;
|
||||
torrent_title: string | null;
|
||||
year: number | null;
|
||||
cover_path: string | null;
|
||||
showreel_images: string[] | null;
|
||||
versions: MovieVersion[];
|
||||
tmdb_id: number | null;
|
||||
tmdb_title: string | null;
|
||||
rating: number | null;
|
||||
vote_count: number | null;
|
||||
overview: string | null;
|
||||
genres: string[] | null;
|
||||
release_date: string | null;
|
||||
runtime: number | null;
|
||||
status: string | null;
|
||||
tagline: string | null;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
similar: SimilarMedia[] | null;
|
||||
keywords: string[] | null;
|
||||
cast: TmdbPerson[] | null;
|
||||
director: string | null;
|
||||
newest: number | null;
|
||||
}
|
||||
|
||||
export interface EpisodeRelease {
|
||||
path: string;
|
||||
playable_file: string | null;
|
||||
resolution: string | null;
|
||||
quality: string | null;
|
||||
codec: string | null;
|
||||
audio: string | null;
|
||||
encoder: string | null;
|
||||
size: number | null;
|
||||
in_rtorrent: boolean | null;
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
episode_number: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
air_date: string | null;
|
||||
runtime: number | null;
|
||||
still_path: string | null;
|
||||
rating: number | null;
|
||||
director: string | null;
|
||||
reel_image: string | null;
|
||||
releases: EpisodeRelease[];
|
||||
}
|
||||
|
||||
export interface Season {
|
||||
season_number: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
air_date: string | null;
|
||||
poster_path: string | null;
|
||||
episode_count: number | null;
|
||||
episodes: Episode[];
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
id: string;
|
||||
title: string;
|
||||
original_title: string | null;
|
||||
alternative_titles: string[] | null;
|
||||
torrent_title: string | null;
|
||||
cover_path: string | null;
|
||||
seasons: Season[];
|
||||
tmdb_id: number | null;
|
||||
tmdb_title: string | null;
|
||||
rating: number | null;
|
||||
vote_count: number | null;
|
||||
overview: string | null;
|
||||
genres: string[] | null;
|
||||
release_date: string | null;
|
||||
status: string | null;
|
||||
tagline: string | null;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
similar: SimilarMedia[] | null;
|
||||
keywords: string[] | null;
|
||||
cast: TmdbPerson[] | null;
|
||||
creators: string[] | null;
|
||||
number_of_seasons: number | null;
|
||||
number_of_episodes: number | null;
|
||||
networks: string[] | null;
|
||||
newest: number | null;
|
||||
}
|
||||
|
||||
export interface MediaStats {
|
||||
total_movies: number;
|
||||
total_movie_versions?: number;
|
||||
total_series: number;
|
||||
total_series_episodes?: number;
|
||||
}
|
||||
|
||||
export interface MediaIndex {
|
||||
version: number;
|
||||
generated_at: string;
|
||||
stats: MediaStats;
|
||||
movies: Movie[];
|
||||
series: Series[];
|
||||
}
|
||||
|
||||
export type MediaType = 'movies' | 'series' | 'episode';
|
||||
|
||||
// Matched person info for search results
|
||||
export interface MatchedPerson {
|
||||
name: string;
|
||||
roles: string; // e.g., "Director", "Tony Stark", "Creator"
|
||||
highlightRoles: boolean; // true if the roles/character matched (vs the name)
|
||||
}
|
||||
|
||||
// Matched episode info for search results
|
||||
export interface MatchedEpisode {
|
||||
name: string; // Episode name (highlighted)
|
||||
location: string; // "SN Episode M" (dimmed)
|
||||
seasonNumber: number; // For navigation to episode
|
||||
episodeNumber: number; // For navigation to episode
|
||||
}
|
||||
|
||||
// Info about why a search matched this item
|
||||
export interface SearchMatchInfo {
|
||||
// Matched people with their roles/characters
|
||||
matchedPeople?: MatchedPerson[];
|
||||
// Matched episodes for series
|
||||
matchedEpisodes?: MatchedEpisode[];
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
title: string;
|
||||
year?: number | null;
|
||||
cover_path: string | null;
|
||||
showreel_images?: string[] | null;
|
||||
type: MediaType;
|
||||
resolution?: string | null;
|
||||
data: Movie | Series | EpisodeWithSeries;
|
||||
// Optional search match info - only present in search results
|
||||
searchMatchInfo?: SearchMatchInfo;
|
||||
}
|
||||
|
||||
// Episode with parent series info for standalone display
|
||||
export interface EpisodeWithSeries {
|
||||
episode: Episode;
|
||||
series: Series;
|
||||
seasonNumber: number;
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* FastAPI-Vue Vite Plugin
|
||||
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
|
||||
*
|
||||
* Configures Vite for FastAPI backend integration:
|
||||
* - Proxies /api/* requests to the FastAPI backend
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
*/
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8420"
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
for (const path of paths) {
|
||||
proxy[path] = {
|
||||
target: backendUrl,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "vite-plugin-fastapi-mediahive",
|
||||
config: () => ({
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../mediahive/frontend-build",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [fastapiVue(), vue()],
|
||||
|
||||
// Vite dev server options
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 8420,
|
||||
strictPort: true,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user