Updated theming

This commit is contained in:
2025-09-06 18:51:10 -04:00
parent dc7b538be6
commit dae849bb90
11 changed files with 704 additions and 111 deletions

View File

@@ -9,7 +9,7 @@ from typing import Optional, List
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from libs.config import Config from libs.config import Config
from libs.database import (Base, ingest_roms, get_existing_rom_paths) from libs.database import (ingest_roms, get_existing_rom_paths)
from libs.objects import Metadata, Game, Roms from libs.objects import Metadata, Game, Roms
from libs.functions import extract_year_from_title, clean_title, download_image, get_image_filename from libs.functions import extract_year_from_title, clean_title, download_image, get_image_filename
from libs.apis import Credentials, IGDB from libs.apis import Credentials, IGDB
@@ -84,7 +84,7 @@ async def make_romlist(dir: Optional[Path] = None, roms: Optional[Roms] = None)
for pointer in rompath.rglob("*"): for pointer in rompath.rglob("*"):
if pointer.is_file(): if pointer.is_file():
title = pointer.stem title = pointer.stem.strip('\'"') # Remove quotes from filename
romList.list.append(Game(title=title, path=pointer, metadata=Metadata())) romList.list.append(Game(title=title, path=pointer, metadata=Metadata()))
return romList return romList
@@ -102,12 +102,12 @@ async def inject_metadata(roms: Roms) -> Roms:
except ValueError: except ValueError:
scrape_errors.append(game.title) scrape_errors.append(game.title)
md = Metadata(title=game.title, year=extract_year_from_title(game.title)) md = Metadata(title=game.title, year=extract_year_from_title(game.title))
# print each item as its done to the top of the screen # log each item as its done
results[i] = md results[i] = md
print("\033[F\033[K", end='') logging.info(f"Scraped: {game.title} # {i+1}/{len(roms.list)}")
# Log recent errors
for err in scrape_errors[-5:]: for err in scrape_errors[-5:]:
print(f"Error: {err}") logging.warning(f"Scraping error: {err}")
print(f"Scraped: {game.title} # {i+1}/{len(roms.list)}")
tasks = [asyncio.create_task(_job(i, game)) for i, game in enumerate(roms.list)] tasks = [asyncio.create_task(_job(i, game)) for i, game in enumerate(roms.list)]
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
@@ -125,9 +125,9 @@ async def filter_new_roms(romlist: Roms, session: Session) -> Roms:
if game.path.resolve() not in existing_paths: if game.path.resolve() not in existing_paths:
new_roms.list.append(game) new_roms.list.append(game)
print(f"Found {len(romlist.list)} total ROMs") logging.info(f"Found {len(romlist.list)} total ROMs")
print(f"Found {len(existing_paths)} existing ROMs in database") logging.info(f"Found {len(existing_paths)} existing ROMs in database")
print(f"Will scrape {len(new_roms.list)} new ROMs") logging.info(f"Will scrape {len(new_roms.list)} new ROMs")
return new_roms return new_roms
@@ -145,11 +145,15 @@ async def main():
new_romlist = await inject_metadata(new_romlist) new_romlist = await inject_metadata(new_romlist)
ingest_roms(new_romlist, s) ingest_roms(new_romlist, s)
else: else:
print("No new ROMs to scrape!") logging.info("No new ROMs to scrape!")
print("Done\nError list:") logging.info("ROM scanning completed")
for err in scrape_errors: if scrape_errors:
print(f" - {err}") logging.warning(f"Total scraping errors: {len(scrape_errors)}")
for err in scrape_errors:
logging.warning(f"Failed to scrape: {err}")
else:
logging.info("ROM scanning completed with no errors")
if __name__ == "__main__": if __name__ == "__main__":
# Initialize logging # Initialize logging

View File

@@ -217,7 +217,7 @@ class ImageBackfillManager:
# Commit batch # Commit batch
db_session.commit() db_session.commit()
print(f" Batch committed to database") print(" Batch committed to database")
async def run(self, limit: Optional[int] = None, dry_run: bool = False): async def run(self, limit: Optional[int] = None, dry_run: bool = False):
"""Run the image backfill process.""" """Run the image backfill process."""
@@ -226,7 +226,7 @@ class ImageBackfillManager:
# Show current statistics # Show current statistics
stats = self.get_stats() stats = self.get_stats()
print(f"Database Statistics:") print("Database Statistics:")
print(f" Total games: {stats['total_games']}") print(f" Total games: {stats['total_games']}")
print(f" Games with cover URLs: {stats['games_with_cover_urls']}") print(f" Games with cover URLs: {stats['games_with_cover_urls']}")
print(f" Games with local covers: {stats['games_with_local_covers']}") print(f" Games with local covers: {stats['games_with_local_covers']}")
@@ -287,12 +287,12 @@ class ImageBackfillManager:
await self.process_batch(games) await self.process_batch(games)
# Show final results # Show final results
print(f"\n✅ Backfill Complete!") print("\n✅ Backfill Complete!")
print(f" Successfully downloaded: {self.successful_downloads} images") print(f" Successfully downloaded: {self.successful_downloads} images")
print(f" Failed downloads: {len(self.failed_downloads)}") print(f" Failed downloads: {len(self.failed_downloads)}")
if self.failed_downloads: if self.failed_downloads:
print(f"\nFailed Downloads:") print("\nFailed Downloads:")
for failure in self.failed_downloads[:10]: # Show first 10 for failure in self.failed_downloads[:10]: # Show first 10
print(f" - {failure}") print(f" - {failure}")
if len(self.failed_downloads) > 10: if len(self.failed_downloads) > 10:

View File

@@ -2,7 +2,7 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from libs.config import Config from libs.config import Config
from libs.database import Base, User_table, UserRole from libs.database import User_table, UserRole
from libs.auth import AuthManager from libs.auth import AuthManager
import sys import sys

View File

@@ -1,7 +1,7 @@
from typing import Optional from typing import Optional
import re import re
import asyncio
import aiohttp import aiohttp
import logging
from pathlib import Path from pathlib import Path
import hashlib import hashlib
@@ -42,10 +42,10 @@ async def download_image(url: str, save_path: Path, session: aiohttp.ClientSessi
f.write(content) f.write(content)
return True return True
else: else:
print(f"Failed to download {url}: HTTP {response.status}") logging.warning(f"Failed to download {url}: HTTP {response.status}")
return False return False
except Exception as e: except Exception as e:
print(f"Error downloading {url}: {e}") logging.error(f"Error downloading {url}: {e}")
return False return False
def get_image_filename(url: str, game_title: str, image_type: str) -> str: def get_image_filename(url: str, game_title: str, image_type: str) -> str:

View File

@@ -7,8 +7,6 @@ import argparse
from pathlib import Path from pathlib import Path
from alembic.config import Config from alembic.config import Config
from alembic import command from alembic import command
from alembic.script import ScriptDirectory
from alembic.runtime.environment import EnvironmentContext
from sqlalchemy import create_engine, inspect from sqlalchemy import create_engine, inspect
# Add current directory to path for imports # Add current directory to path for imports

View File

@@ -9,7 +9,7 @@ import asyncio
import aiohttp import aiohttp
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from sqlalchemy import create_engine, select, func from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
try: try:
@@ -191,7 +191,7 @@ class CoverRefreshManager:
# Commit batch # Commit batch
db_session.commit() db_session.commit()
print(f" Batch committed to database") print(" Batch committed to database")
async def run(self, limit: Optional[int] = None, dry_run: bool = False): async def run(self, limit: Optional[int] = None, dry_run: bool = False):
"""Run the cover refresh process.""" """Run the cover refresh process."""
@@ -246,13 +246,13 @@ class CoverRefreshManager:
await self.process_batch(games) await self.process_batch(games)
# Show final results # Show final results
print(f"\n✅ Refresh Complete!") print("\n✅ Refresh Complete!")
print(f" Games with updated metadata: {self.refreshed_count}") print(f" Games with updated metadata: {self.refreshed_count}")
print(f" Images successfully downloaded: {self.download_success_count}") print(f" Images successfully downloaded: {self.download_success_count}")
print(f" Failed refreshes: {len(self.failed_refreshes)}") print(f" Failed refreshes: {len(self.failed_refreshes)}")
if self.failed_refreshes: if self.failed_refreshes:
print(f"\nFailed Refreshes (first 10):") print("\nFailed Refreshes (first 10):")
for failure in self.failed_refreshes[:10]: for failure in self.failed_refreshes[:10]:
print(f" - {failure}") print(f" - {failure}")
if len(self.failed_refreshes) > 10: if len(self.failed_refreshes) > 10:

View File

@@ -5,7 +5,6 @@ from typing import Optional, Annotated
from datetime import timedelta, datetime, timezone from datetime import timedelta, datetime, timezone
import re import re
import asyncio import asyncio
import subprocess
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Depends, HTTPException, status, Request, Form, Query, BackgroundTasks from fastapi import FastAPI, Depends, HTTPException, status, Request, Form, Query, BackgroundTasks
@@ -26,7 +25,7 @@ try:
except ImportError: except ImportError:
# Fall back to absolute imports (when run directly) # Fall back to absolute imports (when run directly)
from libs.config import Config from libs.config import Config
from libs.database import Base, Game_table, Metadata_table, User_table, UserRole, user_favorites, Tags_table, Genre_table from libs.database import Game_table, Metadata_table, User_table, UserRole, user_favorites, Tags_table, Genre_table
from libs.auth import AuthManager, ACCESS_TOKEN_EXPIRE_MINUTES from libs.auth import AuthManager, ACCESS_TOKEN_EXPIRE_MINUTES
from libs.logging import get_log_manager from libs.logging import get_log_manager
@@ -259,6 +258,53 @@ async def get_game(
}) })
@app.get("/api/games/{game_id}")
async def get_game_json(
game_id: int,
db: Session = Depends(get_db),
current_user: Optional[User_table] = Depends(get_current_user)
):
"""Get game details as JSON for the overlay"""
game = db.get(Game_table, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
is_favorite = False
if current_user and current_user.role != UserRole.DEMO.value:
is_favorite = game in current_user.favorites
# Build game data structure
game_data = {
"id": game.id,
"title": game.metadata_obj.title if game.metadata_obj and game.metadata_obj.title else game.title,
"filename": game.path.name,
"filepath": str(game.path),
"is_favorite": is_favorite,
"can_download": current_user and current_user.role != UserRole.DEMO.value,
"is_demo": current_user is None or current_user.role == UserRole.DEMO.value,
"is_super": current_user and current_user.role == UserRole.SUPER.value,
"metadata": {}
}
if game.metadata_obj:
metadata = game.metadata_obj
game_data["metadata"] = {
"description": metadata.description,
"year": metadata.year,
"developer": metadata.developer,
"publisher": metadata.publisher,
"players": metadata.players,
"cover_image": metadata.cover_image_path.name if metadata.cover_image_path else metadata.cover_image,
"screenshot": metadata.screenshot_path.name if metadata.screenshot_path else metadata.screenshot,
"cover_image_local": bool(metadata.cover_image_path),
"screenshot_local": bool(metadata.screenshot_path),
"genres": [{"name": genre.name} for genre in metadata.genre] if metadata.genre else [],
"tags": [{"name": tag.name} for tag in metadata.tags] if metadata.tags else []
}
return game_data
@app.post("/games/{game_id}/favorite") @app.post("/games/{game_id}/favorite")
async def toggle_favorite( async def toggle_favorite(
game_id: int, game_id: int,
@@ -301,6 +347,8 @@ async def download_game(
# Create a clean filename using the game title # Create a clean filename using the game title
game_title = game.metadata_obj.title if game.metadata_obj and game.metadata_obj.title else game.title game_title = game.metadata_obj.title if game.metadata_obj and game.metadata_obj.title else game.title
# Strip quotes from the title first
game_title = game_title.strip('\'"') # Remove leading/trailing quotes
# Clean the title for use as filename # Clean the title for use as filename
clean_title = re.sub(r'[^\w\s-]', '', game_title).strip() clean_title = re.sub(r'[^\w\s-]', '', game_title).strip()
clean_title = re.sub(r'[-\s]+', '-', clean_title) clean_title = re.sub(r'[-\s]+', '-', clean_title)
@@ -532,7 +580,7 @@ async def get_cover_url(game_id: int, db: Session = Depends(get_db)):
db.commit() db.commit()
return {"cover_url": cover_url} return {"cover_url": cover_url}
except Exception as e: except Exception as e:
print(f"Error converting image ID to URL: {e}") logging.error(f"Error converting image ID to URL: {e}")
# Try to fetch cover from IGDB # Try to fetch cover from IGDB
try: try:
@@ -555,7 +603,7 @@ async def get_cover_url(game_id: int, db: Session = Depends(get_db)):
return {"cover_url": cover_url} return {"cover_url": cover_url}
except Exception as e: except Exception as e:
print(f"Error fetching cover for game {game_id}: {e}") logging.error(f"Error fetching cover for game {game_id}: {e}")
return {"cover_url": None} return {"cover_url": None}
@@ -564,13 +612,13 @@ async def get_cover_url(game_id: int, db: Session = Depends(get_db)):
async def browse_by_tag( async def browse_by_tag(
tag_name: str, tag_name: str,
request: Request, request: Request,
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
view: str = Query("grid", pattern="^(grid|list)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User_table] = Depends(get_current_user) current_user: Optional[User_table] = Depends(get_current_user)
): ):
"""Browse games by tag""" """Browse games by tag"""
page = int(request.query_params.get("page", 1))
per_page = int(request.query_params.get("per_page", 20))
view = request.query_params.get("view", "grid")
per_page = max(10, min(per_page, 100)) per_page = max(10, min(per_page, 100))
offset = (page - 1) * per_page offset = (page - 1) * per_page
@@ -624,6 +672,7 @@ async def browse_by_genre(
request: Request, request: Request,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100), per_page: int = Query(20, ge=1, le=100),
view: str = Query("grid", pattern="^(grid|list)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User_table] = Depends(get_current_user) current_user: Optional[User_table] = Depends(get_current_user)
): ):
@@ -657,18 +706,15 @@ async def browse_by_genre(
"request": request, "request": request,
"current_user": current_user, "current_user": current_user,
"games": games, "games": games,
"page": page, "current_page": page,
"per_page": per_page, "per_page": per_page,
"total": total, "total_games": total,
"total_pages": total_pages, "total_pages": total_pages,
"search": f"genre:{genre_name}", "search": f"genre:{genre_name}",
"show_pagination": True, "view": view,
"current_url": f"/browse/genres/{genre_name}",
"browse_type": "genre", "browse_type": "genre",
"browse_value": genre_name, "browse_value": genre_name,
"is_demo": current_user is None or current_user.role == UserRole.DEMO.value, "is_demo": current_user is None or current_user.role == UserRole.DEMO.value,
"view": "grid", # Default to grid view for genre browsing
"current_page": page,
"user_favorites": user_favorites "user_favorites": user_favorites
}) })
@@ -807,7 +853,7 @@ async def admin_system_stats(
"recent_users": recent_users, "recent_users": recent_users,
"disk_usage": disk_usage, "disk_usage": disk_usage,
"running_tasks": { "running_tasks": {
task_name: not task.done() if task_name in running_tasks else False task_name: not running_tasks[task_name].done() if task_name in running_tasks else False
for task_name in ["rom_scan", "metadata_refresh", "image_sync"] for task_name in ["rom_scan", "metadata_refresh", "image_sync"]
} }
} }
@@ -815,43 +861,110 @@ async def admin_system_stats(
async def run_rom_scan(): async def run_rom_scan():
"""Run the ROM scanner subprocess""" """Run the ROM scanner subprocess"""
try: try:
logging.info("Starting ROM scan subprocess")
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
"python", "-m", "src", "python", "-m", "src",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE
) )
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
return {"success": process.returncode == 0, "output": stdout.decode(), "error": stderr.decode()}
# Log the output for visibility
if stdout:
for line in stdout.decode().strip().split('\n'):
if line.strip():
logging.info(f"ROM Scanner: {line.strip()}")
if stderr:
for line in stderr.decode().strip().split('\n'):
if line.strip():
logging.error(f"ROM Scanner Error: {line.strip()}")
success = process.returncode == 0
logging.info(f"ROM scan subprocess completed with exit code: {process.returncode}")
return {
"success": success,
"output": stdout.decode(),
"error": stderr.decode(),
"returncode": process.returncode
}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} error_msg = f"Failed to start ROM scan subprocess: {str(e)}"
logging.error(error_msg)
return {"success": False, "error": error_msg}
async def run_metadata_refresh(): async def run_metadata_refresh():
"""Refresh metadata for games without complete metadata""" """Refresh metadata for games without complete metadata"""
try: try:
# Run ROM scanner with metadata refresh flag logging.info("Starting metadata refresh subprocess")
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
"python", "-m", "src", "--refresh-metadata", "python", "-m", "src", "--refresh-metadata",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE
) )
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
return {"success": process.returncode == 0, "output": stdout.decode(), "error": stderr.decode()}
# Log the output for visibility
if stdout:
for line in stdout.decode().strip().split('\n'):
if line.strip():
logging.info(f"Metadata Refresh: {line.strip()}")
if stderr:
for line in stderr.decode().strip().split('\n'):
if line.strip():
logging.error(f"Metadata Refresh Error: {line.strip()}")
success = process.returncode == 0
logging.info(f"Metadata refresh subprocess completed with exit code: {process.returncode}")
return {
"success": success,
"output": stdout.decode(),
"error": stderr.decode(),
"returncode": process.returncode
}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} error_msg = f"Failed to start metadata refresh subprocess: {str(e)}"
logging.error(error_msg)
return {"success": False, "error": error_msg}
async def run_image_sync(): async def run_image_sync():
"""Download missing cover images and screenshots""" """Download missing cover images and screenshots"""
try: try:
# Run ROM scanner with image sync flag logging.info("Starting image sync subprocess")
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
"python", "-m", "src", "--sync-images", "python", "-m", "src", "--sync-images",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE
) )
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
return {"success": process.returncode == 0, "output": stdout.decode(), "error": stderr.decode()}
# Log the output for visibility
if stdout:
for line in stdout.decode().strip().split('\n'):
if line.strip():
logging.info(f"Image Sync: {line.strip()}")
if stderr:
for line in stderr.decode().strip().split('\n'):
if line.strip():
logging.error(f"Image Sync Error: {line.strip()}")
success = process.returncode == 0
logging.info(f"Image sync subprocess completed with exit code: {process.returncode}")
return {
"success": success,
"output": stdout.decode(),
"error": stderr.decode(),
"returncode": process.returncode
}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} error_msg = f"Failed to start image sync subprocess: {str(e)}"
logging.error(error_msg)
return {"success": False, "error": error_msg}
@app.get("/api/admin/system-logs") @app.get("/api/admin/system-logs")
async def admin_system_logs( async def admin_system_logs(

View File

@@ -4,47 +4,47 @@
{% block content %} {% block content %}
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Admin Dashboard</h1> <h1 class="text-3xl font-bold mb-2 text-primary">Admin Dashboard</h1>
<p class="text-gray-400">System overview and management</p> <p class="text-secondary">System overview and management</p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700"> <div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center"> <div class="flex items-center">
<div class="text-3xl text-blue-400 mr-4">🎮</div> <div class="text-3xl text-accent mr-4">🎮</div>
<div> <div>
<p class="text-2xl font-bold">{{ total_games }}</p> <p class="text-2xl font-bold text-primary">{{ total_games }}</p>
<p class="text-gray-400 text-sm">Total Games</p> <p class="text-secondary text-sm">Total Games</p>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700"> <div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center"> <div class="flex items-center">
<div class="text-3xl text-green-400 mr-4">👥</div> <div class="text-3xl text-accent mr-4">👥</div>
<div> <div>
<p class="text-2xl font-bold">{{ total_users }}</p> <p class="text-2xl font-bold text-primary">{{ total_users }}</p>
<p class="text-gray-400 text-sm">Total Users</p> <p class="text-secondary text-sm">Total Users</p>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700"> <div class="bg-secondary rounded-lg p-6 border border-theme">
<a href="/admin/users" class="flex items-center hover:bg-gray-700 rounded transition-colors"> <a href="/admin/users" class="flex items-center hover:bg-tertiary rounded transition-colors">
<div class="text-3xl text-yellow-400 mr-4">⚙️</div> <div class="text-3xl text-accent mr-4">⚙️</div>
<div> <div>
<p class="text-lg font-semibold">Manage Users</p> <p class="text-lg font-semibold text-primary">Manage Users</p>
<p class="text-gray-400 text-sm">Create & Edit</p> <p class="text-secondary text-sm">Create & Edit</p>
</div> </div>
</a> </a>
</div> </div>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700"> <div class="bg-secondary rounded-lg p-6 border border-theme">
<a href="/" class="flex items-center hover:bg-gray-700 rounded transition-colors"> <a href="/" class="flex items-center hover:bg-tertiary rounded transition-colors">
<div class="text-3xl text-purple-400 mr-4">📚</div> <div class="text-3xl text-accent mr-4">📚</div>
<div> <div>
<p class="text-lg font-semibold">Browse Games</p> <p class="text-lg font-semibold text-primary">Browse Games</p>
<p class="text-gray-400 text-sm">View Library</p> <p class="text-secondary text-sm">View Library</p>
</div> </div>
</a> </a>
</div> </div>
@@ -363,53 +363,53 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="bg-secondary rounded-lg p-6 border border-theme"> <div class="bg-secondary rounded-lg p-6 border border-theme">
<h2 class="text-xl font-bold mb-4">Recent Games</h2> <h2 class="text-xl font-bold mb-4 text-primary">Recent Games</h2>
{% if recent_games %} {% if recent_games %}
<div class="space-y-3"> <div class="space-y-3">
{% for game in recent_games %} {% for game in recent_games %}
<div class="flex justify-between items-center py-2 border-b border-gray-700 last:border-b-0"> <div class="flex justify-between items-center py-2 border-b border-theme last:border-b-0">
<div> <div>
<p class="font-medium">{{ game.metadata_obj.title or game.title }}</p> <p class="font-medium text-primary">{{ game.metadata_obj.title or game.title }}</p>
<p class="text-sm text-gray-400">{{ game.path.name }}</p> <p class="text-sm text-secondary">{{ game.path.name }}</p>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<a href="/games/{{ game.id }}" class="text-blue-400 hover:text-blue-300 text-sm">View</a> <button onclick="showGameDetail({{ game.id }})" class="text-accent hover:text-accent text-sm transition-colors">View</button>
<a href="/admin/games/{{ game.id }}/edit" class="text-yellow-400 hover:text-yellow-300 text-sm">Edit</a> <a href="/admin/games/{{ game.id }}/edit" class="text-warning-color hover:opacity-80 text-sm transition-colors">Edit</a>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="text-gray-400">No games found</p> <p class="text-secondary">No games found</p>
{% endif %} {% endif %}
</div> </div>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700"> <div class="bg-secondary rounded-lg p-6 border border-theme">
<h2 class="text-xl font-bold mb-4">Recent Users</h2> <h2 class="text-xl font-bold mb-4 text-primary">Recent Users</h2>
{% if recent_users %} {% if recent_users %}
<div class="space-y-3"> <div class="space-y-3">
{% for user in recent_users %} {% for user in recent_users %}
<div class="flex justify-between items-center py-2 border-b border-gray-700 last:border-b-0"> <div class="flex justify-between items-center py-2 border-b border-theme last:border-b-0">
<div> <div>
<p class="font-medium">{{ user.username }}</p> <p class="font-medium text-primary">{{ user.username }}</p>
<p class="text-sm text-gray-400">{{ user.email }}</p> <p class="text-sm text-secondary">{{ user.email }}</p>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="px-2 py-1 rounded text-xs <span class="px-2 py-1 rounded text-xs text-white
{% if user.role == 'super' %}bg-red-600 {% if user.role == 'super' %}bg-danger-color
{% elif user.role == 'normal' %}bg-blue-600 {% elif user.role == 'normal' %}bg-accent
{% else %}bg-yellow-600{% endif %}"> {% else %}bg-warning-color{% endif %}">
{{ user.role.upper() }} {{ user.role.upper() }}
</span> </span>
{% if not user.is_active %} {% if not user.is_active %}
<span class="px-2 py-1 rounded text-xs bg-gray-600">INACTIVE</span> <span class="px-2 py-1 rounded text-xs bg-tertiary text-secondary">INACTIVE</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="text-gray-400">No users found</p> <p class="text-secondary">No users found</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -628,41 +628,41 @@ async function showSystemStats() {
content.innerHTML = ` content.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6"> <div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div class="bg-tertiary p-4 rounded-lg"> <div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Games</h4> <h4 class="text-lg font-semibold text-accent">Games</h4>
<p class="text-2xl font-bold">${stats.games}</p> <p class="text-2xl font-bold text-primary">${stats.games}</p>
</div> </div>
<div class="bg-tertiary p-4 rounded-lg"> <div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Users</h4> <h4 class="text-lg font-semibold text-accent">Users</h4>
<p class="text-2xl font-bold">${stats.users}</p> <p class="text-2xl font-bold text-primary">${stats.users}</p>
</div> </div>
<div class="bg-tertiary p-4 rounded-lg"> <div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Metadata</h4> <h4 class="text-lg font-semibold text-accent">Metadata</h4>
<p class="text-2xl font-bold">${stats.metadata}</p> <p class="text-2xl font-bold text-primary">${stats.metadata}</p>
</div> </div>
<div class="bg-tertiary p-4 rounded-lg"> <div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Tags</h4> <h4 class="text-lg font-semibold text-accent">Tags</h4>
<p class="text-2xl font-bold">${stats.tags}</p> <p class="text-2xl font-bold text-primary">${stats.tags}</p>
</div> </div>
<div class="bg-tertiary p-4 rounded-lg"> <div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">Genres</h4> <h4 class="text-lg font-semibold text-accent">Genres</h4>
<p class="text-2xl font-bold">${stats.genres}</p> <p class="text-2xl font-bold text-primary">${stats.genres}</p>
</div> </div>
<div class="bg-tertiary p-4 rounded-lg"> <div class="bg-tertiary p-4 rounded-lg border border-theme">
<h4 class="text-lg font-semibold text-accent">New Users (30d)</h4> <h4 class="text-lg font-semibold text-accent">New Users (30d)</h4>
<p class="text-2xl font-bold">${stats.recent_users}</p> <p class="text-2xl font-bold text-primary">${stats.recent_users}</p>
</div> </div>
</div> </div>
${stats.disk_usage.total ? ` ${stats.disk_usage.total ? `
<div class="mb-6"> <div class="mb-6">
<h4 class="text-lg font-semibold mb-3">Disk Usage</h4> <h4 class="text-lg font-semibold mb-3 text-primary">Disk Usage</h4>
<div class="bg-tertiary p-4 rounded-lg"> <div class="bg-tertiary p-4 rounded-lg border border-theme">
<div class="flex justify-between mb-2"> <div class="flex justify-between mb-2 text-secondary">
<span>Used: ${formatBytes(stats.disk_usage.used)}</span> <span>Used: ${formatBytes(stats.disk_usage.used)}</span>
<span>Free: ${formatBytes(stats.disk_usage.free)}</span> <span>Free: ${formatBytes(stats.disk_usage.free)}</span>
</div> </div>
<div class="w-full bg-gray-600 rounded-full h-3"> <div class="w-full bg-secondary rounded-full h-3">
<div class="bg-accent h-3 rounded-full" style="width: ${stats.disk_usage.percent_used.toFixed(1)}%"></div> <div class="bg-accent h-3 rounded-full" style="width: ${stats.disk_usage.percent_used.toFixed(1)}%"></div>
</div> </div>
<p class="text-sm text-secondary mt-1">${stats.disk_usage.percent_used.toFixed(1)}% of ${formatBytes(stats.disk_usage.total)} used</p> <p class="text-sm text-secondary mt-1">${stats.disk_usage.percent_used.toFixed(1)}% of ${formatBytes(stats.disk_usage.total)} used</p>
@@ -671,8 +671,8 @@ async function showSystemStats() {
` : ''} ` : ''}
<div> <div>
<h4 class="text-lg font-semibold mb-3">Running Tasks</h4> <h4 class="text-lg font-semibold mb-3 text-primary">Running Tasks</h4>
<div class="space-y-2"> <div class="space-y-2 text-secondary">
<div class="flex justify-between"> <div class="flex justify-between">
<span>ROM Scan:</span> <span>ROM Scan:</span>
<span class="${stats.running_tasks.rom_scan ? 'text-accent' : 'text-secondary'}">${stats.running_tasks.rom_scan ? 'Running' : 'Idle'}</span> <span class="${stats.running_tasks.rom_scan ? 'text-accent' : 'text-secondary'}">${stats.running_tasks.rom_scan ? 'Running' : 'Idle'}</span>

View File

@@ -115,6 +115,78 @@
--gradient-to: #451a03; --gradient-to: #451a03;
} }
/* Light Theme */
.theme-light {
--primary-bg: #ffffff;
--secondary-bg: #f8fafc;
--tertiary-bg: #e2e8f0;
--accent-bg: #3b82f6;
--accent-hover: #2563eb;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-accent: #3b82f6;
--border-color: #cbd5e1;
--success-color: #059669;
--warning-color: #d97706;
--danger-color: #dc2626;
--gradient-from: #f8fafc;
--gradient-to: #ffffff;
}
/* Light Blue Theme */
.theme-light-blue {
--primary-bg: #f0f9ff;
--secondary-bg: #e0f2fe;
--tertiary-bg: #bae6fd;
--accent-bg: #0ea5e9;
--accent-hover: #0284c7;
--text-primary: #0c4a6e;
--text-secondary: #075985;
--text-accent: #0ea5e9;
--border-color: #7dd3fc;
--success-color: #059669;
--warning-color: #d97706;
--danger-color: #dc2626;
--gradient-from: #e0f2fe;
--gradient-to: #f0f9ff;
}
/* Light Green Theme */
.theme-light-green {
--primary-bg: #f0fdf4;
--secondary-bg: #dcfce7;
--tertiary-bg: #bbf7d0;
--accent-bg: #22c55e;
--accent-hover: #16a34a;
--text-primary: #14532d;
--text-secondary: #166534;
--text-accent: #22c55e;
--border-color: #86efac;
--success-color: #059669;
--warning-color: #d97706;
--danger-color: #dc2626;
--gradient-from: #dcfce7;
--gradient-to: #f0fdf4;
}
/* Light Purple Theme */
.theme-light-purple {
--primary-bg: #faf5ff;
--secondary-bg: #f3e8ff;
--tertiary-bg: #e9d5ff;
--accent-bg: #a855f7;
--accent-hover: #9333ea;
--text-primary: #581c87;
--text-secondary: #7c3aed;
--text-accent: #a855f7;
--border-color: #c4b5fd;
--success-color: #059669;
--warning-color: #d97706;
--danger-color: #dc2626;
--gradient-from: #f3e8ff;
--gradient-to: #faf5ff;
}
/* Apply CSS custom properties */ /* Apply CSS custom properties */
body { body {
background-color: var(--primary-bg); background-color: var(--primary-bg);
@@ -130,6 +202,27 @@
.text-accent { color: var(--text-accent); } .text-accent { color: var(--text-accent); }
.border-theme { border-color: var(--border-color); } .border-theme { border-color: var(--border-color); }
.bg-gradient-theme { background: linear-gradient(to bottom right, var(--gradient-from), var(--gradient-to)); } .bg-gradient-theme { background: linear-gradient(to bottom right, var(--gradient-from), var(--gradient-to)); }
/* Fade-in animation for overlays */
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Smooth transitions for overlay elements */
.transition-all-smooth {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
</style> </style>
</head> </head>
<body class="bg-primary text-primary min-h-screen"> <body class="bg-primary text-primary min-h-screen">

View File

@@ -77,7 +77,7 @@
{% for game in games %} {% 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"> <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 --> <!-- 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 onclick="showGameDetail({{ game.id }})" class="absolute inset-0 z-10 cursor-pointer" aria-label="View {{ game.metadata_obj.title or game.title }} details"></div>
<!-- Cover Image --> <!-- Cover Image -->
<div class="aspect-[3/4] bg-gray-900 relative overflow-hidden"> <div class="aspect-[3/4] bg-gray-900 relative overflow-hidden">
@@ -221,7 +221,7 @@
{% for game in games %} {% 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"> <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 --> <!-- 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 onclick="showGameDetail({{ game.id }})" class="absolute inset-0 z-10 cursor-pointer" aria-label="View {{ game.metadata_obj.title or game.title }} details"></div>
<div class="p-4 flex items-center gap-4"> <div class="p-4 flex items-center gap-4">
<!-- Cover Image --> <!-- Cover Image -->
@@ -450,6 +450,120 @@
</div> </div>
{% endif %} {% endif %}
<!-- Game Detail Overlay -->
<div id="gameDetailOverlay" class="hidden fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4 overflow-y-auto backdrop-blur-sm">
<div class="bg-secondary rounded-lg max-w-6xl max-h-full w-full overflow-hidden relative shadow-2xl border border-theme animate-fade-in">
<!-- Close Button -->
<button onclick="closeGameDetail()" class="absolute top-4 right-4 text-primary hover:text-secondary text-2xl z-20 bg-primary bg-opacity-20 hover:bg-opacity-30 rounded-full w-10 h-10 flex items-center justify-center transition-all">
&times;
</button>
<!-- Loading State -->
<div id="gameDetailLoading" class="flex items-center justify-center h-96">
<div class="text-primary">Loading game details...</div>
</div>
<!-- Game Detail Content -->
<div id="gameDetailContent" class="hidden max-h-screen overflow-y-auto">
<!-- Header -->
<div class="border-b border-theme p-6">
<div class="flex justify-between items-start">
<div>
<h1 id="gameTitle" class="text-3xl font-bold mb-2 text-primary"></h1>
<p id="gameFilename" class="text-secondary"></p>
</div>
<div class="flex space-x-3">
<button id="gameFavoriteBtn" onclick="toggleFavoriteInOverlay()"
class="text-red-400 hover:text-red-300 text-2xl hidden">
</button>
<button id="gameEditBtn" onclick="editGame()"
class="bg-yellow-600 hover:bg-yellow-700 px-4 py-2 rounded text-sm hidden">
Edit Metadata
</button>
<button id="gameDownloadBtn" onclick="downloadGameFromOverlay()"
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded hidden">
Download ROM
</button>
<span id="gameDownloadDisabled" class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed hidden">
Demo Mode - No Downloads
</span>
</div>
</div>
</div>
<!-- Content -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 p-6">
<div class="lg:col-span-2">
<!-- Description -->
<div id="gameDescription" class="bg-tertiary rounded-lg p-6 border border-theme mb-6 hidden">
<h2 class="text-xl font-bold mb-3 text-primary">Description</h2>
<p id="gameDescriptionText" class="text-secondary leading-relaxed"></p>
</div>
<!-- Game Information -->
<div class="bg-tertiary rounded-lg p-6 border border-theme">
<h2 class="text-xl font-bold mb-4 text-primary">Game Information</h2>
<div id="gameInfoGrid" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Dynamic content will be inserted here -->
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Cover Art -->
<div id="gameCoverArt" class="bg-tertiary rounded-lg p-4 border border-theme hidden">
<h3 class="text-lg font-bold mb-3 text-primary">Cover Art</h3>
<img id="gameCoverImage" src="" alt="" class="w-full rounded">
</div>
<!-- Screenshot -->
<div id="gameScreenshot" class="bg-tertiary rounded-lg p-4 border border-theme hidden">
<h3 class="text-lg font-bold mb-3 text-primary">Screenshot</h3>
<div class="relative group cursor-pointer" onclick="openScreenshotModalInOverlay()">
<img id="gameScreenshotImage" src="" alt="" class="w-full rounded transition-transform duration-200 group-hover:scale-105">
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 rounded transition-all duration-200 flex items-center justify-center">
<svg class="w-12 h-12 text-primary opacity-0 group-hover:opacity-80 transition-opacity duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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 0zM10 7v3m0 0v3m0-3h3m-3 0H7"></path>
</svg>
</div>
</div>
<p class="text-xs text-secondary mt-2 text-center">Click to enlarge</p>
</div>
<!-- No Metadata Message -->
<div id="gameNoMetadata" class="bg-tertiary rounded-lg p-4 border border-theme text-center hidden">
<div class="text-4xl mb-2">📦</div>
<p class="text-secondary text-sm">No detailed metadata available for this game</p>
<button id="gameAddMetadataBtn" onclick="editGame()"
class="inline-block mt-2 text-accent hover:opacity-80 text-sm underline hidden transition-opacity">
Add Metadata
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Screenshot Modal -->
<div id="overlayScreenshotModal" class="hidden fixed inset-0 bg-black bg-opacity-90 z-60 flex items-center justify-center p-4">
<div class="relative max-w-4xl max-h-full">
<button onclick="closeOverlayScreenshotModal()" class="absolute top-4 right-4 text-primary hover:text-secondary text-3xl z-10 bg-primary bg-opacity-20 hover:bg-opacity-30 rounded-full w-12 h-12 flex items-center justify-center transition-all">
&times;
</button>
<img id="overlayScreenshotModalImage" src="" alt="" class="max-w-full max-h-full rounded-lg">
<div class="absolute bottom-4 left-4 right-4 text-center">
<p id="overlayScreenshotModalTitle" class="text-primary bg-secondary bg-opacity-90 px-4 py-2 rounded-lg"></p>
</div>
</div>
</div>
<script> <script>
function buildUrl(params) { function buildUrl(params) {
const url = new URL(window.location); const url = new URL(window.location);
@@ -606,5 +720,277 @@
// document.addEventListener('DOMContentLoaded', function() { // document.addEventListener('DOMContentLoaded', function() {
// // Lazy loading code commented out // // Lazy loading code commented out
// }); // });
// Game Detail Overlay Functions
let currentGameData = null;
async function showGameDetail(gameId) {
const overlay = document.getElementById('gameDetailOverlay');
const loading = document.getElementById('gameDetailLoading');
const content = document.getElementById('gameDetailContent');
// Show overlay and loading state
overlay.classList.remove('hidden');
loading.classList.remove('hidden');
content.classList.add('hidden');
document.body.style.overflow = 'hidden';
try {
const response = await fetch(`/api/games/${gameId}`);
if (!response.ok) {
throw new Error('Failed to fetch game details');
}
currentGameData = await response.json();
populateGameDetail(currentGameData);
// Hide loading, show content
loading.classList.add('hidden');
content.classList.remove('hidden');
} catch (error) {
console.error('Error loading game details:', error);
loading.innerHTML = '<div class="text-red-400">Failed to load game details</div>';
}
}
function populateGameDetail(game) {
// Basic info
document.getElementById('gameTitle').textContent = game.title;
document.getElementById('gameFilename').textContent = game.filename;
// Buttons
const favoriteBtn = document.getElementById('gameFavoriteBtn');
const editBtn = document.getElementById('gameEditBtn');
const downloadBtn = document.getElementById('gameDownloadBtn');
const downloadDisabled = document.getElementById('gameDownloadDisabled');
// Show/hide buttons based on user permissions
if (!game.is_demo) {
favoriteBtn.classList.remove('hidden');
favoriteBtn.textContent = game.is_favorite ? '♥' : '♡';
favoriteBtn.className = game.is_favorite ?
'text-red-600 hover:text-red-300 text-2xl' :
'text-red-400 hover:text-red-300 text-2xl';
} else {
favoriteBtn.classList.add('hidden');
}
if (game.is_super) {
editBtn.classList.remove('hidden');
editBtn.onclick = () => window.open(`/admin/games/${game.id}/edit`, '_blank');
} else {
editBtn.classList.add('hidden');
}
if (game.can_download) {
downloadBtn.classList.remove('hidden');
downloadDisabled.classList.add('hidden');
} else {
downloadBtn.classList.add('hidden');
downloadDisabled.classList.remove('hidden');
downloadDisabled.textContent = game.is_demo ? 'Demo Mode - No Downloads' : 'Login to Download';
}
// Description
const descriptionDiv = document.getElementById('gameDescription');
const descriptionText = document.getElementById('gameDescriptionText');
if (game.metadata.description) {
descriptionText.textContent = game.metadata.description;
descriptionDiv.classList.remove('hidden');
} else {
descriptionDiv.classList.add('hidden');
}
// Game info grid
const infoGrid = document.getElementById('gameInfoGrid');
infoGrid.innerHTML = '';
// Add metadata fields
const metadata = game.metadata;
if (metadata.year) {
addInfoField(infoGrid, 'Release Year', metadata.year);
}
if (metadata.developer) {
addInfoField(infoGrid, 'Developer', metadata.developer);
}
if (metadata.publisher) {
addInfoField(infoGrid, 'Publisher', metadata.publisher);
}
if (metadata.players) {
addInfoField(infoGrid, 'Players', metadata.players);
}
// Genres
if (metadata.genres && metadata.genres.length > 0) {
const genresDiv = document.createElement('div');
genresDiv.className = 'md:col-span-2';
genresDiv.innerHTML = `
<p class="text-gray-400 text-sm mb-2">Genres</p>
<div class="flex flex-wrap gap-2">
${metadata.genres.map(genre =>
`<a href="/browse/genres/${encodeURIComponent(genre.name)}" class="bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-sm transition-colors">${genre.name}</a>`
).join('')}
</div>
`;
infoGrid.appendChild(genresDiv);
}
// Tags
if (metadata.tags && metadata.tags.length > 0) {
const tagsDiv = document.createElement('div');
tagsDiv.className = 'md:col-span-2';
tagsDiv.innerHTML = `
<p class="text-gray-400 text-sm mb-2">Tags</p>
<div class="flex flex-wrap gap-2">
${metadata.tags.map(tag =>
`<span class="bg-gray-600 px-2 py-1 rounded text-sm">${tag.name}</span>`
).join('')}
</div>
`;
infoGrid.appendChild(tagsDiv);
}
// File path
addInfoField(infoGrid, 'File Path', game.filepath, 'md:col-span-2', 'font-mono text-sm bg-tertiary text-primary p-2 rounded');
// Cover art
const coverArt = document.getElementById('gameCoverArt');
const coverImage = document.getElementById('gameCoverImage');
if (metadata.cover_image) {
const imageSrc = metadata.cover_image_local ?
`/images/${metadata.cover_image}` :
metadata.cover_image;
coverImage.src = imageSrc;
coverImage.alt = `${game.title} cover`;
coverArt.classList.remove('hidden');
} else {
coverArt.classList.add('hidden');
}
// Screenshot
const screenshot = document.getElementById('gameScreenshot');
const screenshotImage = document.getElementById('gameScreenshotImage');
if (metadata.screenshot) {
const imageSrc = metadata.screenshot_local ?
`/images/${metadata.screenshot}` :
metadata.screenshot;
screenshotImage.src = imageSrc;
screenshotImage.alt = `${game.title} screenshot`;
screenshot.classList.remove('hidden');
} else {
screenshot.classList.add('hidden');
}
// No metadata message
const noMetadata = document.getElementById('gameNoMetadata');
const addMetadataBtn = document.getElementById('gameAddMetadataBtn');
if (!metadata.description && !metadata.year && !metadata.developer) {
noMetadata.classList.remove('hidden');
if (game.is_super) {
addMetadataBtn.classList.remove('hidden');
addMetadataBtn.onclick = () => window.open(`/admin/games/${game.id}/edit`, '_blank');
} else {
addMetadataBtn.classList.add('hidden');
}
} else {
noMetadata.classList.add('hidden');
}
}
function addInfoField(parent, label, value, colSpan = '', valueClass = 'font-medium text-primary') {
const div = document.createElement('div');
if (colSpan) div.className = colSpan;
div.innerHTML = `
<p class="text-secondary text-sm">${label}</p>
<p class="${valueClass}">${value}</p>
`;
parent.appendChild(div);
}
function closeGameDetail() {
const overlay = document.getElementById('gameDetailOverlay');
overlay.classList.add('hidden');
document.body.style.overflow = 'auto';
currentGameData = null;
}
async function toggleFavoriteInOverlay() {
if (!currentGameData) return;
try {
await toggleFavorite(currentGameData.id);
// Update the overlay button
const favoriteBtn = document.getElementById('gameFavoriteBtn');
const isFavorited = favoriteBtn.textContent.trim() === '♥';
if (isFavorited) {
favoriteBtn.textContent = '♡';
favoriteBtn.className = 'text-red-400 hover:text-red-300 text-2xl';
currentGameData.is_favorite = false;
} else {
favoriteBtn.textContent = '♥';
favoriteBtn.className = 'text-red-600 hover:text-red-300 text-2xl';
currentGameData.is_favorite = true;
}
} catch (error) {
console.error('Error toggling favorite in overlay:', error);
}
}
async function downloadGameFromOverlay() {
if (!currentGameData) return;
await downloadGame(currentGameData.id);
}
function openScreenshotModalInOverlay() {
if (!currentGameData || !currentGameData.metadata.screenshot) return;
const modal = document.getElementById('overlayScreenshotModal');
const image = document.getElementById('overlayScreenshotModalImage');
const title = document.getElementById('overlayScreenshotModalTitle');
const imageSrc = currentGameData.metadata.screenshot_local ?
`/images/${currentGameData.metadata.screenshot}` :
currentGameData.metadata.screenshot;
image.src = imageSrc;
image.alt = `${currentGameData.title} screenshot`;
title.textContent = `${currentGameData.title} - Screenshot`;
modal.classList.remove('hidden');
}
function closeOverlayScreenshotModal() {
const modal = document.getElementById('overlayScreenshotModal');
modal.classList.add('hidden');
}
// Close overlay when clicking outside
document.getElementById('gameDetailOverlay').addEventListener('click', function(e) {
if (e.target === this) {
closeGameDetail();
}
});
// Close screenshot modal when clicking outside
document.getElementById('overlayScreenshotModal').addEventListener('click', function(e) {
if (e.target === this) {
closeOverlayScreenshotModal();
}
});
// Close modals with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const gameOverlay = document.getElementById('gameDetailOverlay');
const screenshotModal = document.getElementById('overlayScreenshotModal');
if (!screenshotModal.classList.contains('hidden')) {
closeOverlayScreenshotModal();
} else if (!gameOverlay.classList.contains('hidden')) {
closeGameDetail();
}
}
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -4,7 +4,6 @@ Test script to download images for existing games that don't have local images y
""" """
import asyncio import asyncio
import aiohttp import aiohttp
from pathlib import Path
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -50,7 +49,7 @@ async def test_image_downloads():
# Update database with local path # Update database with local path
metadata.cover_image_path = cover_path metadata.cover_image_path = cover_path
else: else:
print(f" ✗ Failed to download cover") print(" ✗ Failed to download cover")
# Download screenshot # Download screenshot
if metadata.screenshot: if metadata.screenshot:
@@ -65,11 +64,11 @@ async def test_image_downloads():
# Update database with local path # Update database with local path
metadata.screenshot_path = screenshot_path metadata.screenshot_path = screenshot_path
else: else:
print(f" ✗ Failed to download screenshot") print(" ✗ Failed to download screenshot")
# Commit the updates # Commit the updates
session.commit() session.commit()
print(f"\n✓ Database updated with local image paths") print("\n✓ Database updated with local image paths")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(test_image_downloads()) asyncio.run(test_image_downloads())