basic filter D:

This commit is contained in:
Lars Behrends
2025-10-24 17:12:36 +02:00
parent 218d0c28c0
commit e78c073f21
13 changed files with 1811 additions and 1120 deletions

View File

@@ -28,11 +28,24 @@ class AdultController extends Controller
// Get search parameters // Get search parameters
$search = trim($queryParams['search'] ?? ''); $search = trim($queryParams['search'] ?? '');
// Get filter parameters
$genres = $queryParams['genres'] ?? [];
if (!is_array($genres)) {
$genres = [$genres];
}
$genres = array_filter($genres);
$directors = $queryParams['directors'] ?? [];
if (!is_array($directors)) {
$directors = [$directors];
}
$directors = array_filter($directors);
// Get view mode // Get view mode
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get adult videos with pagination and search // Get adult videos with pagination and filters
$adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search); $adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors);
// Process metadata to extract local image paths for template compatibility // Process metadata to extract local image paths for template compatibility
foreach ($adultVideos as &$video) { foreach ($adultVideos as &$video) {
@@ -54,7 +67,11 @@ class AdultController extends Controller
} }
// Get total count for pagination // Get total count for pagination
$totalCount = AdultVideo::getTotalCount($this->pdo, $search); $totalCount = AdultVideo::getTotalCount($this->pdo, $search, $genres, $directors);
// Get available filter options
$availableGenres = AdultVideo::getAvailableGenres($this->pdo);
$availableDirectors = AdultVideo::getAvailableDirectors($this->pdo);
// Calculate pagination info // Calculate pagination info
$totalPages = ceil($totalCount / $perPage); $totalPages = ceil($totalCount / $perPage);
@@ -76,7 +93,15 @@ class AdultController extends Controller
], ],
'search' => $search, 'search' => $search,
'view_mode' => $viewMode, 'view_mode' => $viewMode,
'view_modes' => ['grid', 'list', 'covers'] 'view_modes' => ['grid', 'list', 'covers'],
'filters' => [
'genres' => $genres,
'directors' => $directors
],
'available_filters' => [
'genres' => $availableGenres,
'directors' => $availableDirectors
]
]); ]);
} }

View File

@@ -28,14 +28,31 @@ class GameController extends Controller
// Get search parameters // Get search parameters
$search = trim($queryParams['search'] ?? ''); $search = trim($queryParams['search'] ?? '');
// Get filter parameters
$genres = $queryParams['genres'] ?? [];
if (!is_array($genres)) {
$genres = [$genres];
}
$genres = array_filter($genres);
$platforms = $queryParams['platforms'] ?? [];
if (!is_array($platforms)) {
$platforms = [$platforms];
}
$platforms = array_filter($platforms);
// Get view mode // Get view mode
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get games with pagination and search // Get games with pagination and filters
$games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search); $games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search, $genres, $platforms);
// Get total count for pagination // Get total count for pagination
$totalCount = Game::getTotalCount($this->pdo, $search); $totalCount = Game::getTotalCount($this->pdo, $search, $genres, $platforms);
// Get available filter options
$availableGenres = Game::getAvailableGenres($this->pdo);
$availablePlatforms = Game::getAvailablePlatforms($this->pdo);
// Calculate pagination info // Calculate pagination info
$totalPages = ceil($totalCount / $perPage); $totalPages = ceil($totalCount / $perPage);
@@ -57,7 +74,15 @@ class GameController extends Controller
], ],
'search' => $search, 'search' => $search,
'view_mode' => $viewMode, 'view_mode' => $viewMode,
'view_modes' => ['grid', 'list', 'covers'] 'view_modes' => ['grid', 'list', 'covers'],
'filters' => [
'genres' => $genres,
'platforms' => $platforms
],
'available_filters' => [
'genres' => $availableGenres,
'platforms' => $availablePlatforms
]
]); ]);
} }

View File

@@ -28,14 +28,31 @@ class MovieController extends Controller
// Get search parameters // Get search parameters
$search = trim($queryParams['search'] ?? ''); $search = trim($queryParams['search'] ?? '');
// Get filter parameters
$genres = $queryParams['genres'] ?? [];
if (!is_array($genres)) {
$genres = [$genres];
}
$genres = array_filter($genres);
$directors = $queryParams['directors'] ?? [];
if (!is_array($directors)) {
$directors = [$directors];
}
$directors = array_filter($directors);
// Get view mode // Get view mode
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get movies with pagination and search // Get movies with pagination and filters
$movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search); $movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors);
// Get total count for pagination // Get total count for pagination
$totalCount = Movie::getTotalCount($this->pdo, $search); $totalCount = Movie::getTotalCount($this->pdo, $search, $genres, $directors);
// Get available filter options
$availableGenres = Movie::getAvailableGenres($this->pdo);
$availableDirectors = Movie::getAvailableDirectors($this->pdo);
// Calculate pagination info // Calculate pagination info
$totalPages = ceil($totalCount / $perPage); $totalPages = ceil($totalCount / $perPage);
@@ -57,7 +74,15 @@ class MovieController extends Controller
], ],
'search' => $search, 'search' => $search,
'view_mode' => $viewMode, 'view_mode' => $viewMode,
'view_modes' => ['grid', 'list', 'covers'] 'view_modes' => ['grid', 'list', 'covers'],
'filters' => [
'genres' => $genres,
'directors' => $directors
],
'available_filters' => [
'genres' => $availableGenres,
'directors' => $availableDirectors
]
]); ]);
} }

View File

@@ -28,24 +28,37 @@ class TvShowController extends Controller
// Get search parameters // Get search parameters
$search = trim($queryParams['search'] ?? ''); $search = trim($queryParams['search'] ?? '');
// Get filter parameters
$genres = $queryParams['genres'] ?? [];
if (!is_array($genres)) {
$genres = [$genres];
}
$genres = array_filter($genres);
$years = $queryParams['years'] ?? [];
if (!is_array($years)) {
$years = [$years];
}
$years = array_filter($years);
// Get view mode // Get view mode
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get TV shows with pagination and search // Get TV shows with pagination and filters
$tvshows = TvShow::getAllWithPagination($this->pdo, $page, $perPage, $search); $tvshows = TvShow::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $years);
// Get total count for pagination // Get total count for pagination
$totalCount = TvShow::getTotalCount($this->pdo, $search); $totalCount = TvShow::getTotalCount($this->pdo, $search, $genres, $years);
// Get available filter options
$availableGenres = TvShow::getAvailableGenres($this->pdo);
$availableYears = TvShow::getAvailableYears($this->pdo);
// Calculate pagination info // Calculate pagination info
$totalPages = ceil($totalCount / $perPage); $totalPages = ceil($totalCount / $perPage);
$hasNextPage = $page < $totalPages; $hasNextPage = $page < $totalPages;
$hasPrevPage = $page > 1; $hasPrevPage = $page > 1;
/*
echo '<pre>';
print_r($tvshows);
die();
*/
return $this->view->render($response, 'tvshows/index.twig', [ return $this->view->render($response, 'tvshows/index.twig', [
'title' => 'TV Shows', 'title' => 'TV Shows',
'tvshows' => $tvshows, 'tvshows' => $tvshows,
@@ -61,7 +74,15 @@ class TvShowController extends Controller
], ],
'search' => $search, 'search' => $search,
'view_mode' => $viewMode, 'view_mode' => $viewMode,
'view_modes' => ['grid', 'list', 'covers'] 'view_modes' => ['grid', 'list', 'covers'],
'filters' => [
'genres' => $genres,
'years' => $years
],
'available_filters' => [
'genres' => $availableGenres,
'years' => $availableYears
]
]); ]);
} }

View File

@@ -25,59 +25,92 @@ class AdultVideo extends Model
'external_id' 'external_id'
]; ];
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = []): array
{ {
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
$whereClause = '';
$params = [];
if (!empty($search)) {
$whereClause = "WHERE (title LIKE :search OR overview LIKE :search)";
$params['search'] = "%{$search}%";
}
$sql = " $sql = "
SELECT av.*, s.display_name as source_name SELECT av.*, s.display_name as source_name
FROM adult_videos av FROM adult_videos av
JOIN sources s ON av.source_id = s.id JOIN sources s ON av.source_id = s.id
{$whereClause}
ORDER BY av.created_at DESC
LIMIT :limit OFFSET :offset
"; ";
$params = [];
if (!empty($search)) {
$sql .= " WHERE (av.title LIKE :search OR av.overview LIKE :search)";
$params['search'] = "%{$search}%";
}
if (!empty($genres)) {
$placeholders = [];
foreach ($genres as $index => $genre) {
$placeholders[] = ":genre_{$index}";
$params["genre_{$index}"] = $genre;
}
$whereClause = !empty($search) ? " AND" : " WHERE";
$sql .= $whereClause . " av.genre IN (" . implode(',', $placeholders) . ")";
}
if (!empty($directors)) {
$placeholders = [];
foreach ($directors as $index => $director) {
$placeholders[] = ":director_{$index}";
$params["director_{$index}"] = $director;
}
$whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE";
$sql .= $whereClause . " av.director IN (" . implode(',', $placeholders) . ")";
}
$sql .= " ORDER BY av.created_at DESC LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
if (!empty($search)) { foreach ($params as $key => $value) {
$stmt->bindValue(':search', "%{$search}%", \PDO::PARAM_STR); $stmt->bindValue(":{$key}", $value);
} }
$stmt->execute(); $stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} }
public static function getTotalCount(\PDO $pdo, string $search = ''): int public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $directors = []): int
{ {
$whereClause = ''; $sql = "SELECT COUNT(*) as count FROM adult_videos av JOIN sources s ON av.source_id = s.id";
$params = []; $params = [];
if (!empty($search)) { if (!empty($search)) {
$whereClause = "WHERE (title LIKE :search OR overview LIKE :search)"; $sql .= " WHERE (av.title LIKE :search OR av.overview LIKE :search)";
$params['search'] = "%{$search}%"; $params['search'] = "%{$search}%";
} }
$sql = "SELECT COUNT(*) as count FROM adult_videos {$whereClause}"; if (!empty($genres)) {
$placeholders = [];
$stmt = $pdo->prepare($sql); foreach ($genres as $index => $genre) {
$placeholders[] = ":genre_{$index}";
if (!empty($search)) { $params["genre_{$index}"] = $genre;
$stmt->bindValue(':search', "%{$search}%", \PDO::PARAM_STR); }
$whereClause = !empty($search) ? " AND" : " WHERE";
$sql .= $whereClause . " av.genre IN (" . implode(',', $placeholders) . ")";
} }
if (!empty($directors)) {
$placeholders = [];
foreach ($directors as $index => $director) {
$placeholders[] = ":director_{$index}";
$params["director_{$index}"] = $director;
}
$whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE";
$sql .= $whereClause . " av.director IN (" . implode(',', $placeholders) . ")";
}
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(":{$key}", $value);
}
$stmt->execute(); $stmt->execute();
return (int) $stmt->fetch(\PDO::FETCH_ASSOC)['count']; return (int) $stmt->fetch()['count'];
} }
public function markAsWatched(): bool public function markAsWatched(): bool
@@ -181,4 +214,32 @@ class AdultVideo extends Model
"); ");
return $stmt->fetch(\PDO::FETCH_ASSOC); return $stmt->fetch(\PDO::FETCH_ASSOC);
} }
/**
* Get available genres for filtering
*/
public static function getAvailableGenres(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT DISTINCT genre
FROM adult_videos
WHERE genre IS NOT NULL AND genre != ''
ORDER BY genre
");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get available directors for filtering
*/
public static function getAvailableDirectors(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT DISTINCT director
FROM adult_videos
WHERE director IS NOT NULL AND director != ''
ORDER BY director
");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
} }

View File

@@ -262,16 +262,34 @@ class Game extends Model
/** /**
* Get total count of games for pagination * Get total count of games for pagination
*/ */
public static function getTotalCount(\PDO $pdo, string $search = ''): int public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $platforms = []): int
{ {
$sql = "SELECT COUNT(*) as count FROM games"; $sql = "SELECT COUNT(*) as count FROM games WHERE game_key IS NOT NULL";
$params = []; $params = [];
if (!empty($search)) { if (!empty($search)) {
$sql .= " WHERE title LIKE :search"; $sql .= " AND title LIKE :search";
$params['search'] = "%{$search}%"; $params['search'] = "%{$search}%";
} }
if (!empty($genres)) {
$placeholders = [];
foreach ($genres as $index => $genre) {
$placeholders[] = ":genre_{$index}";
$params["genre_{$index}"] = $genre;
}
$sql .= " AND genre IN (" . implode(',', $placeholders) . ")";
}
if (!empty($platforms)) {
$placeholders = [];
foreach ($platforms as $index => $platform) {
$placeholders[] = ":platform_{$index}";
$params["platform_{$index}"] = $platform;
}
$sql .= " AND platform IN (" . implode(',', $placeholders) . ")";
}
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
return (int) $stmt->fetch()['count']; return (int) $stmt->fetch()['count'];
@@ -280,7 +298,7 @@ class Game extends Model
/** /**
* Get grouped games with pagination and search support * Get grouped games with pagination and search support
*/ */
public static function getGroupedGamesWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array public static function getGroupedGamesWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $platforms = []): array
{ {
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
@@ -307,6 +325,24 @@ class Game extends Model
$params['search'] = "%{$search}%"; $params['search'] = "%{$search}%";
} }
if (!empty($genres)) {
$placeholders = [];
foreach ($genres as $index => $genre) {
$placeholders[] = ":genre_{$index}";
$params["genre_{$index}"] = $genre;
}
$sql .= " AND genre IN (" . implode(',', $placeholders) . ")";
}
if (!empty($platforms)) {
$placeholders = [];
foreach ($platforms as $index => $platform) {
$placeholders[] = ":platform_{$index}";
$params["platform_{$index}"] = $platform;
}
$sql .= " AND platform IN (" . implode(',', $placeholders) . ")";
}
$sql .= " GROUP BY game_key, title ORDER BY last_played_at DESC, total_playtime DESC LIMIT :limit OFFSET :offset"; $sql .= " GROUP BY game_key, title ORDER BY last_played_at DESC, total_playtime DESC LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
@@ -379,51 +415,31 @@ class Game extends Model
} }
/** /**
* Get Playnite-specific series * Get available genres for filtering
*/ */
public function getSeries(): array public static function getAvailableGenres(\PDO $pdo): array
{ {
return $this->series_json ?? []; $stmt = $pdo->query("
SELECT DISTINCT genre
FROM games
WHERE genre IS NOT NULL AND genre != ''
ORDER BY genre
");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
} }
/** /**
* Get Playnite-specific age ratings * Get available platforms for filtering
*/ */
public function getAgeRatings(): array public static function getAvailablePlatforms(\PDO $pdo): array
{ {
return $this->age_ratings_json ?? []; $stmt = $pdo->query("
} SELECT DISTINCT platform
FROM games
/** WHERE platform IS NOT NULL AND platform != ''
* Get formatted install size ORDER BY platform
*/ ");
public function getFormattedInstallSize(): string return $stmt->fetchAll(\PDO::FETCH_COLUMN);
{
if (!$this->install_size) {
return 'Unknown';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = $this->install_size;
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* Get Steam store URL if available
*/
public function getSteamUrl(): ?string
{
if (!$this->steam_app_id) {
return null;
}
return "https://store.steampowered.com/app/{$this->steam_app_id}";
} }
/** /**
@@ -435,4 +451,3 @@ class Game extends Model
!empty($this->links_json) || !empty($this->background_image); !empty($this->links_json) || !empty($this->background_image);
} }
} }

View File

@@ -109,22 +109,45 @@ class Movie extends Model
return $stmt->fetchAll(\PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} }
public static function getTotalCount(\PDO $pdo, string $search = ''): int public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $directors = []): int
{ {
$sql = "SELECT COUNT(*) as count FROM movies m JOIN sources s ON m.source_id = s.id"; $sql = "SELECT COUNT(*) as count FROM movies m JOIN sources s ON m.source_id = s.id";
$params = []; $params = [];
if (!empty($search)) { if (!empty($search)) {
$sql .= " WHERE m.title LIKE :search"; $sql .= " WHERE (m.title LIKE :search OR m.overview LIKE :search)";
$params['search'] = "%{$search}%"; $params['search'] = "%{$search}%";
} }
if (!empty($genres)) {
$placeholders = [];
foreach ($genres as $index => $genre) {
$placeholders[] = ":genre_{$index}";
$params["genre_{$index}"] = $genre;
}
$whereClause = !empty($search) ? " AND" : " WHERE";
$sql .= $whereClause . " m.genre IN (" . implode(',', $placeholders) . ")";
}
if (!empty($directors)) {
$placeholders = [];
foreach ($directors as $index => $director) {
$placeholders[] = ":director_{$index}";
$params["director_{$index}"] = $director;
}
$whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE";
$sql .= $whereClause . " m.director IN (" . implode(',', $placeholders) . ")";
}
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute($params); foreach ($params as $key => $value) {
$stmt->bindValue(":{$key}", $value);
}
$stmt->execute();
return (int) $stmt->fetch()['count']; return (int) $stmt->fetch()['count'];
} }
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = []): array
{ {
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
@@ -136,10 +159,30 @@ class Movie extends Model
$params = []; $params = [];
if (!empty($search)) { if (!empty($search)) {
$sql .= " WHERE m.title LIKE :search"; $sql .= " WHERE (m.title LIKE :search OR m.overview LIKE :search)";
$params['search'] = "%{$search}%"; $params['search'] = "%{$search}%";
} }
if (!empty($genres)) {
$placeholders = [];
foreach ($genres as $index => $genre) {
$placeholders[] = ":genre_{$index}";
$params["genre_{$index}"] = $genre;
}
$whereClause = !empty($search) ? " AND" : " WHERE";
$sql .= $whereClause . " m.genre IN (" . implode(',', $placeholders) . ")";
}
if (!empty($directors)) {
$placeholders = [];
foreach ($directors as $index => $director) {
$placeholders[] = ":director_{$index}";
$params["director_{$index}"] = $director;
}
$whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE";
$sql .= $whereClause . " m.director IN (" . implode(',', $placeholders) . ")";
}
$sql .= " ORDER BY m.title ASC LIMIT :limit OFFSET :offset"; $sql .= " ORDER BY m.title ASC LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
@@ -226,4 +269,32 @@ class Movie extends Model
'cast' => $castString 'cast' => $castString
]); ]);
} }
/**
* Get available genres for filtering
*/
public static function getAvailableGenres(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT DISTINCT genre
FROM movies
WHERE genre IS NOT NULL AND genre != ''
ORDER BY genre
");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get available directors for filtering
*/
public static function getAvailableDirectors(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT DISTINCT director
FROM movies
WHERE director IS NOT NULL AND director != ''
ORDER BY director
");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
} }

View File

@@ -83,7 +83,7 @@ class TvShow extends Model
/** /**
* Get total count with optional search * Get total count with optional search
*/ */
public static function getTotalCount(\PDO $pdo, string $search = ''): int public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $years = []): int
{ {
$sql = "SELECT COUNT(*) as count FROM tv_shows t JOIN sources s ON t.source_id = s.id"; $sql = "SELECT COUNT(*) as count FROM tv_shows t JOIN sources s ON t.source_id = s.id";
$params = []; $params = [];
@@ -93,15 +93,38 @@ class TvShow extends Model
$params['search'] = "%{$search}%"; $params['search'] = "%{$search}%";
} }
if (!empty($genres)) {
$placeholders = [];
foreach ($genres as $index => $genre) {
$placeholders[] = ":genre_{$index}";
$params["genre_{$index}"] = $genre;
}
$whereClause = !empty($search) ? " AND" : " WHERE";
$sql .= $whereClause . " t.genre IN (" . implode(',', $placeholders) . ")";
}
if (!empty($years)) {
$placeholders = [];
foreach ($years as $index => $year) {
$placeholders[] = ":year_{$index}";
$params["year_{$index}"] = $year;
}
$whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE";
$sql .= $whereClause . " YEAR(first_air_date) IN (" . implode(',', $placeholders) . ")";
}
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute($params); foreach ($params as $key => $value) {
$stmt->bindValue(":{$key}", $value);
}
$stmt->execute();
return (int) $stmt->fetch()['count']; return (int) $stmt->fetch()['count'];
} }
/** /**
* Get all TV shows with pagination and optional search * Get all TV shows with pagination and optional search
*/ */
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $years = []): array
{ {
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
@@ -117,6 +140,26 @@ class TvShow extends Model
$params['search'] = "%{$search}%"; $params['search'] = "%{$search}%";
} }
if (!empty($genres)) {
$placeholders = [];
foreach ($genres as $index => $genre) {
$placeholders[] = ":genre_{$index}";
$params["genre_{$index}"] = $genre;
}
$whereClause = !empty($search) ? " AND" : " WHERE";
$sql .= $whereClause . " t.genre IN (" . implode(',', $placeholders) . ")";
}
if (!empty($years)) {
$placeholders = [];
foreach ($years as $index => $year) {
$placeholders[] = ":year_{$index}";
$params["year_{$index}"] = $year;
}
$whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE";
$sql .= $whereClause . " YEAR(first_air_date) IN (" . implode(',', $placeholders) . ")";
}
$sql .= " ORDER BY t.title ASC LIMIT :limit OFFSET :offset"; $sql .= " ORDER BY t.title ASC LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
@@ -259,9 +302,8 @@ public function recordView(): bool
public static function getAvailableGenres(\PDO $pdo): array public static function getAvailableGenres(\PDO $pdo): array
{ {
$stmt = $pdo->query(" $stmt = $pdo->query("
SELECT DISTINCT TRIM(value) as genre SELECT DISTINCT genre
FROM tv_shows, FROM tv_shows
json_each('[\"' || REPLACE(genre, ',', '\",\"') || '\"]')
WHERE genre IS NOT NULL AND genre != '' WHERE genre IS NOT NULL AND genre != ''
ORDER BY genre ORDER BY genre
"); ");
@@ -274,7 +316,7 @@ public static function getAvailableGenres(\PDO $pdo): array
public static function getAvailableYears(\PDO $pdo): array public static function getAvailableYears(\PDO $pdo): array
{ {
$stmt = $pdo->query(" $stmt = $pdo->query("
SELECT DISTINCT strftime('%Y', first_air_date) as year SELECT DISTINCT YEAR(first_air_date) as year
FROM tv_shows FROM tv_shows
WHERE first_air_date IS NOT NULL WHERE first_air_date IS NOT NULL
ORDER BY year DESC ORDER BY year DESC

View File

@@ -75,6 +75,7 @@ class PlayniteImportService
*/ */
private function transformPlayniteGame(array $game, int $index): array private function transformPlayniteGame(array $game, int $index): array
{ {
/*
// Validate required fields // Validate required fields
if (empty($game['Name'])) { if (empty($game['Name'])) {
throw new \Exception("Missing game name"); throw new \Exception("Missing game name");
@@ -83,7 +84,7 @@ class PlayniteImportService
if (empty($game['GameId'])) { if (empty($game['GameId'])) {
throw new \Exception("Missing GameId"); throw new \Exception("Missing GameId");
} }
*/
// Find or create source // Find or create source
$source = $this->findOrCreateSource($game); $source = $this->findOrCreateSource($game);
@@ -132,10 +133,10 @@ class PlayniteImportService
'steam_app_id' => $this->extractSteamAppId($game), 'steam_app_id' => $this->extractSteamAppId($game),
// Playnite-specific metadata // Playnite-specific metadata
'is_installed' => $game['IsInstalled'] ?? false, // 'is_installed' => $this->toBoolean($game['IsInstalled'] ?? false),
'is_favorite' => $game['Favorite'] ?? false, //'is_favorite' => $this->toBoolean($game['Favorite'] ?? false),
'is_custom_game' => $game['IsCustomGame'] ?? false, //'is_custom_game' => $this->toBoolean($game['IsCustomGame'] ?? false),
'installation_status' => $game['InstallationStatus'] ?? 0, //'installation_status' => $game['InstallationStatus'] ?? 0,
// Timestamps // Timestamps
'added_at' => isset($game['Added']) ? date('Y-m-d H:i:s', strtotime($game['Added'])) : null, 'added_at' => isset($game['Added']) ? date('Y-m-d H:i:s', strtotime($game['Added'])) : null,
@@ -147,16 +148,16 @@ class PlayniteImportService
'metadata' => json_encode([ 'metadata' => json_encode([
'playnite_id' => $game['Id'] ?? null, 'playnite_id' => $game['Id'] ?? null,
'version' => $game['Version'] ?? null, 'version' => $game['Version'] ?? null,
'hidden' => $game['Hidden'] ?? false, 'hidden' => $this->toBoolean($game['Hidden'] ?? false),
'notes' => $game['Notes'] ?? null, 'notes' => $game['Notes'] ?? null,
'manual' => $game['Manual'] ?? null, 'manual' => $game['Manual'] ?? null,
'pre_script' => $game['PreScript'] ?? null, 'pre_script' => $game['PreScript'] ?? null,
'post_script' => $game['PostScript'] ?? null, 'post_script' => $game['PostScript'] ?? null,
'game_started_script' => $game['GameStartedScript'] ?? null, 'game_started_script' => $game['GameStartedScript'] ?? null,
'use_global_scripts' => [ 'use_global_scripts' => [
'pre' => $game['UseGlobalPreScript'] ?? true, 'pre' => $this->toBoolean($game['UseGlobalPreScript'] ?? true),
'post' => $game['UseGlobalPostScript'] ?? true, 'post' => $this->toBoolean($game['UseGlobalPostScript'] ?? true),
'game_started' => $game['UseGlobalGameStartedScript'] ?? true 'game_started' => $this->toBoolean($game['UseGlobalGameStartedScript'] ?? true)
] ]
]) ])
]; ];
@@ -371,4 +372,21 @@ class PlayniteImportService
$gameModel = new Game($this->pdo); $gameModel = new Game($this->pdo);
$gameModel->update($gameId, $gameData); $gameModel->update($gameId, $gameData);
} }
/**
* Convert a value to boolean, handling empty strings properly
*/
private function toBoolean($value): bool
{
if ($value === null || $value === false || $value === 0 || $value === '0') {
return false;
}
if ($value === true || $value === 1 || $value === '1') {
return true;
}
if (is_string($value)) {
return !empty(trim($value));
}
return (bool) $value;
}
} }

View File

@@ -1,299 +1,387 @@
{% extends "layouts/app.twig" %} {% extends "layouts/app.twig" %}
{% block content %} {% block content %}
<div class="px-4 py-3"> <div class="container-fluid">
<!-- Header with search and view controls --> <div class="row">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3"> <!-- Sidebar with filters -->
<div> <div class="col-lg-3 col-xl-2">
<h1 class="display-4 fw-bold text-dark">Adult Videos</h1> <div class="card">
{% if pagination.total_items > 0 %} <div class="card-header">
<div class="text-muted small mt-1"> <h5 class="mb-0">Filters</h5>
{{ pagination.total_items }} videos
{% if search %}
matching "{{ search }}"
{% endif %}
</div>
{% endif %}
</div>
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
<!-- Search form -->
<form method="GET" class="d-flex gap-2">
<input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<div class="position-relative">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search adult videos..."
class="form-control ps-5"
>
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" 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>
</div> </div>
<button type="submit" class="btn btn-primary"> <div class="card-body">
Search <!-- Filter form -->
</button> <form method="GET" id="filterForm">
</form> <input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<input type="hidden" name="search" value="{{ search }}">
<!-- View mode switcher --> <!-- Genre filter -->
<div class="btn-group" role="group"> {% if available_filters.genres %}
{% for mode in view_modes %} <div class="mb-4">
<a <h6 class="fw-bold text-dark mb-2">Genres</h6>
href="?view={{ mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}" {% for genre in available_filters.genres %}
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}" <div class="form-check">
> <input class="form-check-input"
{% if mode == 'grid' %} type="checkbox"
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"> name="genres[]"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/> value="{{ genre }}"
</svg> id="genre_{{ genre|lower|replace({' ': '_'}) }}"
{% endif %} {{ genre in filters.genres ? 'checked' : '' }}>
{% if mode == 'list' %} <label class="form-check-label small" for="genre_{{ genre|lower|replace({' ': '_'}) }}">
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {{ genre }}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/> </label>
</svg>
{% endif %}
{{ mode|title }}
</a>
{% endfor %}
</div>
</div>
</div>
{% if error %}
<div class="alert alert-danger mb-4">
{{ error }}
</div>
{% endif %}
{% 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"/>
</svg>
<h3 class="h5 fw-medium text-dark">
{% if search %}
No adult videos found matching "{{ search }}"
{% else %}
No adult videos found
{% endif %}
</h3>
<p class="text-muted">
{% if search %}
Try adjusting your search terms or browse all adult videos.
{% else %}
Adult videos will appear here after syncing with XBVR or Stash sources.
{% endif %}
</p>
{% if search %}
<a href="{{ path_for('adult.index') }}" class="btn btn-primary mt-3">
View all adult videos
</a>
{% endif %}
</div>
{% else %}
<!-- Adult videos content based on view mode -->
{% if view_mode == 'list' %}
<!-- List view -->
<div class="card">
<ul class="list-group list-group-flush">
{% for movie in movies %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
{% if movie.poster_url %}
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: contain; background-color: #f8f9fa;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
<svg class="text-muted" 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-grow-1">
<h3 class="h6 mb-1">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h3>
<div class="d-flex align-items-center gap-3 small text-muted">
{% 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>
{% endfor %}
</div>
{% endif %}
<!-- Director filter -->
{% if available_filters.directors %}
<div class="mb-4">
<h6 class="fw-bold text-dark mb-2">Directors</h6>
{% for director in available_filters.directors %}
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="directors[]"
value="{{ director }}"
id="director_{{ director|lower|replace({' ': '_'}) }}"
{{ director in filters.directors ? 'checked' : '' }}>
<label class="form-check-label small" for="director_{{ director|lower|replace({' ': '_'}) }}">
{{ director }}
</label>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Filter actions -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm">
Apply Filters
</button>
<a href="{{ path_for('adult.index') }}" class="btn btn-outline-secondary btn-sm">
Clear All
</a>
</div>
</form>
</div>
</div>
</div>
<!-- Main content area -->
<div class="col-lg-9 col-xl-10">
<div class="px-4 py-3">
<!-- Header with search and view controls -->
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
<div>
<h1 class="display-4 fw-bold text-dark">Adult Videos</h1>
{% if pagination.total_items > 0 %}
<div class="text-muted small mt-1">
{{ pagination.total_items }} videos
{% if search %}
matching "{{ search }}"
{% endif %}
{% if filters.genres or filters.directors %}
{% if filters.genres %}
<span class="badge bg-primary ms-1">{{ filters.genres|join(', ') }}</span>
{% endif %}
{% if filters.directors %}
<span class="badge bg-secondary ms-1">{{ filters.directors|join(', ') }}</span>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
<!-- Search form -->
<form method="GET" class="d-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 %}
<input type="hidden" name="genres[]" value="{{ genre }}">
{% endfor %}
{% for director in filters.directors %}
<input type="hidden" name="directors[]" value="{{ director }}">
{% endfor %}
<div class="position-relative">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search adult videos..."
class="form-control ps-5"
>
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" 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>
</div>
<button type="submit" class="btn btn-primary">
Search
</button>
</form>
<!-- View mode switcher -->
<div class="btn-group" 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 director in filters.directors %}&directors[]={{ director }}{% endfor %}"
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}"
>
{% if mode == 'grid' %}
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
{% endif %}
{% if mode == 'list' %}
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
{% endif %}
{{ mode|title }}
</a>
{% endfor %}
</div> </div>
</div> </div>
<div class="d-flex gap-2">
{% if movie.watched %}
<span class="badge bg-success">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="badge bg-danger">
Favorite
</span>
{% endif %}
</div>
</div> </div>
</li>
{% endfor %}
</ul>
</div>
{% elseif view_mode == 'covers' %} {% if error %}
<!-- Cover grid view --> <div class="alert alert-danger mb-4">
<div class="row g-3"> {{ error }}
{% for movie in movies %}
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100">
{% if movie.poster_url %}
<div class="position-relative" style="background-color: #f8f9fa; border-radius: 0.375rem; overflow: hidden;">
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top w-100" style="max-height: 300px; object-fit: contain;">
</div> </div>
{% else %} {% endif %}
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
<svg class="text-muted" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {% 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"/> <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> </svg>
</div> <h3 class="h5 fw-medium text-dark">
{% endif %} {% if search or filters.genres or filters.directors %}
<div class="card-body"> No adult videos found matching your criteria
<h6 class="card-title text-truncate" title="{{ movie.title }}"> {% else %}
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none"> No adult videos found
{{ movie.title }} {% endif %}
</a> </h3>
</h6> <p class="text-muted">
{% if movie.release_date %} {% if search or filters.genres or filters.directors %}
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p> Try adjusting your search terms or filters.
{% else %}
Adult videos will appear here after syncing with XBVR or Stash sources.
{% endif %}
</p>
{% if search or filters.genres or filters.directors %}
<a href="{{ path_for('adult.index') }}" class="btn btn-primary mt-3">
Clear filters
</a>
{% endif %} {% endif %}
</div> </div>
</div> {% else %}
</div> <!-- Adult videos content based on view mode -->
{% endfor %} {% if view_mode == 'list' %}
</div> <!-- List view -->
<div class="card">
<ul class="list-group list-group-flush">
{% for movie in movies %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
{% if movie.poster_url %}
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: contain; background-color: #f8f9fa;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
<svg class="text-muted" 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-grow-1">
<h3 class="h6 mb-1">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h3>
<div class="d-flex align-items-center gap-3 small text-muted">
{% 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="d-flex gap-2">
{% if movie.watched %}
<span class="badge bg-success">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="badge bg-danger">
Favorite
</span>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{% else %} {% elseif view_mode == 'covers' %}
<!-- Default grid view --> <!-- Cover grid view -->
<div class="row g-3"> <div class="row g-3">
{% for movie in movies %} {% for movie in movies %}
<div class="col-12 col-md-6 col-lg-3 col-xl-2"> <div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100"> <div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
{% if movie.poster_url %} {% 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 }}"> <div class="position-relative" style="background-color: #f8f9fa; border-radius: 0.375rem; overflow: hidden;">
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top w-100" style="max-height: 300px; object-fit: contain;">
</div>
{% else %} {% else %}
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="text-muted" 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"/> <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> </svg>
</div> </div>
{% endif %} {% endif %}
</div> <div class="card-body">
<div class="ms-3 flex-grow-1"> <h6 class="card-title text-truncate" title="{{ movie.title }}">
<h5 class="card-title mb-1"> <a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none"> {{ movie.title }}
{{ movie.title }} </a>
</a> </h6>
</h5>
<div class="d-flex align-items-center gap-2 small text-muted mb-2">
{% if movie.release_date %} {% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span> <p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
{% endif %}
{% if movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %} {% endif %}
</div> </div>
{% if movie.source_name %}
<p class="card-text small text-muted mb-2">
{{ movie.source_name }}
</p>
{% endif %}
</div> </div>
</div> </div>
{% if movie.overview %} {% endfor %}
<div class="mt-3"> </div>
<p class="card-text small text-muted" 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 %} {% else %}
</p> <!-- Default grid view -->
<div class="row g-3">
{% for movie in movies %}
<div class="col-12 col-md-6 col-lg-3 col-xl-2">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-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-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
<svg class="text-muted" 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="ms-3 flex-grow-1">
<h5 class="card-title mb-1">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h5>
<div class="d-flex align-items-center gap-2 small text-muted 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="card-text small text-muted mb-2">
{{ movie.source_name }}
</p>
{% endif %}
</div>
</div>
{% if movie.overview %}
<div class="mt-3">
<p class="card-text small text-muted" 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 d-flex justify-content-between align-items-center small text-muted">
{% if movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<div class="d-flex gap-1">
{% if movie.watched %}
<span class="badge bg-success">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="badge bg-danger">
Favorite
</span>
{% endif %}
</div>
</div>
</div>
</div>
</div> </div>
{% endif %} {% endfor %}
<div class="mt-3 d-flex justify-content-between align-items-center small text-muted"> </div>
{% if movie.runtime_minutes %} {% endif %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="d-flex align-items-center justify-content-between mt-4">
<div class="d-flex align-items-center gap-2">
<label for="per_page" class="form-label mb-0">Show:</label>
<select id="per_page" class="form-select form-select-sm w-auto">
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
</select>
<span class="text-muted small">per page</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}"
class="btn btn-outline-secondary btn-sm">
Previous
</a>
{% endif %} {% endif %}
<div class="d-flex gap-1">
{% if movie.watched %} <div class="btn-group" role="group">
<span class="badge bg-success"> {% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
Watched <a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}"
</span> class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
{% endif %} {{ page_num }}
{% if movie.is_favorite %} </a>
<span class="badge bg-danger"> {% endfor %}
Favorite
</span>
{% endif %}
</div> </div>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}"
class="btn btn-outline-secondary btn-sm">
Next
</a>
{% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
{% endif %}
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="d-flex align-items-center justify-content-between mt-4">
<div class="d-flex align-items-center gap-2">
<label for="per_page" class="form-label mb-0">Show:</label>
<select id="per_page" class="form-select form-select-sm w-auto">
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
</select>
<span class="text-muted small">per page</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-outline-secondary btn-sm">
Previous
</a>
{% endif %}
<div class="btn-group" role="group">
{% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
{{ page_num }}
</a>
{% endfor %}
</div>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-outline-secondary btn-sm">
Next
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
</div> </div>
<script> <script>
@@ -303,5 +391,12 @@ document.getElementById('per_page')?.addEventListener('change', function() {
url.searchParams.set('page', '1'); // Reset to first page url.searchParams.set('page', '1'); // Reset to first page
window.location = url.toString(); window.location = url.toString();
}); });
// Auto-submit filter form when checkboxes change
document.querySelectorAll('#filterForm input[type="checkbox"]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,274 +1,362 @@
{% extends "layouts/app.twig" %} {% extends "layouts/app.twig" %}
{% block content %} {% block content %}
<div class="px-4 py-3"> <div class="container-fluid">
<!-- Header with search and view controls --> <div class="row">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3"> <!-- Sidebar with filters -->
<div> <div class="col-lg-3 col-xl-2">
<h1 class="display-4 fw-bold text-dark">Games</h1> <div class="card">
{% if pagination.total_items > 0 %} <div class="card-header">
<div class="text-muted small mt-1"> <h5 class="mb-0">Filters</h5>
{{ pagination.total_items }} games from {{ games|reduce((carry, game) => carry + game.platform_count, 0) }} platforms
{% if search %}
matching "{{ search }}"
{% endif %}
</div>
{% endif %}
</div>
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
<!-- Search form -->
<form method="GET" class="d-flex gap-2">
<input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<div class="position-relative">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search games..."
class="form-control ps-5"
>
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" 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>
</div> </div>
<button type="submit" class="btn btn-primary"> <div class="card-body">
Search <!-- Filter form -->
</button> <form method="GET" id="filterForm">
</form> <input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<input type="hidden" name="search" value="{{ search }}">
<!-- View mode switcher --> <!-- Genre filter -->
<div class="btn-group" role="group"> {% if available_filters.genres %}
{% for mode in view_modes %} <div class="mb-4">
<a <h6 class="fw-bold text-dark mb-2">Genres</h6>
href="?view={{ mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}" {% for genre in available_filters.genres %}
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}" <div class="form-check">
> <input class="form-check-input"
{% if mode == 'grid' %} type="checkbox"
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"> name="genres[]"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/> value="{{ genre }}"
</svg> id="genre_{{ genre|lower|replace({' ': '_'}) }}"
{% endif %} {{ genre in filters.genres ? 'checked' : '' }}>
{% if mode == 'list' %} <label class="form-check-label small" for="genre_{{ genre|lower|replace({' ': '_'}) }}">
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {{ genre }}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/> </label>
</svg> </div>
{% endif %} {% endfor %}
{{ mode|title }}
</a>
{% endfor %}
</div>
</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"/>
</svg>
<h3 class="h5 fw-medium text-dark">
{% if search %}
No games found matching "{{ search }}"
{% else %}
No games found
{% endif %}
</h3>
<p class="text-muted">
{% if search %}
Try adjusting your search terms or browse all games.
{% else %}
Start syncing your gaming libraries to see your games here.
{% endif %}
</p>
{% if search %}
<a href="{{ path_for('games.index') }}" class="btn btn-primary mt-3">
View all games
</a>
{% endif %}
</div>
{% else %}
<!-- Games content based on view mode -->
{% if view_mode == 'list' %}
<!-- List view -->
<div class="card">
<ul class="list-group list-group-flush">
{% for game in games %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
{% if game.image_url %}
<img class="rounded me-3" style="width: 64px; height: 64px; object-fit: cover;" src="{{ game.image_url }}" alt="{{ game.title }}">
{% else %}
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 64px;">
<svg class="text-muted" 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> </div>
{% endif %} {% endif %}
<div class="flex-grow-1">
<h3 class="h6 mb-1">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="text-decoration-none">
{{ game.title }}
</a>
</h3>
<div class="d-flex align-items-center gap-3 small text-muted">
<span>{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</span>
{% if game.platforms %}
<span class="badge bg-light text-dark">
{{ 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="d-flex flex-wrap gap-1">
{% for genre in game.genres|slice(0, 3) %}
<span class="badge bg-primary">
{{ genre }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
{% elseif view_mode == 'covers' %} <!-- Platform filter -->
<!-- Cover grid view --> {% if available_filters.platforms %}
<div class="row g-3"> <div class="mb-4">
{% for game in games %} <h6 class="fw-bold text-dark mb-2">Platforms</h6>
<div class="col-6 col-sm-4 col-md-3 col-lg-2"> {% for platform in available_filters.platforms %}
<div class="card h-100"> <div class="form-check">
{% if game.image_url %} <input class="form-check-input"
<div class="position-relative" style="aspect-ratio: 3/4; overflow: hidden;"> type="checkbox"
<img src="{{ game.image_url }}" alt="{{ game.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;"> name="platforms[]"
</div> value="{{ platform }}"
{% else %} id="platform_{{ platform|lower|replace({' ': '_'}) }}"
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 3/4; min-height: 200px;"> {{ platform in filters.platforms ? 'checked' : '' }}>
<svg class="text-muted" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <label class="form-check-label small" for="platform_{{ platform|lower|replace({' ': '_'}) }}">
<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"/> {{ platform }}
</svg> </label>
</div> </div>
{% endif %} {% endfor %}
<div class="card-body"> </div>
<h6 class="card-title text-truncate" title="{{ game.title }}"> {% endif %}
{{ game.title }}
</h6> <!-- Filter actions -->
<p class="card-text small text-muted">{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</p> <div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm">
Apply Filters
</button>
<a href="{{ path_for('games.index') }}" class="btn btn-outline-secondary btn-sm">
Clear All
</a>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
{% endfor %}
</div>
{% else %} <!-- Main content area -->
<!-- Default grid view --> <div class="col-lg-9 col-xl-10">
<div class="row g-3"> <div class="px-4 py-3">
{% for game in games %} <!-- Header with search and view controls -->
<div class="col-12 col-md-6 col-lg-4"> <div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
<div class="card h-100"> <div>
<div class="card-body"> <h1 class="display-4 fw-bold text-dark">Games</h1>
<div class="d-flex align-items-center"> {% if pagination.total_items > 0 %}
<div class="flex-shrink-0"> <div class="text-muted small mt-1">
{{ pagination.total_items }} games from {{ games|reduce((carry, game) => carry + game.platform_count, 0) }} platforms
{% if search %}
matching "{{ search }}"
{% endif %}
{% if filters.genres or filters.platforms %}
{% if filters.genres %}
<span class="badge bg-primary ms-1">{{ filters.genres|join(', ') }}</span>
{% endif %}
{% if filters.platforms %}
<span class="badge bg-secondary ms-1">{{ filters.platforms|join(', ') }}</span>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
<!-- Search form -->
<form method="GET" class="d-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 %}
<input type="hidden" name="genres[]" value="{{ genre }}">
{% endfor %}
{% for platform in filters.platforms %}
<input type="hidden" name="platforms[]" value="{{ platform }}">
{% endfor %}
<div class="position-relative">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search games..."
class="form-control ps-5"
>
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" 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>
</div>
<button type="submit" class="btn btn-primary">
Search
</button>
</form>
<!-- View mode switcher -->
<div class="btn-group" 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 platform in filters.platforms %}&platforms[]={{ platform }}{% endfor %}"
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}"
>
{% if mode == 'grid' %}
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
{% endif %}
{% if mode == 'list' %}
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
{% endif %}
{{ mode|title }}
</a>
{% endfor %}
</div>
</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"/>
</svg>
<h3 class="h5 fw-medium text-dark">
{% if search or filters.genres or filters.platforms %}
No games found matching your criteria
{% else %}
No games found
{% endif %}
</h3>
<p class="text-muted">
{% if search or filters.genres or filters.platforms %}
Try adjusting your search terms or filters.
{% else %}
Start syncing your gaming libraries to see your games here.
{% endif %}
</p>
{% if search or filters.genres or filters.platforms %}
<a href="{{ path_for('games.index') }}" class="btn btn-primary mt-3">
Clear filters
</a>
{% endif %}
</div>
{% else %}
<!-- Games content based on view mode -->
{% if view_mode == 'list' %}
<!-- List view -->
<div class="card">
<ul class="list-group list-group-flush">
{% for game in games %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
{% if game.image_url %}
<img class="rounded me-3" style="width: 64px; height: 64px; object-fit: cover;" src="{{ game.image_url }}" alt="{{ game.title }}">
{% else %}
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 64px;">
<svg class="text-muted" 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-grow-1">
<h3 class="h6 mb-1">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="text-decoration-none">
{{ game.title }}
</a>
</h3>
<div class="d-flex align-items-center gap-3 small text-muted">
<span>{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</span>
{% if game.platforms %}
<span class="badge bg-light text-dark">
{{ 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="d-flex flex-wrap gap-1">
{% for genre in game.genres|slice(0, 3) %}
<span class="badge bg-primary">
{{ genre }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
{% elseif view_mode == 'covers' %}
<!-- Cover grid view -->
<div class="row g-3">
{% for game in games %}
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100">
{% if game.image_url %} {% if game.image_url %}
<img class="rounded" style="width: 64px; height: 64px; object-fit: cover;" src="{{ game.image_url }}" alt="{{ game.title }}"> <div class="position-relative" style="aspect-ratio: 3/4; overflow: hidden;">
<img src="{{ game.image_url }}" alt="{{ game.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
</div>
{% else %} {% else %}
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 64px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 3/4; min-height: 200px;">
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="text-muted" 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"/> <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> </svg>
</div> </div>
{% endif %} {% endif %}
</div> <div class="card-body">
<div class="ms-3 flex-grow-1"> <h6 class="card-title text-truncate" title="{{ game.title }}">
<h5 class="card-title mb-1">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="text-decoration-none">
{{ game.title }} {{ game.title }}
</a> </h6>
</h5> <p class="card-text small text-muted">{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</p>
<p class="card-text small text-muted mb-2"> </div>
{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}
{% if game.platforms %}
<span class="badge bg-light text-dark ms-2">
{{ game.platforms|join(', ') }}
</span>
{% endif %}
</p>
</div> </div>
</div> </div>
<div class="mt-3"> {% endfor %}
<div class="d-flex justify-content-between align-items-center small text-muted"> </div>
<span>{{ game.total_playtime|format_duration }} played</span>
{% if game.max_completion > 0 %} {% else %}
<span>{{ game.max_completion }}% complete</span> <!-- Default grid view -->
{% endif %} <div class="row g-3">
{% for game in games %}
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
{% if game.image_url %}
<img class="rounded" style="width: 64px; height: 64px; object-fit: cover;" src="{{ game.image_url }}" alt="{{ game.title }}">
{% else %}
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 64px;">
<svg class="text-muted" 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="ms-3 flex-grow-1">
<h5 class="card-title mb-1">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="text-decoration-none">
{{ game.title }}
</a>
</h5>
<p class="card-text small text-muted mb-2">
{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}
{% if game.platforms %}
<span class="badge bg-light text-dark ms-2">
{{ game.platforms|join(', ') }}
</span>
{% endif %}
</p>
</div>
</div>
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center small text-muted">
<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 d-flex flex-wrap gap-1">
{% for genre in game.genres|slice(0, 3) %}
<span class="badge bg-primary">
{{ genre }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div> </div>
{% if game.genres %} </div>
<div class="mt-2 d-flex flex-wrap gap-1"> {% endfor %}
{% for genre in game.genres|slice(0, 3) %} </div>
<span class="badge bg-primary"> {% endif %}
{{ genre }}
</span> <!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="d-flex align-items-center justify-content-between mt-4">
<div class="d-flex align-items-center gap-2">
<label for="per_page" class="form-label mb-0">Show:</label>
<select id="per_page" class="form-select form-select-sm w-auto">
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
</select>
<span class="text-muted small">per page</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for platform in filters.platforms %}&platforms[]={{ platform }}{% endfor %}"
class="btn btn-outline-secondary btn-sm">
Previous
</a>
{% endif %}
<div class="btn-group" role="group">
{% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for platform in filters.platforms %}&platforms[]={{ platform }}{% endfor %}"
class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
{{ page_num }}
</a>
{% endfor %} {% endfor %}
</div> </div>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for platform in filters.platforms %}&platforms[]={{ platform }}{% endfor %}"
class="btn btn-outline-secondary btn-sm">
Next
</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
{% endif %}
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="d-flex align-items-center justify-content-between mt-4">
<div class="d-flex align-items-center gap-2">
<label for="per_page" class="form-label mb-0">Show:</label>
<select id="per_page" class="form-select form-select-sm w-auto">
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
</select>
<span class="text-muted small">per page</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-outline-secondary btn-sm">
Previous
</a>
{% endif %}
<div class="btn-group" role="group">
{% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
{{ page_num }}
</a>
{% endfor %}
</div>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-outline-secondary btn-sm">
Next
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
</div> </div>
<script> <script>
@@ -278,5 +366,12 @@ document.getElementById('per_page')?.addEventListener('change', function() {
url.searchParams.set('page', '1'); // Reset to first page url.searchParams.set('page', '1'); // Reset to first page
window.location = url.toString(); window.location = url.toString();
}); });
// Auto-submit filter form when checkboxes change
document.querySelectorAll('#filterForm input[type="checkbox"]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,293 +1,381 @@
{% extends "layouts/app.twig" %} {% extends "layouts/app.twig" %}
{% block content %} {% block content %}
<div class="px-4 py-3"> <div class="container-fluid">
<!-- Header with search and view controls --> <div class="row">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3"> <!-- Sidebar with filters -->
<div> <div class="col-lg-3 col-xl-2">
<h1 class="display-4 fw-bold text-dark">Movies</h1> <div class="card">
{% if pagination.total_items > 0 %} <div class="card-header">
<div class="text-muted small mt-1"> <h5 class="mb-0">Filters</h5>
{{ pagination.total_items }} movies
{% if search %}
matching "{{ search }}"
{% endif %}
</div>
{% endif %}
</div>
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
<!-- Search form -->
<form method="GET" class="d-flex gap-2">
<input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<div class="position-relative">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search movies..."
class="form-control ps-5"
>
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" 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>
</div> </div>
<button type="submit" class="btn btn-primary"> <div class="card-body">
Search <!-- Filter form -->
</button> <form method="GET" id="filterForm">
</form> <input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<input type="hidden" name="search" value="{{ search }}">
<!-- View mode switcher --> <!-- Genre filter -->
<div class="btn-group" role="group"> {% if available_filters.genres %}
{% for mode in view_modes %} <div class="mb-4">
<a <h6 class="fw-bold text-dark mb-2">Genres</h6>
href="?view={{ mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}" {% for genre in available_filters.genres %}
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}" <div class="form-check">
> <input class="form-check-input"
{% if mode == 'grid' %} type="checkbox"
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"> name="genres[]"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/> value="{{ genre }}"
</svg> id="genre_{{ genre|lower|replace({' ': '_'}) }}"
{% endif %} {{ genre in filters.genres ? 'checked' : '' }}>
{% if mode == 'list' %} <label class="form-check-label small" for="genre_{{ genre|lower|replace({' ': '_'}) }}">
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {{ genre }}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/> </label>
</svg>
{% endif %}
{{ mode|title }}
</a>
{% endfor %}
</div>
</div>
</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="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>
<h3 class="h5 fw-medium text-dark">
{% if search %}
No movies found matching "{{ search }}"
{% else %}
No movies found
{% endif %}
</h3>
<p class="text-muted">
{% if search %}
Try adjusting your search terms or browse all movies.
{% else %}
Start syncing your movie libraries to see your movies here.
{% endif %}
</p>
{% if search %}
<a href="{{ path_for('movies.index') }}" class="btn btn-primary mt-3">
View all movies
</a>
{% endif %}
</div>
{% else %}
<!-- Movies content based on view mode -->
{% if view_mode == 'list' %}
<!-- List view -->
<div class="card">
<ul class="list-group list-group-flush">
{% for movie in movies %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
{% if movie.poster_url %}
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
<svg class="text-muted" 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-grow-1">
<h3 class="h6 mb-1">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h3>
<div class="d-flex align-items-center gap-3 small text-muted">
{% 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>
{% endfor %}
</div>
{% endif %}
<!-- Director filter -->
{% if available_filters.directors %}
<div class="mb-4">
<h6 class="fw-bold text-dark mb-2">Directors</h6>
{% for director in available_filters.directors %}
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="directors[]"
value="{{ director }}"
id="director_{{ director|lower|replace({' ': '_'}) }}"
{{ director in filters.directors ? 'checked' : '' }}>
<label class="form-check-label small" for="director_{{ director|lower|replace({' ': '_'}) }}">
{{ director }}
</label>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Filter actions -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm">
Apply Filters
</button>
<a href="{{ path_for('movies.index') }}" class="btn btn-outline-secondary btn-sm">
Clear All
</a>
</div>
</form>
</div>
</div>
</div>
<!-- Main content area -->
<div class="col-lg-9 col-xl-10">
<div class="px-4 py-3">
<!-- Header with search and view controls -->
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
<div>
<h1 class="display-4 fw-bold text-dark">Movies</h1>
{% if pagination.total_items > 0 %}
<div class="text-muted small mt-1">
{{ pagination.total_items }} movies
{% if search %}
matching "{{ search }}"
{% endif %}
{% if filters.genres or filters.directors %}
{% if filters.genres %}
<span class="badge bg-primary ms-1">{{ filters.genres|join(', ') }}</span>
{% endif %}
{% if filters.directors %}
<span class="badge bg-secondary ms-1">{{ filters.directors|join(', ') }}</span>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
<!-- Search form -->
<form method="GET" class="d-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 %}
<input type="hidden" name="genres[]" value="{{ genre }}">
{% endfor %}
{% for director in filters.directors %}
<input type="hidden" name="directors[]" value="{{ director }}">
{% endfor %}
<div class="position-relative">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search movies..."
class="form-control ps-5"
>
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" 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>
</div>
<button type="submit" class="btn btn-primary">
Search
</button>
</form>
<!-- View mode switcher -->
<div class="btn-group" 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 director in filters.directors %}&directors[]={{ director }}{% endfor %}"
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}"
>
{% if mode == 'grid' %}
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
{% endif %}
{% if mode == 'list' %}
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
{% endif %}
{{ mode|title }}
</a>
{% endfor %}
</div> </div>
</div> </div>
<div class="d-flex gap-2">
{% if movie.watched %}
<span class="badge bg-success">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="badge bg-danger">
Favorite
</span>
{% endif %}
</div>
</div> </div>
</li>
{% endfor %}
</ul>
</div>
{% elseif view_mode == 'covers' %} {% if movies is empty %}
<!-- Cover grid view --> <div class="text-center py-5">
<div class="row g-3"> <svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{% for movie in movies %}
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100">
{% if movie.poster_url %}
<div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
<img src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
</div>
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
<svg class="text-muted" 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"/> <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> </svg>
</div> <h3 class="h5 fw-medium text-dark">
{% endif %} {% if search or filters.genres or filters.directors %}
<div class="card-body"> No movies found matching your criteria
<h6 class="card-title text-truncate" title="{{ movie.title }}"> {% else %}
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none"> No movies found
{{ movie.title }} {% endif %}
</a> </h3>
</h6> <p class="text-muted">
{% if movie.release_date %} {% if search or filters.genres or filters.directors %}
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p> Try adjusting your search terms or filters.
{% else %}
Start syncing your movie libraries to see your movies here.
{% endif %}
</p>
{% if search or filters.genres or filters.directors %}
<a href="{{ path_for('movies.index') }}" class="btn btn-primary mt-3">
Clear filters
</a>
{% endif %} {% endif %}
</div> </div>
</div> {% else %}
</div> <!-- Movies content based on view mode -->
{% endfor %} {% if view_mode == 'list' %}
</div> <!-- List view -->
<div class="card">
<ul class="list-group list-group-flush">
{% for movie in movies %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
{% if movie.poster_url %}
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
<svg class="text-muted" 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-grow-1">
<h3 class="h6 mb-1">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h3>
<div class="d-flex align-items-center gap-3 small text-muted">
{% 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="d-flex gap-2">
{% if movie.watched %}
<span class="badge bg-success">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="badge bg-danger">
Favorite
</span>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{% else %} {% elseif view_mode == 'covers' %}
<!-- Default grid view --> <!-- Cover grid view -->
<div class="row g-3"> <div class="row g-3">
{% for movie in movies %} {% for movie in movies %}
<div class="col-12 col-md-6 col-lg-3 col-xl-2"> <div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100"> <div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
{% if movie.poster_url %} {% if movie.poster_url %}
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}"> <div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
<img src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
</div>
{% else %} {% else %}
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="text-muted" 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"/> <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> </svg>
</div> </div>
{% endif %} {% endif %}
</div> <div class="card-body">
<div class="ms-3 flex-grow-1"> <h6 class="card-title text-truncate" title="{{ movie.title }}">
<h5 class="card-title mb-1"> <a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none"> {{ movie.title }}
{{ movie.title }} </a>
</a> </h6>
</h5>
<div class="d-flex align-items-center gap-2 small text-muted mb-2">
{% if movie.release_date %} {% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span> <p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
{% endif %}
{% if movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %} {% endif %}
</div> </div>
{% if movie.source_name %}
<p class="card-text small text-muted mb-2">
{{ movie.source_name }}
</p>
{% endif %}
</div> </div>
</div> </div>
{% if movie.overview %} {% endfor %}
<div class="mt-3"> </div>
<p class="card-text small text-muted" 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 %} {% else %}
</p> <!-- Default grid view -->
<div class="row g-3">
{% for movie in movies %}
<div class="col-12 col-md-6 col-lg-3 col-xl-2">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-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-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
<svg class="text-muted" 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="ms-3 flex-grow-1">
<h5 class="card-title mb-1">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h5>
<div class="d-flex align-items-center gap-2 small text-muted 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="card-text small text-muted mb-2">
{{ movie.source_name }}
</p>
{% endif %}
</div>
</div>
{% if movie.overview %}
<div class="mt-3">
<p class="card-text small text-muted" 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 d-flex justify-content-between align-items-center small text-muted">
{% if movie.runtime_minutes %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
{% endif %}
<div class="d-flex gap-1">
{% if movie.watched %}
<span class="badge bg-success">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="badge bg-danger">
Favorite
</span>
{% endif %}
</div>
</div>
</div>
</div>
</div> </div>
{% endif %} {% endfor %}
<div class="mt-3 d-flex justify-content-between align-items-center small text-muted"> </div>
{% if movie.runtime_minutes %} {% endif %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="d-flex align-items-center justify-content-between mt-4">
<div class="d-flex align-items-center gap-2">
<label for="per_page" class="form-label mb-0">Show:</label>
<select id="per_page" class="form-select form-select-sm w-auto">
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
</select>
<span class="text-muted small">per page</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}"
class="btn btn-outline-secondary btn-sm">
Previous
</a>
{% endif %} {% endif %}
<div class="d-flex gap-1">
{% if movie.watched %} <div class="btn-group" role="group">
<span class="badge bg-success"> {% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
Watched <a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}"
</span> class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
{% endif %} {{ page_num }}
{% if movie.is_favorite %} </a>
<span class="badge bg-danger"> {% endfor %}
Favorite
</span>
{% endif %}
</div> </div>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for director in filters.directors %}&directors[]={{ director }}{% endfor %}"
class="btn btn-outline-secondary btn-sm">
Next
</a>
{% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
{% endif %}
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="d-flex align-items-center justify-content-between mt-4">
<div class="d-flex align-items-center gap-2">
<label for="per_page" class="form-label mb-0">Show:</label>
<select id="per_page" class="form-select form-select-sm w-auto">
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
</select>
<span class="text-muted small">per page</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-outline-secondary btn-sm">
Previous
</a>
{% endif %}
<div class="btn-group" role="group">
{% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
{{ page_num }}
</a>
{% endfor %}
</div>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-outline-secondary btn-sm">
Next
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
</div> </div>
<script> <script>
@@ -297,5 +385,12 @@ document.getElementById('per_page')?.addEventListener('change', function() {
url.searchParams.set('page', '1'); // Reset to first page url.searchParams.set('page', '1'); // Reset to first page
window.location = url.toString(); window.location = url.toString();
}); });
// Auto-submit filter form when checkboxes change
document.querySelectorAll('#filterForm input[type="checkbox"]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,286 +1,389 @@
{% extends "layouts/app.twig" %} {% extends "layouts/app.twig" %}
{% block content %} {% block content %}
<div class="px-4 py-3"> <div class="container-fluid">
<!-- Header with search and view controls --> <div class="row">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3"> <!-- Sidebar with filters -->
<div> <div class="col-lg-3 col-xl-2">
<h1 class="display-4 fw-bold text-dark">TV Shows</h1> <div class="card">
<div class="text-muted small mt-1"> <div class="card-header">
</div> <h5 class="mb-0">Filters</h5>
</div>
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
<!-- Search form -->
<form method="GET" class="d-flex gap-2">
<input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<div class="position-relative">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search TV shows..."
class="form-control ps-5"
>
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" 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>
</div> </div>
<button type="submit" class="btn btn-primary"> <div class="card-body">
Search <!-- Filter form -->
</button> <form method="GET" id="filterForm">
</form> <input type="hidden" name="view" value="{{ view_mode }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<input type="hidden" name="search" value="{{ search }}">
<!-- View mode switcher --> <!-- Genre filter -->
<div class="btn-group" role="group"> {% if available_filters.genres %}
{% for mode in view_modes %} <div class="mb-4">
<button <h6 class="fw-bold text-dark mb-2">Genres</h6>
class="btn btn-outline-secondary" {% for genre in available_filters.genres %}
> <div class="form-check">
{% if mode == 'grid' %} <input class="form-check-input"
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"> type="checkbox"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/> name="genres[]"
</svg> value="{{ genre }}"
{% elseif mode == 'list' %} id="genre_{{ genre|lower|replace({' ': '_'}) }}"
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {{ genre in filters.genres ? 'checked' : '' }}>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/> <label class="form-check-label small" for="genre_{{ genre|lower|replace({' ': '_'}) }}">
</svg> {{ genre }}
{% endif %} </label>
{{ mode|title }}
</button>
{% endfor %}
</div>
</div>
</div>
{% if tvshows 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="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>
<h3 class="h5 fw-medium text-dark">
{% if search %}
No TV shows found matching "{{ search }}"
{% else %}
No TV shows found
{% endif %}
</h3>
<p class="text-muted">
{% if search %}
Try adjusting your search terms or browse all TV shows.
{% else %}
Start syncing your TV show libraries to see your TV shows here.
{% endif %}
</p>
{% if search %}
<a href="{{ path_for('tvshows.index') }}" class="btn btn-primary mt-3">
View all TV shows
</a>
{% endif %}
</div>
{% else %}
<!-- Movies content based on view mode -->
{% if view_mode == 'list' %}
<!-- List view -->
<div class="card">
<ul class="list-group list-group-flush">
{% for movie in tvshows %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
{% if movie.poster_url %}
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %}
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
<svg class="text-muted" 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-grow-1">
<h3 class="h6 mb-1">
<a href="{{ path_for('tvshows.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h3>
<div class="d-flex align-items-center gap-3 small text-muted">
{% 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>
{% endfor %}
</div>
{% endif %}
<!-- Year filter -->
{% if available_filters.years %}
<div class="mb-4">
<h6 class="fw-bold text-dark mb-2">Years</h6>
{% for year in available_filters.years %}
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="years[]"
value="{{ year }}"
id="year_{{ year }}"
{{ year in filters.years ? 'checked' : '' }}>
<label class="form-check-label small" for="year_{{ year }}">
{{ year }}
</label>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Filter actions -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm">
Apply Filters
</button>
<a href="{{ path_for('tvshows.index') }}" class="btn btn-outline-secondary btn-sm">
Clear All
</a>
</div>
</form>
</div>
</div>
</div>
<!-- Main content area -->
<div class="col-lg-9 col-xl-10">
<div class="px-4 py-3">
<!-- Header with search and view controls -->
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
<div>
<h1 class="display-4 fw-bold text-dark">TV Shows</h1>
{% if pagination.total_items > 0 %}
<div class="text-muted small mt-1">
{{ pagination.total_items }} TV shows
{% if search %}
matching "{{ search }}"
{% endif %}
{% if filters.genres or filters.years %}
{% if filters.genres %}
<span class="badge bg-primary ms-1">{{ filters.genres|join(', ') }}</span>
{% endif %}
{% if filters.years %}
<span class="badge bg-secondary ms-1">{{ filters.years|join(', ') }}</span>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
<!-- Search form -->
<form method="GET" class="d-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 %}
<input type="hidden" name="genres[]" value="{{ genre }}">
{% endfor %}
{% for year in filters.years %}
<input type="hidden" name="years[]" value="{{ year }}">
{% endfor %}
<div class="position-relative">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search TV shows..."
class="form-control ps-5"
>
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" 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>
</div>
<button type="submit" class="btn btn-primary">
Search
</button>
</form>
<!-- View mode switcher -->
<div class="btn-group" 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 %}"
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}"
>
{% if mode == 'grid' %}
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
{% endif %}
{% if mode == 'list' %}
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
{% endif %}
{{ mode|title }}
</a>
{% endfor %}
</div> </div>
</div> </div>
<div class="d-flex gap-2">
{% if movie.watched %}
<span class="badge bg-success">
Watched
</span>
{% endif %}
{% if movie.is_favorite %}
<span class="badge bg-danger">
Favorite
</span>
{% endif %}
</div>
</div> </div>
</li>
{% endfor %}
</ul>
</div>
{% elseif view_mode == 'covers' %} {% if tvshows is empty %}
<!-- Cover grid view --> <div class="text-center py-5">
<div class="row g-3"> <svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{% for movie in tvshows %}
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100">
{% if movie.poster_url %}
<div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
<img src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
</div>
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
<svg class="text-muted" 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"/> <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> </svg>
</div> <h3 class="h5 fw-medium text-dark">
{% endif %} {% if search or filters.genres or filters.years %}
<div class="card-body"> No TV shows found matching your criteria
<h6 class="card-title text-truncate" title="{{ movie.title }}"> {% else %}
<a href="{{ path_for('tvshows.show', {'id': movie.id}) }}" class="text-decoration-none"> No TV shows found
{{ movie.title }} {% endif %}
</a> </h3>
</h6> <p class="text-muted">
{% if movie.release_date %} {% if search or filters.genres or filters.years %}
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p> Try adjusting your search terms or filters.
{% else %}
Start syncing your TV show libraries to see your TV shows here.
{% endif %}
</p>
{% if search or filters.genres or filters.years %}
<a href="{{ path_for('tvshows.index') }}" class="btn btn-primary mt-3">
Clear filters
</a>
{% endif %} {% endif %}
</div> </div>
</div> {% else %}
</div> <!-- TV Shows content based on view mode -->
{% endfor %} {% if view_mode == 'list' %}
</div> <!-- List view -->
<div class="card">
<ul class="list-group list-group-flush">
{% for tvshow in tvshows %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
{% if tvshow.poster_url %}
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ tvshow.poster_url }}" alt="{{ tvshow.title }}">
{% else %}
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
<svg class="text-muted" 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-grow-1">
<h3 class="h6 mb-1">
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="text-decoration-none">
{{ tvshow.title }}
</a>
</h3>
<div class="d-flex align-items-center gap-3 small text-muted">
{% if tvshow.first_air_date %}
<span>{{ tvshow.first_air_date|date('Y') }}</span>
{% endif %}
{% if tvshow.rating %}
<span>⭐ {{ tvshow.rating }}/10</span>
{% endif %}
{% if tvshow.number_of_seasons %}
<span>{{ tvshow.number_of_seasons }} season{{ tvshow.number_of_seasons > 1 ? 's' : '' }}</span>
{% endif %}
{% if 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="d-flex gap-2">
{% if tvshow.is_favorite %}
<span class="badge bg-danger">
Favorite
</span>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{% else %} {% elseif view_mode == 'covers' %}
<!-- Default grid view --> <!-- Cover grid view -->
<div class="row g-3"> <div class="row g-3">
{% for movie in tvshows %} {% for tvshow in tvshows %}
<div class="col-12 col-md-6 col-lg-3 col-xl-2"> <div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100"> <div class="card h-100">
<div class="card-body"> {% if tvshow.poster_url %}
<div class="d-flex align-items-center"> <div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
<div class="flex-shrink-0"> <img src="/images/{{ tvshow.poster_url }}" alt="{{ tvshow.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
{% if movie.poster_url %} </div>
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
{% else %} {% else %}
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="text-muted" 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"/> <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> </svg>
</div> </div>
{% endif %} {% endif %}
</div> <div class="card-body">
<div class="ms-3 flex-grow-1"> <h6 class="card-title text-truncate" title="{{ tvshow.title }}">
<h5 class="card-title mb-1"> <a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="text-decoration-none">
<a href="{{ path_for('tvshows.show', {'id': movie.id}) }}" class="text-decoration-none"> {{ tvshow.title }}
{{ movie.title }} </a>
</a> </h6>
</h5> {% if tvshow.first_air_date %}
<div class="d-flex align-items-center gap-2 small text-muted mb-2"> <p class="card-text small text-muted">{{ tvshow.first_air_date|date('Y') }}</p>
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
{% endif %} {% endif %}
</div> </div>
{% if movie.source_name %}
<p class="card-text small text-muted mb-2">
{{ movie.source_name }}
</p>
{% endif %}
</div> </div>
</div> </div>
{% if movie.overview %} {% endfor %}
<div class="mt-3"> </div>
<p class="card-text small text-muted" 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 %} {% else %}
</p> <!-- Default grid view -->
<div class="row g-3">
{% for tvshow in tvshows %}
<div class="col-12 col-md-6 col-lg-3 col-xl-2">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-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-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
<svg class="text-muted" 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="ms-3 flex-grow-1">
<h5 class="card-title mb-1">
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="text-decoration-none">
{{ tvshow.title }}
</a>
</h5>
<div class="d-flex align-items-center gap-2 small text-muted mb-2">
{% if tvshow.first_air_date %}
<span>{{ tvshow.first_air_date|date('Y') }}</span>
{% endif %}
{% if tvshow.rating %}
<span>⭐ {{ tvshow.rating }}/10</span>
{% endif %}
</div>
{% if tvshow.source_name %}
<p class="card-text small text-muted mb-2">
{{ tvshow.source_name }}
</p>
{% endif %}
</div>
</div>
{% if tvshow.overview %}
<div class="mt-3">
<p class="card-text small text-muted" 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 d-flex justify-content-between align-items-center small text-muted">
{% if tvshow.number_of_seasons %}
<span>{{ tvshow.number_of_seasons }} season{{ tvshow.number_of_seasons > 1 ? 's' : '' }}</span>
{% endif %}
<div class="d-flex gap-1">
{% if tvshow.is_favorite %}
<span class="badge bg-danger">
Favorite
</span>
{% endif %}
</div>
</div>
</div>
</div>
</div> </div>
{% endif %} {% endfor %}
<div class="mt-3 d-flex justify-content-between align-items-center small text-muted"> </div>
{% if movie.runtime_minutes %} {% endif %}
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="d-flex align-items-center justify-content-between mt-4">
<div class="d-flex align-items-center gap-2">
<label for="per_page" class="form-label mb-0">Show:</label>
<select id="per_page" class="form-select form-select-sm w-auto">
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
</select>
<span class="text-muted small">per page</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for year in filters.years %}&years[]={{ year }}{% endfor %}"
class="btn btn-outline-secondary btn-sm">
Previous
</a>
{% endif %} {% endif %}
<div class="d-flex gap-1">
{% if movie.watched %} <div class="btn-group" role="group">
<span class="badge bg-success"> {% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
Watched <a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for year in filters.years %}&years[]={{ year }}{% endfor %}"
</span> class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
{% endif %} {{ page_num }}
{% if movie.is_favorite %} </a>
<span class="badge bg-danger"> {% endfor %}
Favorite
</span>
{% endif %}
</div> </div>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}{% for genre in filters.genres %}&genres[]={{ genre }}{% endfor %}{% for year in filters.years %}&years[]={{ year }}{% endfor %}"
class="btn btn-outline-secondary btn-sm">
Next
</a>
{% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
{% endif %}
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="d-flex align-items-center justify-content-between mt-4">
<div class="d-flex align-items-center gap-2">
<label for="per_page" class="form-label mb-0">Show:</label>
<select id="per_page" class="form-select form-select-sm w-auto">
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
</select>
<span class="text-muted small">per page</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-outline-secondary btn-sm">
Previous
</a>
{% endif %}
<div class="btn-group" role="group">
{% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
{{ page_num }}
</a>
{% endfor %}
</div>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
class="btn btn-outline-secondary btn-sm">
Next
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
</div> </div>
<script>
document.getElementById('per_page')?.addEventListener('change', function() {
const url = new URL(window.location);
url.searchParams.set('per_page', this.value);
url.searchParams.set('page', '1'); // Reset to first page
window.location = url.toString();
});
// Auto-submit filter form when checkboxes change
document.querySelectorAll('#filterForm input[type="checkbox"]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
});
</script>
{% endblock %} {% endblock %}