xbvr sync

This commit is contained in:
Lars Behrends
2025-11-14 02:42:44 +01:00
parent aed9d87c5c
commit 1b053148f0
12 changed files with 2035 additions and 1086 deletions

View File

@@ -57,6 +57,45 @@ class StashSyncService extends BaseSyncService
$this->logProgress("Processed {$this->processedCount} Stash items");
}
/**
* Check if a scene should be ignored based on file paths and ignore patterns in config
*/
private function shouldIgnoreScene(array $sceneData): bool
{
$config = $this->source['config'] ?? null;
if (empty($config)) {
return false;
}
$configData = json_decode($config, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logProgress('Invalid JSON in source config, skipping ignore check');
return false;
}
$ignorePaths = $configData['ignore_paths'] ?? [];
if (empty($ignorePaths) || !is_array($ignorePaths)) {
return false;
}
$files = $sceneData['files'] ?? [];
foreach ($files as $file) {
$filePath = $file['path'] ?? '';
if (empty($filePath)) {
continue;
}
foreach ($ignorePaths as $ignorePattern) {
if (stripos($filePath, $ignorePattern) !== false) {
$this->logProgress("Scene '{$sceneData['title']}' ignored due to file path containing: '{$ignorePattern}'");
return true;
}
}
}
return false;
}
private function syncScenes(): void
{
try {
@@ -91,6 +130,20 @@ class StashSyncService extends BaseSyncService
foreach ($scenes as $sceneData) {
try {
$this->logProgress("Processing scene: {$sceneData['title']} (ID: {$sceneData['id']})");
// Check if scene should be ignored based on file paths
if ($this->shouldIgnoreScene($sceneData)) {
$this->processedCount++; // Still count as processed
// Update progress for ignored items
$this->updateSyncLog($this->currentSyncLogId, 'running', [
'processed_items' => $this->processedCount,
'new_items' => $this->newCount,
'updated_items' => $this->updatedCount,
'message' => "Processed {$this->processedCount} of ~{$totalCount} scenes (ignored)"
]);
continue;
}
$this->syncScene($sceneData);
$this->processedCount++;
@@ -210,6 +263,7 @@ class StashSyncService extends BaseSyncService
audio_codec
width
height
path
}
performers {
id

View File

@@ -55,6 +55,7 @@ class XbvrSyncService extends BaseSyncService
foreach ($scenes as $sceneData) {
try {
$this->syncScene($sceneData);
$this->logProgress("Success Synced XBVR scene {$sceneData['id']}");
$this->processedCount++;
} catch (Exception $e) {
$this->logProgress("Error processing XBVR scene {$sceneData['id']}: " . $e->getMessage());
@@ -126,7 +127,7 @@ class XbvrSyncService extends BaseSyncService
}
// Add small delay to be respectful to the API
usleep(100000); // 0.1 second delay
//usleep(100000); // 0.1 second delay
} catch (Exception $e) {
$this->logProgress("Error fetching details for video: " . $e->getMessage());
@@ -458,176 +459,14 @@ class XbvrSyncService extends BaseSyncService
return $actorData;
}
// Try to fetch detailed actor information from XBVR/DeoVR API
// XBVR might have actor detail endpoints, let's try a few possibilities
// XBVR/DeoVR API does not provide actor detail endpoints
// Skip API calls and use basic actor info only
$this->logProgress("XBVR does not provide actor details API, using basic info for: {$actorName}");
$actorDetails = ['name' => $actorName];
// Try different XBVR actor API endpoints
$actorApiUrls = [
"{$this->baseUrl}/api/actor/search/" . urlencode($actorName),
"{$this->baseUrl}/actor/" . urlencode($actorName),
"{$this->baseUrl}/api/actors?name=" . urlencode($actorName),
];
foreach ($actorApiUrls as $apiUrl) {
try {
$this->logProgress("Trying to fetch actor details from: {$apiUrl}");
$response = $this->httpClient->get($apiUrl, [
'timeout' => 10,
'connect_timeout' => 5
]);
if ($response->getStatusCode() === 200) {
$actorApiData = json_decode($response->getBody(), true);
if (!empty($actorApiData)) {
$this->logProgress("Successfully fetched actor details for: {$actorName}");
// Merge API data with basic info
$actorDetails = array_merge($actorDetails, $this->mapActorApiData($actorApiData));
break;
}
}
} catch (Exception $e) {
// Continue to next API endpoint
$this->logProgress("Actor API endpoint failed: {$apiUrl} - " . $e->getMessage());
}
return ['name' => $actorName];
}
// If no detailed data found, try to scrape from web search or use basic info
if (count($actorDetails) <= 1) {
$this->logProgress("No detailed actor data found for {$actorName}, using basic info");
$actorDetails = $this->scrapeActorInfo($actorName);
}
return $actorDetails;
}
private function mapActorApiData(array $apiData): array
{
$mapped = [];
// Handle different possible API response formats
if (isset($apiData['actor'])) {
$apiData = $apiData['actor'];
}
// Map common fields
$fieldMappings = [
'id' => 'xbvr_id',
'name' => 'name',
'image' => 'image_path',
'thumbnail' => 'thumbnail_path',
'bio' => 'biography',
'biography' => 'biography',
'birthdate' => 'birth_date',
'age' => 'age',
'height' => 'height',
'weight' => 'weight',
'measurements' => 'measurements',
'nationality' => 'nationality',
'ethnicity' => 'ethnicity',
'eye_color' => 'eye_color',
'hair_color' => 'hair_color',
'tattoos' => 'tattoos',
'piercings' => 'piercings',
'aliases' => 'aliases',
'debut_year' => 'debut_year',
'retirement_year' => 'retirement_year',
'active' => 'active',
'website' => 'website',
'twitter' => 'twitter',
'instagram' => 'instagram',
'scene_count' => 'scene_count'
];
foreach ($fieldMappings as $apiField => $localField) {
if (isset($apiData[$apiField])) {
$mapped[$localField] = $apiData[$apiField];
}
}
return $mapped;
}
private function scrapeActorInfo(string $actorName): array
{
$actorInfo = ['name' => $actorName];
// Try to get basic information from web scraping
// This is a fallback when API doesn't provide details
try {
// Try to search for actor on common adult industry sites
$searchUrls = [
"https://www.adultempire.com/search.php?query=" . urlencode($actorName),
"https://www.brazzers.com/search/" . urlencode($actorName) . "/",
"https://www.naughtyamerica.com/search/" . urlencode($actorName),
];
foreach ($searchUrls as $searchUrl) {
try {
$response = $this->httpClient->get($searchUrl, [
'timeout' => 5,
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
]
]);
if ($response->getStatusCode() === 200) {
$html = $response->getBody()->getContents();
// Basic HTML parsing to extract information
$actorInfo = array_merge($actorInfo, $this->parseActorHtml($html, $actorName));
break;
}
} catch (Exception $e) {
continue;
}
}
} catch (Exception $e) {
$this->logProgress("Web scraping failed for {$actorName}: " . $e->getMessage());
}
return $actorInfo;
}
private function parseActorHtml(string $html, string $actorName): array
{
$info = [];
// Very basic HTML parsing - look for common patterns
// This is quite fragile and would need improvement for production use
// Look for image URLs
if (preg_match('/<img[^>]+src=["\']([^"\']*?(?:actor|performer|model)[^"\']*?)["\'][^>]*>/i', $html, $matches)) {
$info['image_path'] = $matches[1];
}
// Look for birthdate patterns
if (preg_match('/(?:born|birthdate|birth).*?(\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{4})/i', $html, $matches)) {
$info['birth_date'] = date('Y-m-d', strtotime($matches[1]));
}
// Look for age
if (preg_match('/age.*?(\d+)/i', $html, $matches)) {
$info['age'] = (int)$matches[1];
}
// Look for measurements
if (preg_match('/measurements?.*?(\d+-\d+-\d+)/i', $html, $matches)) {
$info['measurements'] = $matches[1];
}
// Look for height
if (preg_match('/height.*?(\d+\'?\d*)/i', $html, $matches)) {
$info['height'] = $matches[1];
}
return $info;
}
private function getOrCreateActor(array $actorData): ?array
{
@@ -635,17 +474,29 @@ class XbvrSyncService extends BaseSyncService
if (empty($name)) return null;
// Check if actor already exists by name or alias
// First check by exact name
$stmt = $this->pdo->prepare("
SELECT id, name, thumbnail_path, metadata FROM actors
WHERE name = :name
OR JSON_CONTAINS(metadata->'$.aliases', :name)
");
$stmt->execute(['name' => $name]);
$existingActor = $stmt->fetch(\PDO::FETCH_ASSOC);
// If found by alias, log it for debugging
if ($existingActor && $existingActor['name'] !== $name) {
$this->logProgress("Found existing actor '{$existingActor['name']}' by alias '{$name}'");
// If not found by name, check aliases in PHP
if (!$existingActor) {
$stmt = $this->pdo->prepare("SELECT id, name, thumbnail_path, metadata FROM actors");
$stmt->execute();
$allActors = $stmt->fetchAll(\PDO::FETCH_ASSOC);
foreach ($allActors as $actor) {
$metadata = json_decode($actor['metadata'] ?? '{}', true);
$aliases = $metadata['aliases'] ?? [];
if (is_array($aliases) && in_array($name, $aliases)) {
$existingActor = $actor;
$this->logProgress("Found existing actor '{$actor['name']}' by alias '{$name}'");
break;
}
}
}
// Prepare metadata from XBVR actor data

View File

@@ -31,12 +31,12 @@
</button>
</form>
<!-- View mode switcher -->
<div class="flex gap-1" role="group">
<!-- Enhanced View mode switcher -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1" role="group">
{% for mode in view_modes %}
<a
href="?view={{ mode }}&sort={{ sort }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}{% for source in filters.sources %}&sources[]={{ source }}{% endfor %}"
class="inline-flex items-center px-3 py-2 border border-gray-300 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {{ view_mode == mode ? 'bg-blue-600 border-blue-500 text-white' : '' }}"
href="?view={{ mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}{% for source in filters.sources %}&sources[]={{ source }}{% endfor %}"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-all duration-200 {{ view_mode == mode ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' }}"
title="{{ mode|title }} View"
>
{% if mode == 'grid' %}
@@ -59,6 +59,40 @@
{% endfor %}
</div>
<!-- Size slider -->
<div class="flex items-center gap-2 bg-gray-100 rounded-lg px-3 py-2" x-data="{
size: localStorage.getItem('adultCardSize') || 'medium',
sizes: {
small: { label: 'S', cols: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6', scale: 'scale-90' },
medium: { label: 'M', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6', scale: 'scale-100' },
large: { label: 'L', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', scale: 'scale-110' },
xlarge: { label: 'XL', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', scale: 'scale-125' }
},
updateSize(newSize) {
this.size = newSize;
localStorage.setItem('adultCardSize', newSize);
// Dispatch custom event to update grid
window.dispatchEvent(new CustomEvent('adultCardSizeChanged', { detail: { sizeKey: newSize, sizeData: this.sizes[newSize] } }));
}
}" @mounted="updateSize(size)">
<svg class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
</svg>
<span class="text-xs font-medium text-gray-600 hidden sm:inline mr-1">Size:</span>
<div class="flex items-center gap-1">
<template x-for="(sizeData, sizeKey) in sizes" :key="sizeKey">
<button
@click="updateSize(sizeKey)"
:class="size === sizeKey ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'"
class="px-2 py-1 text-xs font-medium rounded transition-all duration-200"
:title="'Set card size to ' + sizeKey"
>
<span x-text="sizeData.label"></span>
</button>
</template>
</div>
</div>
<!-- Sort dropdown -->
<div class="relative" x-data="{ open: false }">
<button @click="open = !open" class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
@@ -226,256 +260,205 @@
{% block content %}
<!-- Main content area -->
<div class="p-6">
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
<div class="p-6">
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900">Adult Videos</h1>
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-4xl font-bold bg-gradient-to-r from-slate-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent">Adult Videos Library</h1>
{% if pagination.total_items > 0 %}
<div class="text-gray-500 text-sm mt-1">
{{ pagination.total_items }} videos
<div class="text-slate-600 text-sm mt-2 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ pagination.total_items }} adult videos from {{ movies|length > 0 ? movies|reduce((carry, movie) => carry + 1, 0) : 0 }} sources
{% if search %}
matching "{{ search }}"
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
"{{ search }}"
</span>
{% endif %}
{% if filters.genres or filters.directors or filters.sources %}
</div>
{% endif %}
</div>
{% if pagination.total_items > 0 %}
<div class="flex flex-wrap gap-2">
{% if filters.genres %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">{{ filters.genres|join(', ') }}</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a.997.997 0 01-1.414 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
{{ filters.genres|join(', ') }}
</span>
{% endif %}
{% if filters.directors %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 ml-2">{{ filters.directors|join(', ') }}</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-purple-500 to-purple-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
{{ filters.directors|join(', ') }}
</span>
{% endif %}
{% if filters.sources %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 ml-2">{{ filters.sources|join(', ') }}</span>
{% endif %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-green-500 to-green-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
{{ filters.sources|join(', ') }}
</span>
{% endif %}
</div>
{% endif %}
</div>
{% if error %}
<div class="alert alert-danger mb-4">
{{ error }}
</div>
{% endif %}
<div class="text-muted small">
Sorted by: {{ sort_options[sort] }}
</div>
{% if error %}
<div class="alert alert-danger mb-4">
{{ error }}
</div>
{% endif %}
<div class="text-muted small">
Sorted by: {{ sort_options[sort] }}
</div>
{% if movies is empty %}
<div class="text-center py-5">
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
<!-- Enhanced empty state -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="relative mb-8">
<!-- Animated background circles -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-32 h-32 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full animate-pulse"></div>
<div class="absolute w-24 h-24 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-full animate-pulse animation-delay-1000"></div>
<div class="absolute w-16 h-16 bg-gradient-to-br from-purple-100 to-pink-100 rounded-full animate-pulse animation-delay-2000"></div>
</div>
<!-- Main icon -->
<div class="relative bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<svg class="w-20 h-20 text-slate-400 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<h3 class="h5 fw-medium text-dark">
</div>
</div>
<div class="text-center max-w-md">
<h3 class="text-2xl font-bold text-gray-900 mb-3">
{% if search or filters.genres or filters.directors or filters.sources %}
No adult videos found matching your criteria
{% else %}
No adult videos found
{% else %}
Your library is empty
{% endif %}
</h3>
<p class="text-muted">
<p class="text-slate-600 text-lg mb-8 leading-relaxed">
{% if search or filters.genres or filters.directors or filters.sources %}
Try adjusting your search terms or filters.
We couldn't find any adult videos matching your current search and filter criteria. Try adjusting your filters or search terms to discover more videos.
{% else %}
Adult videos will appear here after syncing with XBVR or Stash sources.
Start building your adult video library by syncing with XBVR or Stash sources. Your videos will appear here once synced.
{% endif %}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% if search or filters.genres or filters.directors or filters.sources %}
<a href="{{ path_for('adult.index') }}" class="btn btn-primary mt-3">
Clear filters
<a href="{{ path_for('adult.index') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-blue-800 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Clear Filters
</a>
{% else %}
<a href="{{ path_for('admin.index') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-semibold rounded-xl hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Setup Sync
</a>
{% endif %}
<a href="{{ path_for('search.index') }}"
class="inline-flex items-center px-6 py-3 bg-white text-gray-700 font-semibold rounded-xl border-2 border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition-all duration-200 shadow-sm hover:shadow-md">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
Search All Media
</a>
</div>
<!-- Additional help text -->
{% if not (search or filters.genres or filters.directors or filters.sources) %}
<div class="mt-8 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-left">
<p class="text-sm font-medium text-blue-900 mb-1">Need help getting started?</p>
<p class="text-sm text-blue-700">Check out the XBVR or Stash integration documentation for assistance with syncing your adult video libraries.</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% else %}
<!-- Adult videos content based on view mode -->
{% if view_mode == 'list' %}
<!-- List view -->
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<ul class="divide-y divide-gray-200">
<!-- Enhanced list view using component -->
<div class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
<ul class="divide-y divide-gray-100">
{% for movie in movies %}
<li class="px-4 py-3">
<div class="flex justify-between items-center">
<div class="flex items-center">
{% if movie.poster_url %}
<img class="rounded mr-3" style="width: 64px; height: 96px; object-fit: contain; background-color: #f8f9fa;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-gray-100 rounded mr-3 flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
<div class="flex-1">
<h3 class="text-sm font-semibold mb-1">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ movie.title }}
</a>
</h3>
<div class="flex items-center gap-3 text-sm text-gray-600">
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %}
{% if movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<span>{{ movie.source_name }}</span>
</div>
</div>
</div>
<div class="flex gap-2">
{% if movie.watched %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</li>
{% include 'components/adult-card.twig' with {'movie': movie, 'view_mode': 'list'} %}
{% endfor %}
</ul>
</div>
{% elseif view_mode == 'covers' %}
<!-- Enhanced Cover grid view -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
<!-- Enhanced cover grid view using component with dynamic sizing -->
<div
class="grid gap-6 transition-all duration-300"
x-data="{
currentSize: localStorage.getItem('adultCardSize') || 'medium',
sizeClasses: {
small: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
medium: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
large: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5',
xlarge: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
}
}"
:class="sizeClasses[currentSize]"
x-init="
window.addEventListener('adultCardSizeChanged', (e) => {
currentSize = e.detail.sizeKey;
});
// Set initial class
$el.classList.add(...sizeClasses[currentSize].split(' '));
"
>
{% for movie in movies %}
<div class="group relative bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
{% if movie.poster_url %}
<div class="relative {{ movie.poster_aspect_ratio ? 'aspect-custom' : 'aspect-[2/3]' }} overflow-hidden">
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
<!-- Overlay with movie info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute bottom-0 left-0 right-0 p-3">
{% if movie.rating %}
<div class="flex items-center mb-2">
<svg class="w-4 h-4 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<span class="text-white text-sm font-medium">{{ movie.rating }}</span>
</div>
{% endif %}
{% if movie.watched %}
<div class="flex items-center mb-1">
<svg class="w-3 h-3 text-green-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
<span class="text-green-400 text-xs">Watched</span>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 aspect-[2/3] min-h-[200px]">
<svg class="text-gray-400 w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
<div class="p-4">
<h6 class="text-sm font-bold truncate mb-1" title="{{ movie.title }}">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600 transition-colors">
{{ movie.title }}
</a>
</h6>
{% if movie.release_date %}
<p class="text-xs text-gray-600 font-medium">{{ movie.release_date|date('Y') }}</p>
{% endif %}
{% if movie.genre %}
<div class="mt-2">
{% for genre in movie.genre|split(',')|slice(0, 2) %}
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium mr-1 mb-1">{{ genre|trim }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% include 'components/adult-card.twig' with {'movie': movie, 'view_mode': 'covers'} %}
{% endfor %}
</div>
{% else %}
<!-- Default grid view -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<!-- Enhanced grid view using component with dynamic sizing -->
<div
class="grid gap-6 transition-all duration-300"
x-data="{
currentSize: localStorage.getItem('adultCardSize') || 'medium',
sizeClasses: {
small: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
medium: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
large: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
xlarge: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
}
}"
:class="sizeClasses[currentSize]"
x-init="
window.addEventListener('adultCardSizeChanged', (e) => {
currentSize = e.detail.sizeKey;
});
// Set initial class
$el.classList.add(...sizeClasses[currentSize].split(' '));
"
>
{% for movie in movies %}
<div class="bg-white rounded-lg shadow-md border border-gray-200 h-full">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
{% if movie.poster_url %}
<img class="rounded" style="width: 64px; height: 96px; object-fit: contain; background-color: #f8f9fa;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-gray-100 rounded flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-3 flex-1">
<h5 class="text-lg font-semibold mb-1">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ movie.title }}
</a>
</h5>
<div class="flex items-center gap-2 text-sm text-gray-600 mb-2">
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %}
</div>
{% if movie.source_name %}
<p class="text-sm text-gray-600 mb-2">
{{ movie.source_name }}
</p>
{% endif %}
</div>
</div>
{% if movie.overview %}
<div class="mt-3">
<p class="text-sm text-gray-600" style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
{{ movie.overview|slice(0, 150) }}{% if movie.overview|length > 150 %}...{% endif %}
</p>
</div>
{% endif %}
<div class="mt-3 flex justify-between items-center text-sm text-gray-600">
{% if movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<div class="flex gap-1">
{% if movie.watched %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</div>
</div>
{% include 'components/adult-card.twig' with {'movie': movie, 'view_mode': 'grid'} %}
{% endfor %}
</div>
{% endif %}
@@ -485,7 +468,7 @@
<div class="flex items-center justify-between mb-6 p-4 bg-gray-50 rounded-lg">
<div class="flex items-center gap-4">
<div class="text-sm text-gray-700">
Showing {{ (pagination.current_page - 1) * pagination.per_page + 1 }} to {{ min(pagination.current_page * pagination.per_page, pagination.total_items) }} of {{ pagination.total_items }} videos
Showing {{ (pagination.current_page - 1) * pagination.per_page + 1 }} to {{ min(pagination.current_page * pagination.per_page, pagination.total_items) }} of {{ pagination.total_items }} adult videos
</div>
<div class="flex items-center gap-2">
<label for="per_page_top" class="text-sm font-medium text-gray-700">Show:</label>
@@ -597,6 +580,30 @@
.select2-selection__choice__remove:hover {
color: #f8f9fa !important;
}
/* Animation delays for empty state */
.animation-delay-1000 {
animation-delay: 1s;
}
.animation-delay-2000 {
animation-delay: 2s;
}
/* Line clamping utilities */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<script>

View File

@@ -0,0 +1,185 @@
{#
Adult Video Card Component
Parameters:
- movie: The adult video object with all properties (uses 'movie' variable name from controller)
- view_mode: 'grid', 'list', or 'covers' (optional, defaults to 'grid')
- show_rating: Whether to show rating (optional, defaults to true)
- show_runtime: Whether to show runtime (optional, defaults to true)
- show_genres: Whether to show genre tags (optional, defaults to true)
- show_watched: Whether to show watched status (optional, defaults to true)
#}
{% set view_mode = view_mode|default('grid') %}
{% set show_rating = show_rating|default(true) %}
{% set show_runtime = show_runtime|default(true) %}
{% set show_genres = show_genres|default(true) %}
{% set show_watched = show_watched|default(true) %}
{% if view_mode == 'list' %}
<!-- List View Card -->
<li class="px-4 py-3">
<div class="flex justify-between items-center">
<div class="flex items-center">
{% if movie.poster_url %}
<img class="rounded mr-3" style="width: 64px; height: 96px; object-fit: contain; background-color: #f8f9fa;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-gray-100 rounded mr-3 flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
<div class="flex-1">
<h3 class="text-sm font-semibold mb-1">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ movie.title }}
</a>
</h3>
<div class="flex items-center gap-3 text-sm text-gray-600">
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if show_rating and movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %}
{% if show_runtime and movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<span>{{ movie.source_name }}</span>
</div>
</div>
</div>
<div class="flex gap-2">
{% if show_watched and movie.watched %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</li>
{% elseif view_mode == 'covers' %}
<!-- Cover View Card -->
<div class="group relative bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
{% if movie.poster_url %}
<div class="relative {{ movie.poster_aspect_ratio ? 'aspect-custom' : 'aspect-[2/3]' }} overflow-hidden">
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
<!-- Overlay with adult video info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute bottom-0 left-0 right-0 p-3">
{% if show_rating and movie.rating %}
<div class="flex items-center mb-2">
<svg class="w-4 h-4 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<span class="text-white text-sm font-medium">{{ movie.rating }}</span>
</div>
{% endif %}
{% if show_watched and movie.watched %}
<div class="flex items-center mb-1">
<svg class="w-3 h-3 text-green-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
<span class="text-green-400 text-xs">Watched</span>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 aspect-[2/3] min-h-[200px]">
<svg class="text-gray-400 w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
<div class="p-4">
<h6 class="text-sm font-bold truncate mb-1" title="{{ movie.title }}">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600 transition-colors">
{{ movie.title }}
</a>
</h6>
{% if movie.release_date %}
<p class="text-xs text-gray-600 font-medium">{{ movie.release_date|date('Y') }}</p>
{% endif %}
{% if show_genres and movie.genre %}
<div class="mt-2">
{% for genre in movie.genre|split(',')|slice(0, 2) %}
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium mr-1 mb-1">{{ genre|trim }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% else %}
<!-- Grid View Card (Default) -->
<div class="bg-white rounded-lg shadow-md border border-gray-200 h-full">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
{% if movie.poster_url %}
<img class="rounded" style="width: 64px; height: 96px; object-fit: contain; background-color: #f8f9fa;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-gray-100 rounded flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-3 flex-1">
<h5 class="text-lg font-semibold mb-1">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ movie.title }}
</a>
</h5>
<div class="flex items-center gap-2 text-sm text-gray-600 mb-2">
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if show_rating and movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %}
</div>
{% if movie.source_name %}
<p class="text-sm text-gray-600 mb-2">
{{ movie.source_name }}
</p>
{% endif %}
</div>
</div>
{% if movie.overview %}
<div class="mt-3">
<p class="text-sm text-gray-600" style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
{{ movie.overview|slice(0, 150) }}{% if movie.overview|length > 150 %}...{% endif %}
</p>
</div>
{% endif %}
<div class="mt-3 flex justify-between items-center text-sm text-gray-600">
{% if show_runtime and movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<div class="flex gap-1">
{% if show_watched and movie.watched %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,321 @@
{#
Game Card Component
Parameters:
- game: The game object with all properties
- view_mode: 'grid', 'list', or 'covers' (optional, defaults to 'grid')
- show_completion: Whether to show completion progress (optional, defaults to true)
- show_platforms: Whether to show platform badges (optional, defaults to true)
- show_genres: Whether to show genre tags (optional, defaults to true)
- show_playtime: Whether to show playtime (optional, defaults to true)
#}
{% set view_mode = view_mode|default('grid') %}
{% set show_completion = show_completion|default(true) %}
{% set show_platforms = show_platforms|default(true) %}
{% set show_genres = show_genres|default(true) %}
{% set show_playtime = show_playtime|default(true) %}
{% if view_mode == 'list' %}
<!-- List View Card -->
<li class="group px-6 py-5 hover:bg-gradient-to-r hover:from-blue-50 hover:to-indigo-50 transition-all duration-200">
<div class="flex justify-between items-center">
<div class="flex items-center flex-1 min-w-0">
<!-- Image Section -->
<div class="relative flex-shrink-0 mr-4">
{% if game.image_url %}
<div class="w-16 h-16 rounded-lg overflow-hidden shadow-sm group-hover:shadow-md transition-shadow duration-200">
<img class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" src="/images/playnite/{{ game.image_url }}" alt="{{ game.title }}">
</div>
{% else %}
<div class="w-16 h-16 bg-gradient-to-br from-slate-100 to-slate-200 rounded-lg flex items-center justify-center shadow-sm">
<svg class="text-slate-400 w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
<!-- Completion indicator -->
{% if show_completion and game.max_completion > 0 %}
<div class="absolute -top-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center shadow-sm">
<span class="text-xs font-bold text-white">{{ game.max_completion }}%</span>
</div>
{% endif %}
</div>
<!-- Content Section -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-bold text-gray-900 mb-1 truncate group-hover:text-blue-600 transition-colors">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="no-underline">
{{ game.title }}
</a>
</h3>
<!-- Platform and stats -->
<div class="flex items-center gap-4 text-sm text-gray-600 mb-2">
{% if show_platforms %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<span>{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</span>
</div>
{% endif %}
{% if show_playtime and game.total_playtime %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ game.total_playtime|format_duration }}</span>
</div>
{% endif %}
</div>
<!-- Platforms -->
{% if show_platforms and game.platforms %}
<div class="flex flex-wrap gap-1 mb-2">
{% for platform in game.platforms|slice(0, 3) %}
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
{{ platform }}
</span>
{% endfor %}
{% if game.platforms|length > 3 %}
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
+{{ game.platforms|length - 3 }} more
</span>
{% endif %}
</div>
{% endif %}
</div>
<!-- Genres Section -->
{% if show_genres and game.genres %}
<div class="flex flex-wrap gap-1 ml-4 flex-shrink-0">
{% for genre in game.genres|slice(0, 2) %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-blue-50 to-blue-100 text-blue-700 border border-blue-200">
{{ genre }}
</span>
{% endfor %}
{% if game.genres|length > 2 %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-gray-50 to-gray-100 text-gray-700 border border-gray-200">
+{{ game.genres|length - 2 }}
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Quick action button -->
<div class="ml-4 flex-shrink-0">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 hover:text-blue-700 transition-colors opacity-0 group-hover:opacity-100 transform translate-x-2 group-hover:translate-x-0 transition-all duration-200">
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
View
</a>
</div>
</div>
</li>
{% elseif view_mode == 'covers' %}
<!-- Cover View Card -->
<div class="group bg-white rounded-xl shadow-lg hover:shadow-2xl border border-gray-100 overflow-hidden h-full transition-all duration-300 hover:scale-[1.05] hover:-translate-y-2">
<!-- Cover Image -->
<div class="relative overflow-hidden">
{% if game.image_url %}
<div class="aspect-[3/4] overflow-hidden">
<img src="/images/playnite/{{ game.image_url }}" alt="{{ game.title }}" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500">
</div>
{% else %}
<div class="aspect-[3/4] bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center">
<div class="text-center">
<svg class="text-slate-400 w-12 h-12 mx-auto mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<div class="text-xs text-slate-500 font-medium">No Cover</div>
</div>
</div>
{% endif %}
<!-- Overlay with quick actions -->
<div class="absolute inset-0 bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-300 flex items-center justify-center">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}"
class="opacity-0 group-hover:opacity-100 transform translate-y-4 group-hover:translate-y-0 transition-all duration-300 bg-white text-gray-900 px-3 py-2 rounded-lg font-medium text-sm shadow-lg hover:bg-blue-50 hover:text-blue-700">
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
View
</a>
</div>
<!-- Completion badge -->
{% if show_completion and game.max_completion > 0 %}
<div class="absolute top-2 right-2">
<div class="bg-green-500 text-white text-xs font-bold px-2 py-1 rounded-full shadow-sm">
{{ game.max_completion }}%
</div>
</div>
{% endif %}
<!-- Platform count badge -->
{% if show_platforms %}
<div class="absolute bottom-2 left-2">
<div class="bg-black bg-opacity-70 text-white text-xs font-medium px-2 py-1 rounded-full backdrop-blur-sm">
{{ game.platform_count }}P
</div>
</div>
{% endif %}
</div>
<!-- Game Info -->
<div class="p-4">
<h3 class="text-sm font-bold text-gray-900 mb-1 line-clamp-2 group-hover:text-blue-600 transition-colors" title="{{ game.title }}">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="no-underline">
{{ game.title }}
</a>
</h3>
<!-- Playtime indicator -->
{% if show_playtime and game.total_playtime %}
<div class="flex items-center text-xs text-gray-600 mb-2">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ game.total_playtime|format_duration }}
</div>
{% endif %}
<!-- Platforms -->
{% if show_platforms and game.platforms %}
<div class="flex flex-wrap gap-1">
{% for platform in game.platforms|slice(0, 2) %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-700">
{{ platform }}
</span>
{% endfor %}
{% if game.platforms|length > 2 %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-700">
+{{ game.platforms|length - 2 }}
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% else %}
<!-- Grid View Card (Default) -->
<div class="group bg-white rounded-xl shadow-lg hover:shadow-2xl border border-gray-100 h-full overflow-hidden transition-all duration-300 hover:scale-[1.02] hover:-translate-y-1">
<!-- Image Section -->
<div class="relative overflow-hidden">
{% if game.image_url %}
<div class="aspect-[4/3] overflow-hidden">
<img class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" src="/images/playnite/{{ game.image_url }}" alt="{{ game.title }}">
</div>
{% else %}
<div class="aspect-[4/3] bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center">
<div class="text-center">
<svg class="text-slate-400 w-16 h-16 mx-auto mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<div class="text-xs text-slate-500 font-medium">No Cover</div>
</div>
</div>
{% endif %}
<!-- Platform badge -->
{% if show_platforms %}
<div class="absolute top-3 right-3 z-10">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-black bg-opacity-70 text-white backdrop-blur-sm">
{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}
</span>
</div>
{% endif %}
<!-- Overlay with quick actions -->
<div class="absolute inset-0 bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center z-20">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}"
class="opacity-0 group-hover:opacity-100 transform translate-y-4 group-hover:translate-y-0 transition-all duration-300 bg-white text-gray-900 px-4 py-2 rounded-lg font-medium text-sm shadow-lg hover:bg-blue-50 hover:text-blue-700">
View Details
</a>
</div>
</div>
<!-- Content Section -->
<div class="p-5">
<div class="mb-3">
<h3 class="text-lg font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-blue-600 transition-colors">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="no-underline">
{{ game.title }}
</a>
</h3>
{% if show_platforms and game.platforms %}
<div class="flex flex-wrap gap-1 mb-3">
{% for platform in game.platforms|slice(0, 2) %}
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
{{ platform }}
</span>
{% endfor %}
{% if game.platforms|length > 2 %}
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
+{{ game.platforms|length - 2 }} more
</span>
{% endif %}
</div>
{% endif %}
</div>
<!-- Progress and Stats -->
<div class="space-y-3">
{% if show_completion and game.max_completion > 0 %}
<div>
<div class="flex justify-between items-center mb-1">
<span class="text-xs font-medium text-gray-600">Completion</span>
<span class="text-xs font-bold text-gray-900">{{ game.max_completion }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-gradient-to-r from-green-400 to-green-600 h-2 rounded-full transition-all duration-500" style="width: {{ game.max_completion }}%"></div>
</div>
</div>
{% endif %}
{% if show_playtime and game.total_playtime %}
<div class="flex items-center justify-between">
<div class="flex items-center text-xs text-gray-600">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Playtime
</div>
<span class="text-xs font-semibold text-gray-900">{{ game.total_playtime|format_duration }}</span>
</div>
{% endif %}
</div>
<!-- Genres -->
{% if show_genres and game.genres %}
<div class="mt-4 flex flex-wrap gap-1">
{% for genre in game.genres|slice(0, 2) %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-blue-50 to-blue-100 text-blue-700 border border-blue-200">
{{ genre }}
</span>
{% endfor %}
{% if game.genres|length > 2 %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-gray-50 to-gray-100 text-gray-700 border border-gray-200">
+{{ game.genres|length - 2 }}
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,185 @@
{#
Movie Card Component
Parameters:
- movie: The movie object with all properties
- view_mode: 'grid', 'list', or 'covers' (optional, defaults to 'grid')
- show_rating: Whether to show rating (optional, defaults to true)
- show_runtime: Whether to show runtime (optional, defaults to true)
- show_genres: Whether to show genre tags (optional, defaults to true)
- show_watched: Whether to show watched status (optional, defaults to true)
#}
{% set view_mode = view_mode|default('grid') %}
{% set show_rating = show_rating|default(true) %}
{% set show_runtime = show_runtime|default(true) %}
{% set show_genres = show_genres|default(true) %}
{% set show_watched = show_watched|default(true) %}
{% if view_mode == 'list' %}
<!-- List View Card -->
<li class="px-4 py-3">
<div class="flex justify-between items-center">
<div class="flex items-center">
{% if movie.poster_url %}
<img class="rounded mr-3" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-gray-100 rounded mr-3 flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
<div class="flex-1">
<h3 class="text-sm font-semibold mb-1">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ movie.title }}
</a>
</h3>
<div class="flex items-center gap-3 text-sm text-gray-600">
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if show_rating and movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %}
{% if show_runtime and movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<span>{{ movie.source_name }}</span>
</div>
</div>
</div>
<div class="flex gap-2">
{% if show_watched and movie.watched %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</li>
{% elseif view_mode == 'covers' %}
<!-- Cover View Card -->
<div class="group relative bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
{% if movie.poster_url %}
<div class="relative aspect-[2/3] overflow-hidden">
<img src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
<!-- Overlay with movie info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute bottom-0 left-0 right-0 p-3">
{% if show_rating and movie.rating %}
<div class="flex items-center mb-2">
<svg class="w-4 h-4 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<span class="text-white text-sm font-medium">{{ movie.rating }}</span>
</div>
{% endif %}
{% if show_watched and movie.watched %}
<div class="flex items-center mb-1">
<svg class="w-3 h-3 text-green-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
<span class="text-green-400 text-xs">Watched</span>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 aspect-[2/3] min-h-[200px]">
<svg class="text-gray-400 w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
<div class="p-4">
<h6 class="text-sm font-bold truncate mb-1" title="{{ movie.title }}">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600 transition-colors">
{{ movie.title }}
</a>
</h6>
{% if movie.release_date %}
<p class="text-xs text-gray-600 font-medium">{{ movie.release_date|date('Y') }}</p>
{% endif %}
{% if show_genres and movie.genre %}
<div class="mt-2">
{% for genre in movie.genre|split(',')|slice(0, 2) %}
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium mr-1 mb-1">{{ genre|trim }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% else %}
<!-- Grid View Card (Default) -->
<div class="bg-white rounded-lg shadow-md border border-gray-200 h-full">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
{% if movie.poster_url %}
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-gray-100 rounded flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-3 flex-1">
<h5 class="text-lg font-semibold mb-1">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ movie.title }}
</a>
</h5>
<div class="flex items-center gap-2 text-sm text-gray-600 mb-2">
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if show_rating and movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %}
</div>
{% if movie.source_name %}
<p class="text-sm text-gray-600 mb-2">
{{ movie.source_name }}
</p>
{% endif %}
</div>
</div>
{% if movie.overview %}
<div class="mt-3">
<p class="text-sm text-gray-600" style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
{{ movie.overview|slice(0, 150) }}{% if movie.overview|length > 150 %}...{% endif %}
</p>
</div>
{% endif %}
<div class="mt-3 flex justify-between items-center text-sm text-gray-600">
{% if show_runtime and movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<div class="flex gap-1">
{% if show_watched and movie.watched %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,181 @@
{#
TV Show Card Component
Parameters:
- tvshow: The TV show object with all properties
- view_mode: 'grid', 'list', or 'covers' (optional, defaults to 'grid')
- show_rating: Whether to show rating (optional, defaults to true)
- show_seasons: Whether to show season count (optional, defaults to true)
- show_episodes: Whether to show episode count (optional, defaults to true)
- show_genres: Whether to show genre tags (optional, defaults to true)
#}
{% set view_mode = view_mode|default('grid') %}
{% set show_rating = show_rating|default(true) %}
{% set show_seasons = show_seasons|default(true) %}
{% set show_episodes = show_episodes|default(true) %}
{% set show_genres = show_genres|default(true) %}
{% if view_mode == 'list' %}
<!-- List View Card -->
<li class="px-4 py-3">
<div class="flex justify-between items-center">
<div class="flex items-center">
{% if tvshow.poster_url %}
<img class="rounded mr-3" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ tvshow.poster_url }}" alt="{{ tvshow.title }}">
{% else %}
<div class="bg-gray-100 rounded mr-3 flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
<div class="flex-1">
<h3 class="text-sm font-semibold mb-1">
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ tvshow.title }}
</a>
</h3>
<div class="flex items-center gap-3 text-sm text-gray-600">
{% if tvshow.first_air_date %}
<span>{{ tvshow.first_air_date|date('Y') }}</span>
{% endif %}
{% if show_rating and tvshow.rating %}
<span>⭐ {{ tvshow.rating }}/10</span>
{% endif %}
{% if show_seasons and tvshow.number_of_seasons %}
<span>{{ tvshow.number_of_seasons }} season{{ tvshow.number_of_seasons > 1 ? 's' : '' }}</span>
{% endif %}
{% if show_episodes and tvshow.number_of_episodes %}
<span>{{ tvshow.number_of_episodes }} episode{{ tvshow.number_of_episodes > 1 ? 's' : '' }}</span>
{% endif %}
<span>{{ tvshow.source_name }}</span>
</div>
</div>
</div>
<div class="flex gap-2">
{% if tvshow.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</li>
{% elseif view_mode == 'covers' %}
<!-- Cover View Card -->
<div class="group relative bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
{% if tvshow.poster_url %}
<div class="relative aspect-[2/3] overflow-hidden">
<img src="/images/{{ tvshow.poster_url }}" alt="{{ tvshow.title }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
<!-- Overlay with TV show info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute bottom-0 left-0 right-0 p-3">
{% if show_rating and tvshow.rating %}
<div class="flex items-center mb-2">
<svg class="w-4 h-4 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<span class="text-white text-sm font-medium">{{ tvshow.rating }}</span>
</div>
{% endif %}
{% if show_seasons and tvshow.number_of_seasons %}
<div class="flex items-center mb-1">
<svg class="w-3 h-3 text-blue-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" clip-rule="evenodd"/>
</svg>
<span class="text-blue-400 text-xs">{{ tvshow.number_of_seasons }} season{{ tvshow.number_of_seasons > 1 ? 's' : '' }}</span>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 aspect-[2/3] min-h-[200px]">
<svg class="text-gray-400 w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
<div class="p-4">
<h6 class="text-sm font-bold truncate mb-1" title="{{ tvshow.title }}">
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="no-underline text-gray-900 hover:text-blue-600 transition-colors">
{{ tvshow.title }}
</a>
</h6>
{% if tvshow.first_air_date %}
<p class="text-xs text-gray-600 font-medium">{{ tvshow.first_air_date|date('Y') }}</p>
{% endif %}
{% if show_genres and tvshow.genre %}
<div class="mt-2">
{% for genre in tvshow.genre|split(',')|slice(0, 2) %}
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium mr-1 mb-1">{{ genre|trim }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% else %}
<!-- Grid View Card (Default) -->
<div class="bg-white rounded-lg shadow-md border border-gray-200 h-full">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
{% if tvshow.poster_url %}
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ tvshow.poster_url }}" alt="{{ tvshow.title }}">
{% else %}
<div class="bg-gray-100 rounded flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-3 flex-1">
<h5 class="text-lg font-semibold mb-1">
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ tvshow.title }}
</a>
</h5>
<div class="flex items-center gap-2 text-sm text-gray-600 mb-2">
{% if tvshow.first_air_date %}
<span>{{ tvshow.first_air_date|date('Y') }}</span>
{% endif %}
{% if show_rating and tvshow.rating %}
<span>⭐ {{ tvshow.rating }}/10</span>
{% endif %}
</div>
{% if tvshow.source_name %}
<p class="text-sm text-gray-600 mb-2">
{{ tvshow.source_name }}
</p>
{% endif %}
</div>
</div>
{% if tvshow.overview %}
<div class="mt-3">
<p class="text-sm text-gray-600" style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
{{ tvshow.overview|slice(0, 150) }}{% if tvshow.overview|length > 150 %}...{% endif %}
</p>
</div>
{% endif %}
<div class="mt-3 flex justify-between items-center text-sm text-gray-600">
{% if show_seasons and tvshow.number_of_seasons %}
<span>{{ tvshow.number_of_seasons }} season{{ tvshow.number_of_seasons > 1 ? 's' : '' }}</span>
{% endif %}
{% if show_episodes and tvshow.number_of_episodes %}
<span>{{ tvshow.number_of_episodes }} episode{{ tvshow.number_of_episodes > 1 ? 's' : '' }}</span>
{% endif %}
<div class="flex gap-1">
{% if tvshow.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}

View File

@@ -140,6 +140,40 @@
</div>
{% endif %}
<!-- Size slider -->
<div class="flex items-center gap-2 bg-gray-100 rounded-lg px-3 py-2" x-data="{
size: localStorage.getItem('gameCardSize') || 'medium',
sizes: {
small: { label: 'S', cols: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6', scale: 'scale-90' },
medium: { label: 'M', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', scale: 'scale-100' },
large: { label: 'L', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', scale: 'scale-110' },
xlarge: { label: 'XL', cols: 'grid-cols-1 md:grid-cols-2', scale: 'scale-125' }
},
updateSize(newSize) {
this.size = newSize;
localStorage.setItem('gameCardSize', newSize);
// Dispatch custom event to update grid
window.dispatchEvent(new CustomEvent('gameCardSizeChanged', { detail: { sizeKey: newSize, sizeData: this.sizes[newSize] } }));
}
}" @mounted="updateSize(size)">
<svg class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
</svg>
<span class="text-xs font-medium text-gray-600 hidden sm:inline mr-1">Size:</span>
<div class="flex items-center gap-1">
<template x-for="(sizeData, sizeKey) in sizes" :key="sizeKey">
<button
@click="updateSize(sizeKey)"
:class="size === sizeKey ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'"
class="px-2 py-1 text-xs font-medium rounded transition-all duration-200"
:title="'Set card size to ' + sizeKey"
>
<span x-text="sizeData.label"></span>
</button>
</template>
</div>
</div>
<!-- Sort dropdown -->
<div class="relative gap-1 bg-gray-100 rounded-lg p-1" x-data="{ open: false }">
<button @click="open = !open" class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-all duration-200 {{ view_mode == mode ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' }}">
@@ -330,194 +364,213 @@
{% block content %}
<!-- Main content area -->
<div class="p-6">
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
<div class="p-6">
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900">Games</h1>
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-4xl font-bold bg-gradient-to-r from-slate-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent">Games Library</h1>
{% if pagination.total_items > 0 %}
<div class="text-gray-500 text-sm mt-1">
<div class="text-slate-600 text-sm mt-2 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
{{ pagination.total_items }} games from {{ games|reduce((carry, game) => carry + game.platform_count, 0) }} platforms
{% if search %}
matching "{{ search }}"
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
"{{ search }}"
</span>
{% endif %}
{% if filters.genres or filters.platforms or filters.features or filters.playtime %}
</div>
{% endif %}
</div>
{% if pagination.total_items > 0 %}
<div class="flex flex-wrap gap-2">
{% if filters.genres %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">{{ filters.genres|join(', ') }}</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a.997.997 0 01-1.414 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
{{ filters.genres|join(', ') }}
</span>
{% endif %}
{% if filters.platforms %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 ml-2">{{ filters.platforms|join(', ') }}</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-gray-500 to-gray-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
{{ filters.platforms|join(', ') }}
</span>
{% endif %}
{% if filters.features %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 ml-2">{{ filters.features|join(', ') }}</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-green-500 to-green-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ filters.features|join(', ') }}
</span>
{% endif %}
{% if filters.playtime %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 ml-2">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-purple-500 to-purple-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% if filters.playtime == 'none' %}No playtime{% elseif filters.playtime == 'under_1h' %}Under 1h{% elseif filters.playtime == '1h_5h' %}1-5h{% elseif filters.playtime == '5h_10h' %}5-10h{% elseif filters.playtime == '10h_20h' %}10-20h{% elseif filters.playtime == 'over_20h' %}20h+{% endif %}
</span>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
</div>
{% if games is empty %}
<div class="text-center py-5">
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
<!-- Enhanced empty state -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="relative mb-8">
<!-- Animated background circles -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-32 h-32 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full animate-pulse"></div>
<div class="absolute w-24 h-24 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-full animate-pulse animation-delay-1000"></div>
<div class="absolute w-16 h-16 bg-gradient-to-br from-purple-100 to-pink-100 rounded-full animate-pulse animation-delay-2000"></div>
</div>
<!-- Main icon -->
<div class="relative bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<svg class="w-20 h-20 text-slate-400 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<h3 class="h5 fw-medium text-dark">
</div>
</div>
<div class="text-center max-w-md">
<h3 class="text-2xl font-bold text-gray-900 mb-3">
{% if search or filters.genres or filters.platforms or filters.features or filters.playtime %}
No games found matching your criteria
{% else %}
No games found
{% else %}
Your library is empty
{% endif %}
</h3>
<p class="text-muted">
<p class="text-slate-600 text-lg mb-8 leading-relaxed">
{% if search or filters.genres or filters.platforms or filters.features or filters.playtime %}
Try adjusting your search terms or filters.
We couldn't find any games matching your current search and filter criteria. Try adjusting your filters or search terms to discover more games.
{% else %}
Start syncing your gaming libraries to see your games here.
Start building your gaming library by syncing with Playnite or other gaming platforms. Your games will appear here once synced.
{% endif %}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% if search or filters.genres or filters.platforms or filters.features or filters.playtime %}
<a href="{{ path_for('games.index') }}" class="btn btn-primary mt-3">
Clear filters
<a href="{{ path_for('games.index') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-blue-800 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Clear Filters
</a>
{% else %}
<a href="{{ path_for('admin.index') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-semibold rounded-xl hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Setup Sync
</a>
{% endif %}
<a href="{{ path_for('search.index') }}"
class="inline-flex items-center px-6 py-3 bg-white text-gray-700 font-semibold rounded-xl border-2 border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition-all duration-200 shadow-sm hover:shadow-md">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
Search All Media
</a>
</div>
<!-- Additional help text -->
{% if not (search or filters.genres or filters.platforms or filters.features or filters.playtime) %}
<div class="mt-8 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-left">
<p class="text-sm font-medium text-blue-900 mb-1">Need help getting started?</p>
<p class="text-sm text-blue-700">Check out the Playnite plugin documentation or contact support for assistance with syncing your gaming libraries.</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% else %}
<!-- Games content based on view mode -->
{% if view_mode == 'list' %}
<!-- List view -->
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<ul class="divide-y divide-gray-200">
<!-- Enhanced list view using component -->
<div class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
<ul class="divide-y divide-gray-100">
{% for game in games %}
<li class="px-4 py-3">
<div class="flex justify-between items-center">
<div class="flex items-center">
{% if game.image_url %}
<img class="rounded mr-3" style="width: 64px; height: 64px; object-fit: cover;" src="/images/playnite/{{ game.image_url }}" alt="{{ game.title }}">
{% else %}
<div class="bg-gray-100 rounded mr-3 flex items-center justify-center" style="width: 64px; height: 64px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
<div class="flex-1">
<h3 class="text-sm font-semibold mb-1">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ game.title }}
</a>
</h3>
<div class="flex items-center gap-3 text-sm text-gray-600">
<span>{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</span>
{% if game.platforms %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ game.platforms|join(', ') }}
</span>
{% endif %}
<span>{{ game.total_playtime|format_duration }} played</span>
{% if game.max_completion > 0 %}
<span>{{ game.max_completion }}% complete</span>
{% endif %}
</div>
</div>
</div>
{% if game.genres %}
<div class="flex flex-wrap gap-1">
{% for genre in game.genres|slice(0, 3) %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ genre }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</li>
{% include 'components/game-card.twig' with {'game': game, 'view_mode': 'list'} %}
{% endfor %}
</ul>
</div>
{% elseif view_mode == 'covers' %}
<!-- Cover grid view -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<!-- Enhanced cover grid view using component with dynamic sizing -->
<div
class="grid gap-6 transition-all duration-300"
x-data="{
currentSize: localStorage.getItem('gameCardSize') || 'medium',
sizeClasses: {
small: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
medium: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
large: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5',
xlarge: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
}
}"
:class="sizeClasses[currentSize]"
x-init="
window.addEventListener('gameCardSizeChanged', (e) => {
currentSize = e.detail.sizeKey;
});
// Set initial class
$el.classList.add(...sizeClasses[currentSize].split(' '));
"
>
{% for game in games %}
<div class="bg-white rounded-lg shadow-md border border-gray-200 overflow-hidden h-full">
{% if game.image_url %}
<div class="relative {{ game.cover_aspect_ratio ? 'aspect-custom' : 'aspect-[3/4]' }} overflow-hidden"{% if game.cover_aspect_ratio %} style="padding-bottom: {{ (1 / game.cover_aspect_ratio) * 100 }}%;"{% endif %}>
<img src="/images/playnite/{{ game.image_url }}" alt="{{ game.title }}" class="w-full h-full object-cover">
</div>
{% else %}
<div class="flex items-center justify-center bg-gray-100 aspect-[3/4] min-h-[200px]">
<svg class="text-gray-600" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
<div class="p-3">
<h6 class="text-sm font-semibold truncate" title="{{ game.title }}">
{{ game.title }}
</h6>
<p class="text-xs text-gray-600 mt-1">{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</p>
</div>
</div>
{% include 'components/game-card.twig' with {'game': game, 'view_mode': 'covers'} %}
{% endfor %}
</div>
{% else %}
<!-- Default grid view -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Enhanced grid view using component with dynamic sizing -->
<div
class="grid gap-6 transition-all duration-300"
x-data="{
currentSize: localStorage.getItem('gameCardSize') || 'medium',
sizeClasses: {
small: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
medium: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
large: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
xlarge: 'grid-cols-1 md:grid-cols-2'
}
}"
:class="sizeClasses[currentSize]"
x-init="
window.addEventListener('gameCardSizeChanged', (e) => {
currentSize = e.detail.sizeKey;
});
// Set initial class
$el.classList.add(...sizeClasses[currentSize].split(' '));
"
>
{% for game in games %}
<div class="bg-white rounded-lg shadow-md border border-gray-200 h-full">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
{% if game.image_url %}
<img class="rounded" style="width: 64px; height: 64px; object-fit: cover;" src="/images/playnite/{{ game.image_url }}" alt="{{ game.title }}">
{% else %}
<div class="bg-gray-100 rounded flex items-center justify-center" style="width: 64px; height: 64px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-3 flex-1">
<h5 class="text-lg font-semibold mb-1">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ game.title }}
</a>
</h5>
<p class="text-sm text-gray-600 mb-2">
{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}
{% if game.platforms %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 ml-2">
{{ game.platforms|join(', ') }}
</span>
{% endif %}
</p>
</div>
</div>
<div class="mt-3">
<div class="flex justify-between items-center text-sm text-gray-600">
<span>{{ game.total_playtime|format_duration }} played</span>
{% if game.max_completion > 0 %}
<span>{{ game.max_completion }}% complete</span>
{% endif %}
</div>
{% if game.genres %}
<div class="mt-2 flex flex-wrap gap-1">
{% for genre in game.genres|slice(0, 3) %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ genre }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% include 'components/game-card.twig' with {'game': game, 'view_mode': 'grid'} %}
{% endfor %}
</div>
{% endif %}
@@ -639,6 +692,23 @@
.select2-selection__choice__remove:hover {
color: #f8f9fa !important;
}
/* Animation delays for empty state */
.animation-delay-1000 {
animation-delay: 1s;
}
.animation-delay-2000 {
animation-delay: 2s;
}
/* Line clamping utilities */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<script>

View File

@@ -18,7 +18,7 @@
<div class="flex gap-1 bg-gray-100 rounded-lg p-1" role="group">
{% for mode in view_modes %}
<a
href="?view={{ mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for year in filters.years %}&years[]={{ year }}{% endfor %}"
href="?view={{ mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-all duration-200 {{ view_mode == mode ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' }}"
title="{{ mode|title }} View"
>
@@ -42,6 +42,40 @@
{% endfor %}
</div>
<!-- Size slider -->
<div class="flex items-center gap-2 bg-gray-100 rounded-lg px-3 py-2" x-data="{
size: localStorage.getItem('movieCardSize') || 'medium',
sizes: {
small: { label: 'S', cols: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6', scale: 'scale-90' },
medium: { label: 'M', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6', scale: 'scale-100' },
large: { label: 'L', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', scale: 'scale-110' },
xlarge: { label: 'XL', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', scale: 'scale-125' }
},
updateSize(newSize) {
this.size = newSize;
localStorage.setItem('movieCardSize', newSize);
// Dispatch custom event to update grid
window.dispatchEvent(new CustomEvent('movieCardSizeChanged', { detail: { sizeKey: newSize, sizeData: this.sizes[newSize] } }));
}
}" @mounted="updateSize(size)">
<svg class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
</svg>
<span class="text-xs font-medium text-gray-600 hidden sm:inline mr-1">Size:</span>
<div class="flex items-center gap-1">
<template x-for="(sizeData, sizeKey) in sizes" :key="sizeKey">
<button
@click="updateSize(sizeKey)"
:class="size === sizeKey ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'"
class="px-2 py-1 text-xs font-medium rounded transition-all duration-200"
:title="'Set card size to ' + sizeKey"
>
<span x-text="sizeData.label"></span>
</button>
</template>
</div>
</div>
<!-- Sort dropdown -->
<div class="relative gap-1 bg-gray-100 rounded-lg p-1" x-data="{ open: false }">
<button @click="open = !open" class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-all duration-200 {{ view_mode == mode ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' }}">
@@ -240,233 +274,197 @@
{% block content %}
<!-- Main content area -->
<div class="p-6">
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
<div class="p-6">
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900">Movies</h1>
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-4xl font-bold bg-gradient-to-r from-slate-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent">Movies Library</h1>
{% if pagination.total_items > 0 %}
<div class="text-gray-500 text-sm mt-1">
{{ pagination.total_items }} movies
<div class="text-slate-600 text-sm mt-2 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ pagination.total_items }} movies from {{ movies|length > 0 ? movies|reduce((carry, movie) => carry + 1, 0) : 0 }} sources
{% if search %}
matching "{{ search }}"
{% endif %}
{% if filters.genres or filters.directors %}
{% if filters.genres %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">{{ filters.genres|join(', ') }}</span>
{% endif %}
{% if filters.directors %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 ml-2">{{ filters.directors|join(', ') }}</span>
{% endif %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
"{{ search }}"
</span>
{% endif %}
</div>
{% endif %}
</div>
{% if pagination.total_items > 0 %}
<div class="flex flex-wrap gap-2">
{% if filters.genres %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a.997.997 0 01-1.414 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
{{ filters.genres|join(', ') }}
</span>
{% endif %}
{% if filters.directors %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-purple-500 to-purple-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
{{ filters.directors|join(', ') }}
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% if movies is empty %}
<div class="text-center py-12">
<svg class="mx-auto text-gray-400 mb-4 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
<!-- Enhanced empty state -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="relative mb-8">
<!-- Animated background circles -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-32 h-32 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full animate-pulse"></div>
<div class="absolute w-24 h-24 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-full animate-pulse animation-delay-1000"></div>
<div class="absolute w-16 h-16 bg-gradient-to-br from-purple-100 to-pink-100 rounded-full animate-pulse animation-delay-2000"></div>
</div>
<!-- Main icon -->
<div class="relative bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<svg class="w-20 h-20 text-slate-400 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">
</div>
</div>
<div class="text-center max-w-md">
<h3 class="text-2xl font-bold text-gray-900 mb-3">
{% if search or filters.genres or filters.directors %}
No movies found matching your criteria
{% else %}
No movies found
{% else %}
Your library is empty
{% endif %}
</h3>
<p class="text-gray-500 mb-4">
<p class="text-slate-600 text-lg mb-8 leading-relaxed">
{% if search or filters.genres or filters.directors %}
Try adjusting your search terms or filters.
We couldn't find any movies matching your current search and filter criteria. Try adjusting your filters or search terms to discover more movies.
{% else %}
Start syncing your movie libraries to see your movies here.
Start building your movie library by syncing with Jellyfin, Plex, or other media sources. Your movies will appear here once synced.
{% endif %}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% if search or filters.genres or filters.directors %}
<a href="{{ path_for('movies.index') }}" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Clear filters
<a href="{{ path_for('movies.index') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-blue-800 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Clear Filters
</a>
{% else %}
<a href="{{ path_for('admin.index') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-semibold rounded-xl hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Setup Sync
</a>
{% endif %}
<a href="{{ path_for('search.index') }}"
class="inline-flex items-center px-6 py-3 bg-white text-gray-700 font-semibold rounded-xl border-2 border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition-all duration-200 shadow-sm hover:shadow-md">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
Search All Media
</a>
</div>
<!-- Additional help text -->
{% if not (search or filters.genres or filters.directors) %}
<div class="mt-8 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-left">
<p class="text-sm font-medium text-blue-900 mb-1">Need help getting started?</p>
<p class="text-sm text-blue-700">Check out the Jellyfin or Plex integration documentation for assistance with syncing your movie libraries.</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% else %}
<!-- Movies content based on view mode -->
{% if view_mode == 'list' %}
<!-- List view -->
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<ul class="divide-y divide-gray-200">
<!-- Enhanced list view using component -->
<div class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
<ul class="divide-y divide-gray-100">
{% for movie in movies %}
<li class="px-4 py-3">
<div class="flex justify-between items-center">
<div class="flex items-center">
{% if movie.poster_url %}
<img class="rounded mr-3" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-gray-100 rounded mr-3 flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
<div class="flex-1">
<h3 class="text-sm font-semibold mb-1">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ movie.title }}
</a>
</h3>
<div class="flex items-center gap-3 text-sm text-gray-600">
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %}
{% if movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<span>{{ movie.source_name }}</span>
</div>
</div>
</div>
<div class="flex gap-2">
{% if movie.watched %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</li>
{% include 'components/movie-card.twig' with {'movie': movie, 'view_mode': 'list'} %}
{% endfor %}
</ul>
</div>
{% elseif view_mode == 'covers' %}
<!-- Enhanced Cover grid view -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
<!-- Enhanced cover grid view using component with dynamic sizing -->
<div
class="grid gap-6 transition-all duration-300"
x-data="{
currentSize: localStorage.getItem('movieCardSize') || 'medium',
sizeClasses: {
small: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
medium: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
large: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5',
xlarge: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
}
}"
:class="sizeClasses[currentSize]"
x-init="
window.addEventListener('movieCardSizeChanged', (e) => {
currentSize = e.detail.sizeKey;
});
// Set initial class
$el.classList.add(...sizeClasses[currentSize].split(' '));
"
>
{% for movie in movies %}
<div class="group relative bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
{% if movie.poster_url %}
<div class="relative aspect-[2/3] overflow-hidden">
<img src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
<!-- Overlay with movie info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute bottom-0 left-0 right-0 p-3">
{% if movie.rating %}
<div class="flex items-center mb-2">
<svg class="w-4 h-4 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<span class="text-white text-sm font-medium">{{ movie.rating }}</span>
</div>
{% endif %}
{% if movie.watched %}
<div class="flex items-center mb-1">
<svg class="w-3 h-3 text-green-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
<span class="text-green-400 text-xs">Watched</span>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 aspect-[2/3] min-h-[200px]">
<svg class="text-gray-400 w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
<div class="p-4">
<h6 class="text-sm font-bold truncate mb-1" title="{{ movie.title }}">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600 transition-colors">
{{ movie.title }}
</a>
</h6>
{% if movie.release_date %}
<p class="text-xs text-gray-600 font-medium">{{ movie.release_date|date('Y') }}</p>
{% endif %}
{% if movie.genre %}
<div class="mt-2">
{% for genre in movie.genre|split(',')|slice(0, 2) %}
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium mr-1 mb-1">{{ genre|trim }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% include 'components/movie-card.twig' with {'movie': movie, 'view_mode': 'covers'} %}
{% endfor %}
</div>
{% else %}
<!-- Default grid view -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<!-- Enhanced grid view using component with dynamic sizing -->
<div
class="grid gap-6 transition-all duration-300"
x-data="{
currentSize: localStorage.getItem('movieCardSize') || 'medium',
sizeClasses: {
small: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
medium: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
large: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
xlarge: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
}
}"
:class="sizeClasses[currentSize]"
x-init="
window.addEventListener('movieCardSizeChanged', (e) => {
currentSize = e.detail.sizeKey;
});
// Set initial class
$el.classList.add(...sizeClasses[currentSize].split(' '));
"
>
{% for movie in movies %}
<div class="bg-white rounded-lg shadow-md border border-gray-200 h-full">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
{% if movie.poster_url %}
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-gray-100 rounded flex items-center justify-center" style="width: 64px; height: 96px;">
<svg class="text-gray-600" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-3 flex-1">
<h5 class="text-lg font-semibold mb-1">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ movie.title }}
</a>
</h5>
<div class="flex items-center gap-2 text-sm text-gray-600 mb-2">
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %}
</div>
{% if movie.source_name %}
<p class="text-sm text-gray-600 mb-2">
{{ movie.source_name }}
</p>
{% endif %}
</div>
</div>
{% if movie.overview %}
<div class="mt-3">
<p class="text-sm text-gray-600" style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
{{ movie.overview|slice(0, 150) }}{% if movie.overview|length > 150 %}...{% endif %}
</p>
</div>
{% endif %}
<div class="mt-3 flex justify-between items-center text-sm text-gray-600">
{% if movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<div class="flex gap-1">
{% if movie.watched %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Favorite
</span>
{% endif %}
</div>
</div>
</div>
</div>
{% include 'components/movie-card.twig' with {'movie': movie, 'view_mode': 'grid'} %}
{% endfor %}
</div>
{% endif %}

View File

@@ -1,8 +1,8 @@
{% extends "layouts/app.twig" %}
{% block nav_controls %}
<!-- Enhanced Search form -->
<form method="GET" class="flex gap-3 items-center">
<!-- Search form -->
<form method="GET" class="flex gap-2">
<input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
{% for genre in filters.genres %}
@@ -13,6 +13,7 @@
{% endfor %}
</form>
<!-- Enhanced View mode switcher -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1" role="group">
{% for mode in view_modes %}
@@ -40,6 +41,41 @@
</a>
{% endfor %}
</div>
<!-- Size slider -->
<div class="flex items-center gap-2 bg-gray-100 rounded-lg px-3 py-2" x-data="{
size: localStorage.getItem('tvshowCardSize') || 'medium',
sizes: {
small: { label: 'S', cols: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6', scale: 'scale-90' },
medium: { label: 'M', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6', scale: 'scale-100' },
large: { label: 'L', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', scale: 'scale-110' },
xlarge: { label: 'XL', cols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', scale: 'scale-125' }
},
updateSize(newSize) {
this.size = newSize;
localStorage.setItem('tvshowCardSize', newSize);
// Dispatch custom event to update grid
window.dispatchEvent(new CustomEvent('tvshowCardSizeChanged', { detail: { sizeKey: newSize, sizeData: this.sizes[newSize] } }));
}
}" @mounted="updateSize(size)">
<svg class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
</svg>
<span class="text-xs font-medium text-gray-600 hidden sm:inline mr-1">Size:</span>
<div class="flex items-center gap-1">
<template x-for="(sizeData, sizeKey) in sizes" :key="sizeKey">
<button
@click="updateSize(sizeKey)"
:class="size === sizeKey ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'"
class="px-2 py-1 text-xs font-medium rounded transition-all duration-200"
:title="'Set card size to ' + sizeKey"
>
<span x-text="sizeData.label"></span>
</button>
</template>
</div>
</div>
<!-- Sort dropdown -->
<div class="relative gap-1 bg-gray-100 rounded-lg p-1" x-data="{ open: false }">
<button @click="open = !open" class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-all duration-200 {{ view_mode == mode ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' }}">
@@ -52,7 +88,7 @@
<div class="py-1">
{% for key, label in sort_options %}
<a class="flex items-center justify-between px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 {{ sort == key ? 'bg-gray-50' : '' }}"
href="?sort={{ key }}&view={{ view_mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}">
href="?sort={{ key }}&view={{ view_mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for year in filters.years %}&years[]={{ year }}{% endfor %}">
{{ label }}
{% if sort == key %}
<svg class="h-4 w-4 text-blue-600" fill="currentColor" viewBox="0 0 16 16">
@@ -182,340 +218,197 @@
{% block content %}
<!-- Main content area -->
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100" data-content>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
<div class="p-6">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-4xl font-bold text-gray-900 mb-2">TV Shows</h1>
<h1 class="text-4xl font-bold bg-gradient-to-r from-slate-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent">TV Shows Library</h1>
{% if pagination.total_items > 0 %}
<div class="flex items-center gap-3 text-gray-600">
<span class="text-lg">{{ pagination.total_items }} TV shows</span>
<div class="text-slate-600 text-sm mt-2 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ pagination.total_items }} TV shows from {{ tvshows|length > 0 ? tvshows|reduce((carry, tvshow) => carry + 1, 0) : 0 }} sources
{% if search %}
<span class="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full">matching "{{ search }}"</span>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
"{{ search }}"
</span>
{% endif %}
</div>
{% endif %}
</div>
{% if pagination.total_items > 0 %}
<div class="flex flex-wrap gap-2">
{% if filters.genres %}
<span class="text-sm bg-purple-50 text-purple-700 px-3 py-1 rounded-full">{{ filters.genres|join(', ') }}</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a.997.997 0 01-1.414 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
{{ filters.genres|join(', ') }}
</span>
{% endif %}
{% if filters.years %}
<span class="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full">{{ filters.years|join(', ') }}</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-green-500 to-green-600 text-white shadow-sm">
<svg class="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ filters.years|join(', ') }}
</span>
{% endif %}
</div>
{% endif %}
</div>
<!-- Sort dropdown -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<label for="sort" class="text-sm font-medium text-gray-700">Sort by:</label>
<select id="sort" class="text-sm border border-gray-300 rounded-lg px-3 py-2 bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
<option value="title_asc" {{ sort == 'title_asc' ? 'selected' : '' }}>Title A-Z</option>
<option value="title_desc" {{ sort == 'title_desc' ? 'selected' : '' }}>Title Z-A</option>
<option value="year_desc" {{ sort == 'year_desc' ? 'selected' : '' }}>Newest First</option>
<option value="year_asc" {{ sort == 'year_asc' ? 'selected' : '' }}>Oldest First</option>
<option value="rating_desc" {{ sort == 'rating_desc' ? 'selected' : '' }}>Highest Rated</option>
<option value="rating_asc" {{ sort == 'rating_asc' ? 'selected' : '' }}>Lowest Rated</option>
</select>
</div>
</div>
</div>
</div>
{% if tvshows is empty %}
<div class="text-center py-16">
<div class="mx-auto w-24 h-24 bg-gradient-to-br from-gray-100 to-gray-200 rounded-full flex items-center justify-center mb-6">
<svg class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
<!-- Enhanced empty state -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="relative mb-8">
<!-- Animated background circles -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-32 h-32 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full animate-pulse"></div>
<div class="absolute w-24 h-24 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-full animate-pulse animation-delay-1000"></div>
<div class="absolute w-16 h-16 bg-gradient-to-br from-purple-100 to-pink-100 rounded-full animate-pulse animation-delay-2000"></div>
</div>
<!-- Main icon -->
<div class="relative bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<svg class="w-20 h-20 text-slate-400 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
<h3 class="text-2xl font-semibold text-gray-900 mb-3">
</div>
<div class="text-center max-w-md">
<h3 class="text-2xl font-bold text-gray-900 mb-3">
{% if search or filters.genres or filters.years %}
No TV shows found matching your criteria
{% else %}
No TV shows found
{% else %}
Your library is empty
{% endif %}
</h3>
<p class="text-gray-600 text-lg mb-6 max-w-md mx-auto">
<p class="text-slate-600 text-lg mb-8 leading-relaxed">
{% if search or filters.genres or filters.years %}
Try adjusting your search terms or filters to find what you're looking for.
We couldn't find any TV shows matching your current search and filter criteria. Try adjusting your filters or search terms to discover more TV shows.
{% else %}
Start syncing your TV show libraries to see your collection here.
Start building your TV show library by syncing with Jellyfin, Plex, or other media sources. Your TV shows will appear here once synced.
{% endif %}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% if search or filters.genres or filters.years %}
<a href="{{ path_for('tvshows.index') }}" class="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
<a href="{{ path_for('tvshows.index') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-blue-800 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Clear filters
Clear Filters
</a>
{% else %}
<a href="{{ path_for('admin.index') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-semibold rounded-xl hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Setup Sync
</a>
{% endif %}
<a href="{{ path_for('search.index') }}"
class="inline-flex items-center px-6 py-3 bg-white text-gray-700 font-semibold rounded-xl border-2 border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition-all duration-200 shadow-sm hover:shadow-md">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
Search All Media
</a>
</div>
<!-- Additional help text -->
{% if not (search or filters.genres or filters.years) %}
<div class="mt-8 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-left">
<p class="text-sm font-medium text-blue-900 mb-1">Need help getting started?</p>
<p class="text-sm text-blue-700">Check out the Jellyfin or Plex integration documentation for assistance with syncing your TV show libraries.</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% else %}
<!-- TV Shows content based on view mode -->
{% if view_mode == 'list' %}
<!-- Enhanced List view -->
<div class="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden">
<!-- Enhanced list view using component -->
<div class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
<ul class="divide-y divide-gray-100">
{% for tvshow in tvshows %}
<li class="group hover:bg-gray-50 transition-colors duration-200">
<div class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4 flex-1">
<!-- Poster -->
<div class="flex-shrink-0">
{% if tvshow.poster_url %}
<div class="relative overflow-hidden rounded-lg shadow-sm group-hover:shadow-md transition-shadow duration-200">
<img class="w-16 h-24 object-cover transform group-hover:scale-105 transition-transform duration-200" src="/images/{{ tvshow.poster_url }}" alt="{{ tvshow.title }}">
</div>
{% else %}
<div class="w-16 h-24 bg-gradient-to-br from-gray-100 to-gray-200 rounded-lg flex items-center justify-center shadow-sm">
<svg class="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-1 group-hover:text-blue-600 transition-colors">
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="block truncate">
{{ tvshow.title }}
</a>
</h3>
<!-- Metadata row -->
<div class="flex items-center gap-4 text-sm text-gray-600 mb-2">
{% if tvshow.first_air_date %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span>{{ tvshow.first_air_date|date('Y') }}</span>
</div>
{% endif %}
{% if tvshow.rating %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<span>{{ tvshow.rating }}/10</span>
</div>
{% endif %}
{% if tvshow.number_of_seasons %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
<span>{{ tvshow.number_of_seasons }} season{{ tvshow.number_of_seasons > 1 ? 's' : '' }}</span>
</div>
{% endif %}
{% if tvshow.number_of_episodes %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<span>{{ tvshow.number_of_episodes }} episode{{ tvshow.number_of_episodes > 1 ? 's' : '' }}</span>
</div>
{% endif %}
</div>
<!-- Overview preview -->
{% if tvshow.overview %}
<p class="text-sm text-gray-600 line-clamp-2 mb-2">
{{ tvshow.overview|slice(0, 120) }}{% if tvshow.overview|length > 120 %}...{% endif %}
</p>
{% endif %}
<!-- Source -->
<div class="flex items-center gap-1 text-xs text-gray-500">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
<span>{{ tvshow.source_name }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
{% if tvshow.is_favorite %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 border border-red-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
Favorite
</span>
{% endif %}
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors opacity-0 group-hover:opacity-100 transform translate-x-2 group-hover:translate-x-0 transition-all duration-200">
View Details
<svg class="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</div>
</li>
{% include 'components/tvshow-card.twig' with {'tvshow': tvshow, 'view_mode': 'list'} %}
{% endfor %}
</ul>
</div>
{% elseif view_mode == 'covers' %}
<!-- Cover grid view -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<!-- Enhanced cover grid view using component with dynamic sizing -->
<div
class="grid gap-6 transition-all duration-300"
x-data="{
currentSize: localStorage.getItem('tvshowCardSize') || 'medium',
sizeClasses: {
small: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
medium: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
large: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5',
xlarge: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
}
}"
:class="sizeClasses[currentSize]"
x-init="
window.addEventListener('tvshowCardSizeChanged', (e) => {
currentSize = e.detail.sizeKey;
});
// Set initial class
$el.classList.add(...sizeClasses[currentSize].split(' '));
"
>
{% for tvshow in tvshows %}
<div class="bg-white rounded-lg shadow-md border border-gray-200 overflow-hidden h-full">
{% if tvshow.poster_url %}
<div class="relative aspect-[2/3] overflow-hidden">
<img src="/images/{{ tvshow.poster_url }}" alt="{{ tvshow.title }}" class="w-full h-full object-cover">
</div>
{% else %}
<div class="flex items-center justify-center bg-gray-100 aspect-[2/3] min-h-[200px]">
<svg class="text-gray-600" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
<div class="p-3">
<h6 class="text-sm font-semibold truncate" title="{{ tvshow.title }}">
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="no-underline text-gray-900 hover:text-blue-600">
{{ tvshow.title }}
</a>
</h6>
{% if tvshow.first_air_date %}
<p class="text-xs text-gray-600 mt-1">{{ tvshow.first_air_date|date('Y') }}</p>
{% endif %}
</div>
</div>
{% include 'components/tvshow-card.twig' with {'tvshow': tvshow, 'view_mode': 'covers'} %}
{% endfor %}
</div>
{% else %}
<!-- Enhanced Default grid view -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6 gap-6">
<!-- Enhanced grid view using component with dynamic sizing -->
<div
class="grid gap-6 transition-all duration-300"
x-data="{
currentSize: localStorage.getItem('tvshowCardSize') || 'medium',
sizeClasses: {
small: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
medium: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
large: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
xlarge: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
}
}"
:class="sizeClasses[currentSize]"
x-init="
window.addEventListener('tvshowCardSizeChanged', (e) => {
currentSize = e.detail.sizeKey;
});
// Set initial class
$el.classList.add(...sizeClasses[currentSize].split(' '));
"
>
{% for tvshow in tvshows %}
<div class="group bg-white rounded-xl shadow-lg border border-gray-200 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 h-full overflow-hidden">
<div class="p-6">
<div class="flex items-start space-x-4">
<!-- Poster -->
<div class="flex-shrink-0">
{% if tvshow.poster_url %}
<div class="relative overflow-hidden rounded-lg shadow-md group-hover:shadow-lg transition-shadow duration-300">
<img class="w-20 h-30 object-cover transform group-hover:scale-105 transition-transform duration-300" src="/images/{{ tvshow.poster_url }}" alt="{{ tvshow.title }}">
</div>
{% else %}
<div class="w-20 h-30 bg-gradient-to-br from-gray-100 to-gray-200 rounded-lg flex items-center justify-center shadow-sm">
<svg class="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
</svg>
</div>
{% endif %}
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<h5 class="text-xl font-bold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors line-clamp-2">
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="block">
{{ tvshow.title }}
</a>
</h5>
<!-- Metadata -->
<div class="flex items-center gap-3 text-sm text-gray-600 mb-3">
{% if tvshow.first_air_date %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span>{{ tvshow.first_air_date|date('Y') }}</span>
</div>
{% endif %}
{% if tvshow.rating %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<span class="font-medium">{{ tvshow.rating }}/10</span>
</div>
{% endif %}
</div>
<!-- Source -->
{% if tvshow.source_name %}
<div class="flex items-center gap-1 text-sm text-gray-500 mb-3">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
<span>{{ tvshow.source_name }}</span>
</div>
{% endif %}
<!-- Overview -->
{% if tvshow.overview %}
<div class="mb-4">
<p class="text-sm text-gray-600 line-clamp-3 leading-relaxed">
{{ tvshow.overview|slice(0, 180) }}{% if tvshow.overview|length > 180 %}...{% endif %}
</p>
</div>
{% endif %}
<!-- Footer -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-sm text-gray-600">
{% if tvshow.number_of_seasons %}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
{{ tvshow.number_of_seasons }} season{{ tvshow.number_of_seasons > 1 ? 's' : '' }}
</span>
{% endif %}
{% if tvshow.number_of_episodes %}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ tvshow.number_of_episodes }} episode{{ tvshow.number_of_episodes > 1 ? 's' : '' }}
</span>
{% endif %}
</div>
<div class="flex items-center gap-2">
{% if tvshow.is_favorite %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 border border-red-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
Favorite
</span>
{% endif %}
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}"
class="inline-flex items-center px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors opacity-0 group-hover:opacity-100 transform translate-x-2 group-hover:translate-x-0 transition-all duration-200">
View
<svg class="w-4 h-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% include 'components/tvshow-card.twig' with {'tvshow': tvshow, 'view_mode': 'grid'} %}
{% endfor %}
</div>
{% endif %}
@@ -637,6 +530,30 @@
.select2-selection__choice__remove:hover {
color: #f8f9fa !important;
}
/* Animation delays for empty state */
.animation-delay-1000 {
animation-delay: 1s;
}
.animation-delay-2000 {
animation-delay: 2s;
}
/* Line clamping utilities */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<script>

View File

@@ -0,0 +1,116 @@
<?php
/**
* Setup Stash Ignore Configuration
*
* Example script showing how to configure ignore paths for Stash sources
*/
require_once __DIR__ . '/vendor/autoload.php';
// Load environment variables
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
// Load database configuration
$dbConfig = require __DIR__ . '/config/database.php';
\App\Database\Database::setConfig($dbConfig);
// Initialize database
try {
$pdo = \App\Database\Database::getInstance();
echo "✅ Database connection successful\n";
// Get Stash source
$stmt = $pdo->prepare('SELECT * FROM sources WHERE name = ?');
$stmt->execute(['stash']);
$stashSource = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$stashSource) {
echo "❌ No Stash source found in database\n";
echo "💡 Please set up a Stash source first\n";
exit(1);
}
echo "🔍 Current Stash source configuration:\n";
echo " ID: {$stashSource['id']}\n";
echo " Name: {$stashSource['display_name']}\n";
echo " Current config: " . ($stashSource['config'] ?: 'NOT SET') . "\n\n";
// Example ignore configurations
$exampleConfigs = [
'ignore_sample_videos' => [
'ignore_paths' => ['sample', 'trailer']
],
'ignore_specific_directories' => [
'ignore_paths' => ['/media/videos/ignored/', '/media/videos/temp/']
],
'ignore_multiple_patterns' => [
'ignore_paths' => ['sample', 'trailer', 'temp', 'backup']
]
];
echo "📋 Example ignore configurations:\n\n";
foreach ($exampleConfigs as $name => $config) {
echo "Configuration: {$name}\n";
echo " JSON: " . json_encode($config) . "\n";
echo " Description: Ignores scenes where any file path contains: " . implode(', ', $config['ignore_paths']) . "\n\n";
}
// Ask user which config to apply
echo "Choose a configuration to apply:\n";
echo "1. Ignore sample and trailer videos\n";
echo "2. Ignore specific directories\n";
echo "3. Ignore multiple patterns\n";
echo "4. Custom configuration\n";
echo "Enter choice (1-4): ";
$choice = trim(fgets(STDIN));
$selectedConfig = null;
switch ($choice) {
case '1':
$selectedConfig = $exampleConfigs['ignore_sample_videos'];
break;
case '2':
$selectedConfig = $exampleConfigs['ignore_specific_directories'];
break;
case '3':
$selectedConfig = $exampleConfigs['ignore_multiple_patterns'];
break;
case '4':
echo "Enter custom ignore patterns (comma-separated): ";
$patterns = trim(fgets(STDIN));
$patternArray = array_map('trim', explode(',', $patterns));
$selectedConfig = ['ignore_paths' => $patternArray];
break;
default:
echo "❌ Invalid choice\n";
exit(1);
}
// Update the source config
$newConfigJson = json_encode($selectedConfig);
$stmt = $pdo->prepare('UPDATE sources SET config = ? WHERE id = ?');
$result = $stmt->execute([$newConfigJson, $stashSource['id']]);
if ($result) {
echo "\n✅ Successfully updated Stash source configuration!\n";
echo " New config: {$newConfigJson}\n";
echo "\n📝 The Stash sync will now ignore scenes where any file path contains:\n";
foreach ($selectedConfig['ignore_paths'] as $pattern) {
echo " - '{$pattern}'\n";
}
echo "\n🔄 Run a Stash sync to test the ignore functionality.\n";
} else {
echo "❌ Failed to update configuration\n";
}
} catch (Exception $e) {
echo "❌ Error: " . $e->getMessage() . "\n";
echo " File: " . $e->getFile() . ":" . $e->getLine() . "\n";
}

64
test_xbvr_sync.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
// Load helper functions
require_once __DIR__ . '/app/helpers.php';
// Load environment variables
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
// Load database configuration
$dbConfig = require __DIR__ . '/config/database.php';
// Set up database connection
try {
\App\Database\Database::setConfig($dbConfig);
$pdo = \App\Database\Database::getInstance();
echo "Database connection established successfully.\n";
} catch (Exception $e) {
die('Database connection failed: ' . $e->getMessage() . "\n");
}
// Find XBVR source
$stmt = $pdo->prepare("SELECT * FROM sources WHERE name = 'xbvr' AND is_active = 1 LIMIT 1");
$stmt->execute();
$xbvrSource = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$xbvrSource) {
echo "No active XBVR source found. Please create an XBVR source first.\n";
exit(1);
}
echo "Found XBVR source: {$xbvrSource['display_name']} ({$xbvrSource['api_url']})\n";
// Convert source array to expected format for sync services
$sourceData = [
'id' => $xbvrSource['id'],
'name' => $xbvrSource['name'],
'display_name' => $xbvrSource['display_name'],
'api_url' => $xbvrSource['api_url'],
'api_key' => $xbvrSource['api_key'],
'config' => $xbvrSource['config'],
'is_active' => $xbvrSource['is_active'],
'last_sync_at' => $xbvrSource['last_sync_at']
];
// Test XBVR sync service
echo "\nTesting XBVR sync service...\n";
try {
$syncService = new \App\Services\XbvrSyncService($pdo, $sourceData, null);
echo "✓ XBVR sync service instantiated successfully!\n";
echo "✓ No PHP errors during instantiation - the PDO fix worked!\n";
echo "✓ The actor API 404 issue should be resolved since we removed the XBVR actor API calls.\n";
} catch (Exception $e) {
echo "✗ Error testing XBVR sync service: " . $e->getMessage() . "\n";
echo "File: " . $e->getFile() . ":" . $e->getLine() . "\n";
exit(1);
}
echo "\nXBVR sync service test complete.\n";