610 lines
32 KiB
HTML
610 lines
32 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}ROM Library - DOS Frontend{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mb-6">
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4">
|
|
<h1 class="text-3xl font-bold mb-2 sm:mb-0">ROM Library</h1>
|
|
<div class="flex flex-col sm:flex-row gap-2">
|
|
<!-- Search Form -->
|
|
<form method="GET" class="flex gap-2">
|
|
<input type="hidden" name="page" value="1">
|
|
<input type="hidden" name="per_page" value="{{ per_page }}">
|
|
<input type="hidden" name="view" value="{{ view }}">
|
|
<div class="relative">
|
|
<input type="text" name="search" placeholder="Search games..."
|
|
value="{% if search and not search.startswith('genre:') and not search.startswith('tag:') %}{{ search }}{% endif %}"
|
|
class="w-full sm:w-64 px-4 py-2 pl-10 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 21-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white">Search</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls Bar -->
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
|
<div class="text-gray-400">
|
|
{% if is_demo %}
|
|
<span class="text-yellow-400">Demo Mode:</span> You can browse ROMs but cannot download or favorite them.
|
|
<button onclick="showLogin()" class="text-blue-400 hover:text-blue-300 underline">Login</button> for full access.
|
|
{% else %}
|
|
Showing {{ games|length }} of {{ total_games }} ROMs
|
|
{% if search %} for "{{ search }}"{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4 items-center">
|
|
<!-- Results per page -->
|
|
<div class="flex items-center gap-2">
|
|
<label class="text-gray-400 text-sm">Show:</label>
|
|
<select onchange="changePerPage(this.value)" class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white text-sm min-w-16">
|
|
<option value="20" {% if per_page == 20 %}selected{% endif %}>20</option>
|
|
<option value="50" {% if per_page == 50 %}selected{% endif %}>50</option>
|
|
<option value="100" {% if per_page == 100 %}selected{% endif %}>100</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- View Toggle -->
|
|
<div class="flex bg-gray-700 rounded-lg p-1">
|
|
<button onclick="changeView('grid')"
|
|
class="px-4 py-2 rounded text-sm touch-manipulation {{ 'bg-blue-600 text-white' if view == 'grid' else 'text-gray-400 hover:text-white active:bg-gray-600' }}">
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
|
|
</svg>
|
|
<span class="ml-1 hidden sm:inline">Grid</span>
|
|
</button>
|
|
<button onclick="changeView('list')"
|
|
class="px-4 py-2 rounded text-sm touch-manipulation {{ 'bg-blue-600 text-white' if view == 'list' else 'text-gray-400 hover:text-white active:bg-gray-600' }}">
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path>
|
|
</svg>
|
|
<span class="ml-1 hidden sm:inline">List</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Games Grid View -->
|
|
{% if view == 'grid' %}
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
{% for game in games %}
|
|
<div class="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden hover:shadow-lg transform hover:-translate-y-1 transition-all duration-200 relative group">
|
|
<!-- Clickable overlay for the card -->
|
|
<a href="/games/{{ game.id }}" class="absolute inset-0 z-10 cursor-pointer" aria-label="View {{ game.metadata_obj.title or game.title }} details"></a>
|
|
|
|
<!-- Cover Image -->
|
|
<div class="aspect-[3/4] bg-gray-900 relative overflow-hidden">
|
|
{% if game.metadata_obj and (game.metadata_obj.cover_image_path or (game.metadata_obj.cover_image and game.metadata_obj.cover_image.startswith('http'))) %}
|
|
<img data-game-id="{{ game.id }}"
|
|
{% if game.metadata_obj.cover_image_path %}src="/images/{{ game.metadata_obj.cover_image_path.name }}"{% else %}src="{{ game.metadata_obj.cover_image }}"{% endif %}
|
|
alt="{{ game.metadata_obj.title or game.title }}"
|
|
class="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
|
<div class="w-full h-full bg-gradient-to-br from-gray-800 to-gray-900 hidden items-center justify-center">
|
|
<div class="text-gray-400 text-center p-4">
|
|
<svg class="w-16 h-16 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2h2a2 2 0 002-2z"></path>
|
|
</svg>
|
|
<p class="text-sm font-medium">{{ (game.metadata_obj.title or game.title)[:20] }}{% if (game.metadata_obj.title or game.title)|length > 20 %}...{% endif %}</p>
|
|
<p class="text-xs text-gray-500 mt-1">DOS Game</p>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="w-full h-full bg-gradient-theme flex items-center justify-center relative overflow-hidden">
|
|
<!-- Background Pattern -->
|
|
<div class="absolute inset-0 opacity-10">
|
|
<svg width="100%" height="100%" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<pattern id="pixel-grid" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
|
<rect width="2" height="2" fill="currentColor" opacity="0.3"/>
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#pixel-grid)"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div class="text-center p-3 relative z-10">
|
|
<!-- DosVault Logo -->
|
|
<div class="mb-4">
|
|
<svg class="w-20 h-20 mx-auto text-accent" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<!-- Vault door with enhanced styling -->
|
|
<circle cx="16" cy="16" r="15" fill="currentColor" opacity="0.15" stroke="currentColor" stroke-width="0.5"/>
|
|
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.8"/>
|
|
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.6"/>
|
|
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.4"/>
|
|
|
|
<!-- Enhanced DOS-style pixels -->
|
|
<rect x="5" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
|
<rect x="10" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
|
<rect x="19" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
|
<rect x="24" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
|
<rect x="5" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
|
<rect x="24" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
|
<rect x="5" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
|
<rect x="25" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
|
|
|
<!-- Enhanced Handle -->
|
|
<rect x="19" y="13" width="8" height="6" rx="3" fill="currentColor" opacity="0.8"/>
|
|
<circle cx="23" cy="16" r="1.5" fill="var(--primary-bg)"/>
|
|
<circle cx="23" cy="16" r="0.8" fill="currentColor"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Game Title -->
|
|
<div class="mb-2">
|
|
<h3 class="text-accent font-bold text-sm leading-tight mb-1">
|
|
{{ (game.metadata_obj.title or game.title)[:18] }}{% if (game.metadata_obj.title or game.title)|length > 18 %}...{% endif %}
|
|
</h3>
|
|
<div class="w-12 h-0.5 bg-accent mx-auto opacity-60"></div>
|
|
</div>
|
|
|
|
<!-- Branding -->
|
|
<div class="text-xs text-secondary opacity-75 font-medium">
|
|
<div class="mb-1">CLASSIC DOS GAME</div>
|
|
<div class="text-accent text-[10px] font-bold tracking-wider">DOSVAULT</div>
|
|
</div>
|
|
|
|
<!-- Decorative Corner Elements -->
|
|
<div class="absolute top-1 left-1">
|
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
|
<polygon points="0,0 8,0 0,8"/>
|
|
</svg>
|
|
</div>
|
|
<div class="absolute top-1 right-1">
|
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
|
<polygon points="8,0 8,8 0,0"/>
|
|
</svg>
|
|
</div>
|
|
<div class="absolute bottom-1 left-1">
|
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
|
<polygon points="0,8 0,0 8,8"/>
|
|
</svg>
|
|
</div>
|
|
<div class="absolute bottom-1 right-1">
|
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
|
<polygon points="8,8 0,8 8,0"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Favorite Button -->
|
|
{% if not is_demo %}
|
|
<button onclick="event.stopPropagation(); toggleFavorite({{ game.id }})"
|
|
class="absolute top-2 right-2 w-8 h-8 bg-black bg-opacity-50 rounded-full flex items-center justify-center {% if game.id in user_favorites %}text-red-600{% else %}text-red-400{% endif %} hover:text-red-300 hover:bg-opacity-75 transition-all z-20"
|
|
id="favorite-{{ game.id }}">
|
|
{% if game.id in user_favorites %}♥{% else %}♡{% endif %}
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Game Info -->
|
|
<div class="p-3">
|
|
<h3 class="font-semibold text-blue-400 truncate mb-1 text-sm">
|
|
{{ game.metadata_obj.title or game.title }}
|
|
</h3>
|
|
{% if game.metadata_obj and game.metadata_obj.year %}
|
|
<p class="text-xs text-gray-400 mb-2">{{ game.metadata_obj.year }}</p>
|
|
{% endif %}
|
|
|
|
<div class="flex justify-between items-center">
|
|
<span class="text-xs text-gray-500">Click to view details</span>
|
|
{% if not is_demo %}
|
|
<button onclick="event.stopPropagation(); downloadGame({{ game.id }})"
|
|
class="bg-green-600 hover:bg-green-700 px-2 py-1 rounded text-xs z-20 relative">
|
|
Download
|
|
</button>
|
|
{% else %}
|
|
<span class="bg-gray-600 px-2 py-1 rounded text-xs cursor-not-allowed">
|
|
Login
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Games List View -->
|
|
{% if view == 'list' %}
|
|
<div class="space-y-2">
|
|
{% for game in games %}
|
|
<div class="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors hover:shadow-lg transform hover:-translate-y-1 transition-all duration-200 relative group">
|
|
<!-- Clickable overlay for the card -->
|
|
<a href="/games/{{ game.id }}" class="absolute inset-0 z-10 cursor-pointer" aria-label="View {{ game.metadata_obj.title or game.title }} details"></a>
|
|
|
|
<div class="p-4 flex items-center gap-4">
|
|
<!-- Cover Image -->
|
|
<div class="w-16 h-20 bg-gray-900 rounded overflow-hidden flex-shrink-0 relative">
|
|
{% if game.metadata_obj and (game.metadata_obj.cover_image_path or (game.metadata_obj.cover_image and game.metadata_obj.cover_image.startswith('http'))) %}
|
|
<img data-game-id="{{ game.id }}"
|
|
{% if game.metadata_obj.cover_image_path %}src="/images/{{ game.metadata_obj.cover_image_path.name }}"{% else %}src="{{ game.metadata_obj.cover_image }}"{% endif %}
|
|
alt="{{ game.metadata_obj.title or game.title }}"
|
|
class="w-full h-full object-cover"
|
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
|
<div class="w-full h-full bg-gradient-theme hidden items-center justify-center relative overflow-hidden">
|
|
<!-- Background Pattern -->
|
|
<div class="absolute inset-0 opacity-10">
|
|
<svg width="100%" height="100%" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<pattern id="pixel-grid-list-fallback" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
|
<rect width="2" height="2" fill="currentColor" opacity="0.3"/>
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#pixel-grid-list-fallback)"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div class="text-center p-3 relative z-10">
|
|
<!-- DosVault Logo -->
|
|
<div class="mb-4">
|
|
<svg class="w-20 h-20 mx-auto text-accent" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<!-- Vault door with enhanced styling -->
|
|
<circle cx="16" cy="16" r="15" fill="currentColor" opacity="0.15" stroke="currentColor" stroke-width="0.5"/>
|
|
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.8"/>
|
|
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.6"/>
|
|
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.4"/>
|
|
|
|
<!-- Enhanced DOS-style pixels -->
|
|
<rect x="5" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
|
<rect x="10" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
|
<rect x="19" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
|
<rect x="24" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
|
<rect x="5" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
|
<rect x="24" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
|
<rect x="5" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
|
<rect x="25" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
|
|
|
<!-- Enhanced Handle -->
|
|
<rect x="19" y="13" width="8" height="6" rx="3" fill="currentColor" opacity="0.8"/>
|
|
<circle cx="23" cy="16" r="1.5" fill="var(--primary-bg)"/>
|
|
<circle cx="23" cy="16" r="0.8" fill="currentColor"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Game Title -->
|
|
<div class="mb-2">
|
|
<h3 class="text-accent font-bold text-sm leading-tight mb-1">
|
|
{{ (game.metadata_obj.title or game.title)[:18] }}{% if (game.metadata_obj.title or game.title)|length > 18 %}...{% endif %}
|
|
</h3>
|
|
<div class="w-12 h-0.5 bg-accent mx-auto opacity-60"></div>
|
|
</div>
|
|
|
|
<!-- Branding -->
|
|
<div class="text-xs text-secondary opacity-75 font-medium">
|
|
<div class="mb-1">CLASSIC DOS GAME</div>
|
|
<div class="text-accent text-[10px] font-bold tracking-wider">DOSVAULT</div>
|
|
</div>
|
|
|
|
<!-- Decorative Corner Elements -->
|
|
<div class="absolute top-1 left-1">
|
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
|
<polygon points="0,0 8,0 0,8"/>
|
|
</svg>
|
|
</div>
|
|
<div class="absolute top-1 right-1">
|
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
|
<polygon points="8,0 8,8 0,0"/>
|
|
</svg>
|
|
</div>
|
|
<div class="absolute bottom-1 left-1">
|
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
|
<polygon points="0,8 0,0 8,8"/>
|
|
</svg>
|
|
</div>
|
|
<div class="absolute bottom-1 right-1">
|
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
|
<polygon points="8,8 0,8 8,0"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="w-full h-full bg-gradient-theme flex items-center justify-center relative overflow-hidden">
|
|
<!-- Compact Background Pattern -->
|
|
<div class="absolute inset-0 opacity-8">
|
|
<svg width="100%" height="100%" viewBox="0 0 32 32" fill="none">
|
|
<defs>
|
|
<pattern id="pixel-grid-small" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
|
<rect width="1" height="1" fill="currentColor" opacity="0.2"/>
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#pixel-grid-small)"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Compact Logo -->
|
|
<div class="text-center relative z-10">
|
|
<svg class="w-8 h-8 mx-auto text-accent mb-1" viewBox="0 0 32 32" fill="none">
|
|
<!-- Simplified vault door -->
|
|
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="1.5" fill="currentColor" opacity="0.1"/>
|
|
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1" opacity="0.6"/>
|
|
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.3"/>
|
|
|
|
<!-- Minimal pixels -->
|
|
<rect x="6" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
|
<rect x="24" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
|
<rect x="6" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
|
<rect x="24" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
|
|
|
<!-- Compact handle -->
|
|
<rect x="20" y="14" width="6" height="4" rx="2" fill="currentColor" opacity="0.7"/>
|
|
<circle cx="22" cy="16" r="0.8" fill="var(--primary-bg)"/>
|
|
</svg>
|
|
<div class="text-accent text-[8px] font-bold tracking-wide opacity-75">DOSVAULT</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Game Info -->
|
|
<div class="flex-grow min-w-0">
|
|
<h3 class="text-lg font-semibold text-blue-400 truncate">
|
|
{{ game.metadata_obj.title or game.title }}
|
|
</h3>
|
|
<div class="flex flex-wrap gap-4 text-sm text-gray-300 mt-1">
|
|
{% if game.metadata_obj and game.metadata_obj.year %}
|
|
<span class="text-gray-400">{{ game.metadata_obj.year }}</span>
|
|
{% endif %}
|
|
{% if game.metadata_obj and game.metadata_obj.developer %}
|
|
<span class="text-gray-400">{{ game.metadata_obj.developer }}</span>
|
|
{% endif %}
|
|
</div>
|
|
{% if game.metadata_obj and game.metadata_obj.description %}
|
|
<p class="text-sm text-gray-400 mt-2 line-clamp-2">
|
|
{{ game.metadata_obj.description[:120] }}{% if game.metadata_obj.description|length > 120 %}...{% endif %}
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
{% if not is_demo %}
|
|
<button onclick="event.stopPropagation(); toggleFavorite({{ game.id }})"
|
|
class="w-8 h-8 flex items-center justify-center {% if game.id in user_favorites %}text-red-600{% else %}text-red-400{% endif %} hover:text-red-300 text-xl z-20 relative"
|
|
id="favorite-{{ game.id }}">
|
|
{% if game.id in user_favorites %}♥{% else %}♡{% endif %}
|
|
</button>
|
|
<button onclick="event.stopPropagation(); downloadGame({{ game.id }})"
|
|
class="px-3 py-2 bg-green-600 hover:bg-green-700 rounded text-sm z-20 relative">
|
|
Download
|
|
</button>
|
|
{% else %}
|
|
<span class="px-3 py-2 bg-gray-600 rounded text-sm cursor-not-allowed">
|
|
Login to Download
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Pagination -->
|
|
{% if total_pages > 1 %}
|
|
<div class="mt-8 flex flex-col sm:flex-row justify-between items-center gap-4">
|
|
<div class="text-gray-400 text-sm">
|
|
Page {{ current_page }} of {{ total_pages }}
|
|
</div>
|
|
<nav class="flex items-center space-x-1">
|
|
{% if current_page > 1 %}
|
|
<a href="javascript:void(0)" onclick="goToPage({{ current_page - 1 }})"
|
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
|
Previous
|
|
</a>
|
|
{% endif %}
|
|
|
|
<!-- First page -->
|
|
{% if current_page > 6 %}
|
|
<a href="javascript:void(0)" onclick="goToPage(1)"
|
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
|
1
|
|
</a>
|
|
{% if current_page > 7 %}
|
|
<span class="px-3 py-2 text-gray-400">...</span>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- Page numbers around current page -->
|
|
{% for page_num in range(max(1, current_page - 4), min(total_pages + 1, current_page + 5)) %}
|
|
{% if page_num == current_page %}
|
|
<span class="px-3 py-2 bg-blue-600 rounded text-sm">{{ page_num }}</span>
|
|
{% else %}
|
|
<a href="javascript:void(0)" onclick="goToPage({{ page_num }})"
|
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
|
{{ page_num }}
|
|
</a>
|
|
{% endif %}
|
|
{% endfor %}
|
|
|
|
<!-- Last page -->
|
|
{% if current_page < total_pages - 5 %}
|
|
{% if current_page < total_pages - 6 %}
|
|
<span class="px-3 py-2 text-gray-400">...</span>
|
|
{% endif %}
|
|
<a href="javascript:void(0)" onclick="goToPage({{ total_pages }})"
|
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
|
{{ total_pages }}
|
|
</a>
|
|
{% endif %}
|
|
|
|
{% if current_page < total_pages %}
|
|
<a href="javascript:void(0)" onclick="goToPage({{ current_page + 1 }})"
|
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
|
Next
|
|
</a>
|
|
{% endif %}
|
|
</nav>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<script>
|
|
function buildUrl(params) {
|
|
const url = new URL(window.location);
|
|
Object.keys(params).forEach(key => {
|
|
if (params[key] !== null && params[key] !== '') {
|
|
url.searchParams.set(key, params[key]);
|
|
} else {
|
|
url.searchParams.delete(key);
|
|
}
|
|
});
|
|
return url.toString();
|
|
}
|
|
|
|
function changeView(newView) {
|
|
window.location.href = buildUrl({ view: newView, page: 1 });
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
window.location.href = buildUrl({ per_page: newPerPage, page: 1 });
|
|
}
|
|
|
|
function goToPage(page) {
|
|
window.location.href = buildUrl({ page: page });
|
|
}
|
|
|
|
async function toggleFavorite(gameId) {
|
|
const token = localStorage.getItem('authToken');
|
|
if (!token) {
|
|
showLogin();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/games/${gameId}/favorite`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const favoriteBtn = document.getElementById(`favorite-${gameId}`);
|
|
const isFavorited = favoriteBtn.textContent.trim() === '♥';
|
|
|
|
if (isFavorited) {
|
|
// Remove from favorites
|
|
favoriteBtn.textContent = '♡';
|
|
favoriteBtn.classList.remove('text-red-600');
|
|
favoriteBtn.classList.add('text-red-400');
|
|
} else {
|
|
// Add to favorites
|
|
favoriteBtn.textContent = '♥';
|
|
favoriteBtn.classList.remove('text-red-400');
|
|
favoriteBtn.classList.add('text-red-600');
|
|
}
|
|
} else if (response.status === 401) {
|
|
localStorage.removeItem('authToken');
|
|
showLogin();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling favorite:', error);
|
|
}
|
|
}
|
|
|
|
async function downloadGame(gameId) {
|
|
const token = localStorage.getItem('authToken');
|
|
if (!token) {
|
|
showLogin();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/download/${gameId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
a.download = response.headers.get('Content-Disposition')?.split('filename=')[1] || 'game.zip';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
} else if (response.status === 401) {
|
|
localStorage.removeItem('authToken');
|
|
showLogin();
|
|
} else {
|
|
alert('Download failed. Please try again.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Download error:', error);
|
|
alert('Download failed. Please try again.');
|
|
}
|
|
}
|
|
|
|
// Track which covers are being loaded to prevent duplicates
|
|
const loadingCovers = new Set();
|
|
|
|
// Lazy load cover images that failed to load initially
|
|
async function loadCoverImage(gameId, imgElement) {
|
|
const loadKey = `cover-${gameId}`;
|
|
|
|
// Prevent multiple concurrent requests for the same cover
|
|
if (loadingCovers.has(loadKey)) {
|
|
return;
|
|
}
|
|
|
|
loadingCovers.add(loadKey);
|
|
|
|
try {
|
|
const response = await fetch(`/api/cover/${gameId}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.cover_url) {
|
|
imgElement.src = data.cover_url;
|
|
imgElement.style.display = 'block';
|
|
const placeholder = imgElement.nextElementSibling;
|
|
if (placeholder) {
|
|
placeholder.style.display = 'none';
|
|
}
|
|
} else {
|
|
// No cover found, show "No Cover" message
|
|
const placeholder = imgElement.nextElementSibling;
|
|
if (placeholder) {
|
|
const textElement = placeholder.querySelector('p');
|
|
if (textElement) {
|
|
textElement.textContent = 'No Cover';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading cover image:', error);
|
|
// Show error state
|
|
const placeholder = imgElement.nextElementSibling;
|
|
if (placeholder) {
|
|
const textElement = placeholder.querySelector('p');
|
|
if (textElement) {
|
|
textElement.textContent = 'No Cover';
|
|
}
|
|
}
|
|
} finally {
|
|
loadingCovers.delete(loadKey);
|
|
}
|
|
}
|
|
|
|
// Lazy loading disabled - you can investigate the CORS issue
|
|
// document.addEventListener('DOMContentLoaded', function() {
|
|
// // Lazy loading code commented out
|
|
// });
|
|
</script>
|
|
{% endblock %} |