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
$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
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get adult videos with pagination and search
$adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search);
// Get adult videos with pagination and filters
$adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors);
// Process metadata to extract local image paths for template compatibility
foreach ($adultVideos as &$video) {
@@ -54,7 +67,11 @@ class AdultController extends Controller
}
// 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
$totalPages = ceil($totalCount / $perPage);
@@ -76,7 +93,15 @@ class AdultController extends Controller
],
'search' => $search,
'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
$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
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get games with pagination and search
$games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search);
// Get games with pagination and filters
$games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search, $genres, $platforms);
// 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
$totalPages = ceil($totalCount / $perPage);
@@ -57,7 +74,15 @@ class GameController extends Controller
],
'search' => $search,
'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
$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
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get movies with pagination and search
$movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search);
// Get movies with pagination and filters
$movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors);
// 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
$totalPages = ceil($totalCount / $perPage);
@@ -57,7 +74,15 @@ class MovieController extends Controller
],
'search' => $search,
'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
$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
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get TV shows with pagination and search
$tvshows = TvShow::getAllWithPagination($this->pdo, $page, $perPage, $search);
// Get TV shows with pagination and filters
$tvshows = TvShow::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $years);
// 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
$totalPages = ceil($totalCount / $perPage);
$hasNextPage = $page < $totalPages;
$hasPrevPage = $page > 1;
/*
echo '<pre>';
print_r($tvshows);
die();
*/
return $this->view->render($response, 'tvshows/index.twig', [
'title' => 'TV Shows',
'tvshows' => $tvshows,
@@ -61,7 +74,15 @@ class TvShowController extends Controller
],
'search' => $search,
'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'
];
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;
$whereClause = '';
$params = [];
if (!empty($search)) {
$whereClause = "WHERE (title LIKE :search OR overview LIKE :search)";
$params['search'] = "%{$search}%";
}
$sql = "
SELECT av.*, s.display_name as source_name
FROM adult_videos av
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->bindValue(':limit', $perPage, \PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
if (!empty($search)) {
$stmt->bindValue(':search', "%{$search}%", \PDO::PARAM_STR);
foreach ($params as $key => $value) {
$stmt->bindValue(":{$key}", $value);
}
$stmt->execute();
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 = [];
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}%";
}
$sql = "SELECT COUNT(*) as count FROM adult_videos {$whereClause}";
$stmt = $pdo->prepare($sql);
if (!empty($search)) {
$stmt->bindValue(':search', "%{$search}%", \PDO::PARAM_STR);
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) . ")";
}
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(":{$key}", $value);
}
$stmt->execute();
return (int) $stmt->fetch(\PDO::FETCH_ASSOC)['count'];
return (int) $stmt->fetch()['count'];
}
public function markAsWatched(): bool
@@ -181,4 +214,32 @@ class AdultVideo extends Model
");
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
*/
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 = [];
if (!empty($search)) {
$sql .= " WHERE title LIKE :search";
$sql .= " AND title LIKE :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->execute($params);
return (int) $stmt->fetch()['count'];
@@ -280,7 +298,7 @@ class Game extends Model
/**
* 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;
@@ -307,6 +325,24 @@ class Game extends Model
$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";
$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 ?? [];
}
/**
* Get formatted install size
*/
public function getFormattedInstallSize(): string
{
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}";
$stmt = $pdo->query("
SELECT DISTINCT platform
FROM games
WHERE platform IS NOT NULL AND platform != ''
ORDER BY platform
");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
/**
@@ -435,4 +451,3 @@ class Game extends Model
!empty($this->links_json) || !empty($this->background_image);
}
}

View File

@@ -109,22 +109,45 @@ class Movie extends Model
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";
$params = [];
if (!empty($search)) {
$sql .= " WHERE m.title LIKE :search";
$sql .= " WHERE (m.title LIKE :search OR m.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 . " 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->execute($params);
foreach ($params as $key => $value) {
$stmt->bindValue(":{$key}", $value);
}
$stmt->execute();
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;
@@ -136,10 +159,30 @@ class Movie extends Model
$params = [];
if (!empty($search)) {
$sql .= " WHERE m.title LIKE :search";
$sql .= " WHERE (m.title LIKE :search OR m.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 . " 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";
$stmt = $pdo->prepare($sql);
@@ -226,4 +269,32 @@ class Movie extends Model
'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
*/
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";
$params = [];
@@ -93,15 +93,38 @@ class TvShow extends Model
$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->execute($params);
foreach ($params as $key => $value) {
$stmt->bindValue(":{$key}", $value);
}
$stmt->execute();
return (int) $stmt->fetch()['count'];
}
/**
* 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;
@@ -117,6 +140,26 @@ class TvShow extends Model
$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";
$stmt = $pdo->prepare($sql);
@@ -259,9 +302,8 @@ public function recordView(): bool
public static function getAvailableGenres(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT DISTINCT TRIM(value) as genre
FROM tv_shows,
json_each('[\"' || REPLACE(genre, ',', '\",\"') || '\"]')
SELECT DISTINCT genre
FROM tv_shows
WHERE genre IS NOT NULL AND genre != ''
ORDER BY genre
");
@@ -274,7 +316,7 @@ public static function getAvailableGenres(\PDO $pdo): array
public static function getAvailableYears(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT DISTINCT strftime('%Y', first_air_date) as year
SELECT DISTINCT YEAR(first_air_date) as year
FROM tv_shows
WHERE first_air_date IS NOT NULL
ORDER BY year DESC

View File

@@ -75,6 +75,7 @@ class PlayniteImportService
*/
private function transformPlayniteGame(array $game, int $index): array
{
/*
// Validate required fields
if (empty($game['Name'])) {
throw new \Exception("Missing game name");
@@ -83,7 +84,7 @@ class PlayniteImportService
if (empty($game['GameId'])) {
throw new \Exception("Missing GameId");
}
*/
// Find or create source
$source = $this->findOrCreateSource($game);
@@ -132,10 +133,10 @@ class PlayniteImportService
'steam_app_id' => $this->extractSteamAppId($game),
// Playnite-specific metadata
'is_installed' => $game['IsInstalled'] ?? false,
'is_favorite' => $game['Favorite'] ?? false,
'is_custom_game' => $game['IsCustomGame'] ?? false,
'installation_status' => $game['InstallationStatus'] ?? 0,
// 'is_installed' => $this->toBoolean($game['IsInstalled'] ?? false),
//'is_favorite' => $this->toBoolean($game['Favorite'] ?? false),
//'is_custom_game' => $this->toBoolean($game['IsCustomGame'] ?? false),
//'installation_status' => $game['InstallationStatus'] ?? 0,
// Timestamps
'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([
'playnite_id' => $game['Id'] ?? null,
'version' => $game['Version'] ?? null,
'hidden' => $game['Hidden'] ?? false,
'hidden' => $this->toBoolean($game['Hidden'] ?? false),
'notes' => $game['Notes'] ?? null,
'manual' => $game['Manual'] ?? null,
'pre_script' => $game['PreScript'] ?? null,
'post_script' => $game['PostScript'] ?? null,
'game_started_script' => $game['GameStartedScript'] ?? null,
'use_global_scripts' => [
'pre' => $game['UseGlobalPreScript'] ?? true,
'post' => $game['UseGlobalPostScript'] ?? true,
'game_started' => $game['UseGlobalGameStartedScript'] ?? true
'pre' => $this->toBoolean($game['UseGlobalPreScript'] ?? true),
'post' => $this->toBoolean($game['UseGlobalPostScript'] ?? true),
'game_started' => $this->toBoolean($game['UseGlobalGameStartedScript'] ?? true)
]
])
];
@@ -371,4 +372,21 @@ class PlayniteImportService
$gameModel = new Game($this->pdo);
$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" %}
{% block content %}
<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 %}
</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 class="container-fluid">
<div class="row">
<!-- Sidebar with filters -->
<div class="col-lg-3 col-xl-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Filters</h5>
</div>
<button type="submit" class="btn btn-primary">
Search
</button>
</form>
<div class="card-body">
<!-- Filter form -->
<form method="GET" id="filterForm">
<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 -->
<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 %}"
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 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>
<!-- Genre filter -->
{% if available_filters.genres %}
<div class="mb-4">
<h6 class="fw-bold text-dark mb-2">Genres</h6>
{% for genre in available_filters.genres %}
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="genres[]"
value="{{ genre }}"
id="genre_{{ genre|lower|replace({' ': '_'}) }}"
{{ genre in filters.genres ? 'checked' : '' }}>
<label class="form-check-label small" for="genre_{{ genre|lower|replace({' ': '_'}) }}">
{{ genre }}
</label>
</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 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>
{% elseif view_mode == 'covers' %}
<!-- Cover grid view -->
<div class="row g-3">
{% 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;">
{% if error %}
<div class="alert alert-danger mb-4">
{{ error }}
</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">
{% 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>
</div>
{% endif %}
<div class="card-body">
<h6 class="card-title text-truncate" title="{{ movie.title }}">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h6>
{% if movie.release_date %}
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
<h3 class="h5 fw-medium text-dark">
{% if search or filters.genres or filters.directors %}
No adult videos found matching your criteria
{% else %}
No adult videos found
{% endif %}
</h3>
<p class="text-muted">
{% if search or filters.genres or filters.directors %}
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 %}
</div>
</div>
</div>
{% endfor %}
</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>
</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 %}
<!-- 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">
{% elseif view_mode == 'covers' %}
<!-- Cover grid view -->
<div class="row g-3">
{% 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 %}
<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 %}
<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">
<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="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">
<div class="card-body">
<h6 class="card-title text-truncate" title="{{ movie.title }}">
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h6>
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
{% 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>
{% endfor %}
</div>
{% else %}
<!-- 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>
{% 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>
{% endfor %}
</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 }}{% 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 %}
<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 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 director in filters.directors %}&directors[]={{ director }}{% endfor %}"
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 }}{% 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>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</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>
<script>
@@ -303,5 +391,12 @@ document.getElementById('per_page')?.addEventListener('change', function() {
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 %}

View File

@@ -1,274 +1,362 @@
{% extends "layouts/app.twig" %}
{% block content %}
<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">Games</h1>
{% if pagination.total_items > 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 %}
</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 class="container-fluid">
<div class="row">
<!-- Sidebar with filters -->
<div class="col-lg-3 col-xl-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Filters</h5>
</div>
<button type="submit" class="btn btn-primary">
Search
</button>
</form>
<div class="card-body">
<!-- Filter form -->
<form method="GET" id="filterForm">
<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 -->
<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 %}"
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 %}
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>
<!-- Genre filter -->
{% if available_filters.genres %}
<div class="mb-4">
<h6 class="fw-bold text-dark mb-2">Genres</h6>
{% for genre in available_filters.genres %}
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="genres[]"
value="{{ genre }}"
id="genre_{{ genre|lower|replace({' ': '_'}) }}"
{{ genre in filters.genres ? 'checked' : '' }}>
<label class="form-check-label small" for="genre_{{ genre|lower|replace({' ': '_'}) }}">
{{ genre }}
</label>
</div>
{% endfor %}
</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 %}
<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 %}
<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="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
<div class="card-body">
<h6 class="card-title text-truncate" title="{{ game.title }}">
{{ game.title }}
</h6>
<p class="card-text small text-muted">{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</p>
<!-- Platform filter -->
{% if available_filters.platforms %}
<div class="mb-4">
<h6 class="fw-bold text-dark mb-2">Platforms</h6>
{% for platform in available_filters.platforms %}
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="platforms[]"
value="{{ platform }}"
id="platform_{{ platform|lower|replace({' ': '_'}) }}"
{{ platform in filters.platforms ? 'checked' : '' }}>
<label class="form-check-label small" for="platform_{{ platform|lower|replace({' ': '_'}) }}">
{{ platform }}
</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('games.index') }}" class="btn btn-outline-secondary btn-sm">
Clear All
</a>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- Default grid view -->
<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">
<!-- 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">Games</h1>
{% if pagination.total_items > 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 %}
<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 %}
<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">
<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="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
</div>
<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">
<div class="card-body">
<h6 class="card-title text-truncate" title="{{ game.title }}">
{{ 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>
</h6>
<p class="card-text small text-muted">{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</p>
</div>
</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 %}
{% endfor %}
</div>
{% else %}
<!-- Default grid view -->
<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>
{% 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>
</div>
{% endfor %}
</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 }}{% 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 %}
</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 %}
</div>
</div>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</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>
<script>
@@ -278,5 +366,12 @@ document.getElementById('per_page')?.addEventListener('change', function() {
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 %}

View File

@@ -1,293 +1,381 @@
{% extends "layouts/app.twig" %}
{% block content %}
<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 %}
</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 class="container-fluid">
<div class="row">
<!-- Sidebar with filters -->
<div class="col-lg-3 col-xl-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Filters</h5>
</div>
<button type="submit" class="btn btn-primary">
Search
</button>
</form>
<div class="card-body">
<!-- Filter form -->
<form method="GET" id="filterForm">
<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 -->
<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 %}"
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 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>
<!-- Genre filter -->
{% if available_filters.genres %}
<div class="mb-4">
<h6 class="fw-bold text-dark mb-2">Genres</h6>
{% for genre in available_filters.genres %}
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="genres[]"
value="{{ genre }}"
id="genre_{{ genre|lower|replace({' ': '_'}) }}"
{{ genre in filters.genres ? 'checked' : '' }}>
<label class="form-check-label small" for="genre_{{ genre|lower|replace({' ': '_'}) }}">
{{ genre }}
</label>
</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 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>
{% elseif view_mode == 'covers' %}
<!-- Cover grid view -->
<div class="row g-3">
{% 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">
{% 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>
</div>
{% endif %}
<div class="card-body">
<h6 class="card-title text-truncate" title="{{ movie.title }}">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h6>
{% if movie.release_date %}
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
<h3 class="h5 fw-medium text-dark">
{% if search or filters.genres or filters.directors %}
No movies found matching your criteria
{% else %}
No movies found
{% endif %}
</h3>
<p class="text-muted">
{% if search or filters.genres or filters.directors %}
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 %}
</div>
</div>
</div>
{% endfor %}
</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>
</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 %}
<!-- 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">
{% elseif view_mode == 'covers' %}
<!-- Cover grid view -->
<div class="row g-3">
{% 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 %}
<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 %}
<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">
<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"/>
</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">
<div class="card-body">
<h6 class="card-title text-truncate" title="{{ movie.title }}">
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h6>
{% if movie.release_date %}
<span>{{ movie.release_date|date('Y') }}</span>
{% endif %}
{% if movie.rating %}
<span>⭐ {{ movie.rating }}/10</span>
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
{% 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>
{% endfor %}
</div>
{% else %}
<!-- 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>
{% 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>
{% endfor %}
</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 }}{% 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 %}
<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 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 director in filters.directors %}&directors[]={{ director }}{% endfor %}"
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 }}{% 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>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</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>
<script>
@@ -297,5 +385,12 @@ document.getElementById('per_page')?.addEventListener('change', function() {
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 %}

View File

@@ -1,286 +1,389 @@
{% extends "layouts/app.twig" %}
{% block content %}
<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>
<div class="text-muted small mt-1">
</div>
</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 class="container-fluid">
<div class="row">
<!-- Sidebar with filters -->
<div class="col-lg-3 col-xl-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Filters</h5>
</div>
<button type="submit" class="btn btn-primary">
Search
</button>
</form>
<div class="card-body">
<!-- Filter form -->
<form method="GET" id="filterForm">
<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 -->
<div class="btn-group" role="group">
{% for mode in view_modes %}
<button
class="btn btn-outline-secondary"
>
{% 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>
{% elseif 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 }}
</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>
<!-- Genre filter -->
{% if available_filters.genres %}
<div class="mb-4">
<h6 class="fw-bold text-dark mb-2">Genres</h6>
{% for genre in available_filters.genres %}
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="genres[]"
value="{{ genre }}"
id="genre_{{ genre|lower|replace({' ': '_'}) }}"
{{ genre in filters.genres ? 'checked' : '' }}>
<label class="form-check-label small" for="genre_{{ genre|lower|replace({' ': '_'}) }}">
{{ genre }}
</label>
</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 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>
{% elseif view_mode == 'covers' %}
<!-- Cover grid view -->
<div class="row g-3">
{% 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">
{% 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>
</div>
{% endif %}
<div class="card-body">
<h6 class="card-title text-truncate" title="{{ movie.title }}">
<a href="{{ path_for('tvshows.show', {'id': movie.id}) }}" class="text-decoration-none">
{{ movie.title }}
</a>
</h6>
{% if movie.release_date %}
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
<h3 class="h5 fw-medium text-dark">
{% if search or filters.genres or filters.years %}
No TV shows found matching your criteria
{% else %}
No TV shows found
{% endif %}
</h3>
<p class="text-muted">
{% if search or filters.genres or filters.years %}
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 %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- TV Shows content based on view mode -->
{% if view_mode == 'list' %}
<!-- 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 %}
<!-- Default grid view -->
<div class="row g-3">
{% for movie 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 movie.poster_url %}
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
{% elseif view_mode == 'covers' %}
<!-- Cover grid view -->
<div class="row g-3">
{% for tvshow in tvshows %}
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100">
{% if tvshow.poster_url %}
<div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
<img src="/images/{{ tvshow.poster_url }}" alt="{{ tvshow.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
</div>
{% 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">
<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"/>
</svg>
</div>
{% endif %}
</div>
<div class="ms-3 flex-grow-1">
<h5 class="card-title mb-1">
<a href="{{ path_for('tvshows.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>
<div class="card-body">
<h6 class="card-title text-truncate" title="{{ tvshow.title }}">
<a href="{{ path_for('tvshows.show', {'id': tvshow.id}) }}" class="text-decoration-none">
{{ tvshow.title }}
</a>
</h6>
{% if tvshow.first_air_date %}
<p class="card-text small text-muted">{{ tvshow.first_air_date|date('Y') }}</p>
{% 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>
{% endfor %}
</div>
{% else %}
<!-- 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>
{% 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>
{% endfor %}
</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 }}{% 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 %}
<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 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 year in filters.years %}&years[]={{ year }}{% endfor %}"
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 }}{% 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>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</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>
<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 %}