Updated theming
This commit is contained in:
@@ -9,7 +9,7 @@ from typing import Optional, List
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
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.functions import extract_year_from_title, clean_title, download_image, get_image_filename
|
||||
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("*"):
|
||||
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()))
|
||||
return romList
|
||||
|
||||
@@ -102,12 +102,12 @@ async def inject_metadata(roms: Roms) -> Roms:
|
||||
except ValueError:
|
||||
scrape_errors.append(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
|
||||
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:]:
|
||||
print(f"Error: {err}")
|
||||
print(f"Scraped: {game.title} # {i+1}/{len(roms.list)}")
|
||||
logging.warning(f"Scraping error: {err}")
|
||||
|
||||
tasks = [asyncio.create_task(_job(i, game)) for i, game in enumerate(roms.list)]
|
||||
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:
|
||||
new_roms.list.append(game)
|
||||
|
||||
print(f"Found {len(romlist.list)} total ROMs")
|
||||
print(f"Found {len(existing_paths)} existing ROMs in database")
|
||||
print(f"Will scrape {len(new_roms.list)} new ROMs")
|
||||
logging.info(f"Found {len(romlist.list)} total ROMs")
|
||||
logging.info(f"Found {len(existing_paths)} existing ROMs in database")
|
||||
logging.info(f"Will scrape {len(new_roms.list)} new ROMs")
|
||||
|
||||
return new_roms
|
||||
|
||||
@@ -145,11 +145,15 @@ async def main():
|
||||
new_romlist = await inject_metadata(new_romlist)
|
||||
ingest_roms(new_romlist, s)
|
||||
else:
|
||||
print("No new ROMs to scrape!")
|
||||
logging.info("No new ROMs to scrape!")
|
||||
|
||||
print("Done\nError list:")
|
||||
for err in scrape_errors:
|
||||
print(f" - {err}")
|
||||
logging.info("ROM scanning completed")
|
||||
if scrape_errors:
|
||||
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__":
|
||||
# Initialize logging
|
||||
|
||||
@@ -217,7 +217,7 @@ class ImageBackfillManager:
|
||||
|
||||
# Commit batch
|
||||
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):
|
||||
"""Run the image backfill process."""
|
||||
@@ -226,7 +226,7 @@ class ImageBackfillManager:
|
||||
|
||||
# Show current statistics
|
||||
stats = self.get_stats()
|
||||
print(f"Database Statistics:")
|
||||
print("Database Statistics:")
|
||||
print(f" Total games: {stats['total_games']}")
|
||||
print(f" Games with cover URLs: {stats['games_with_cover_urls']}")
|
||||
print(f" Games with local covers: {stats['games_with_local_covers']}")
|
||||
@@ -287,12 +287,12 @@ class ImageBackfillManager:
|
||||
await self.process_batch(games)
|
||||
|
||||
# Show final results
|
||||
print(f"\n✅ Backfill Complete!")
|
||||
print("\n✅ Backfill Complete!")
|
||||
print(f" Successfully downloaded: {self.successful_downloads} images")
|
||||
print(f" Failed downloads: {len(self.failed_downloads)}")
|
||||
|
||||
if self.failed_downloads:
|
||||
print(f"\nFailed Downloads:")
|
||||
print("\nFailed Downloads:")
|
||||
for failure in self.failed_downloads[:10]: # Show first 10
|
||||
print(f" - {failure}")
|
||||
if len(self.failed_downloads) > 10:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
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
|
||||
import sys
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Optional
|
||||
import re
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
|
||||
@@ -42,10 +42,10 @@ async def download_image(url: str, save_path: Path, session: aiohttp.ClientSessi
|
||||
f.write(content)
|
||||
return True
|
||||
else:
|
||||
print(f"Failed to download {url}: HTTP {response.status}")
|
||||
logging.warning(f"Failed to download {url}: HTTP {response.status}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error downloading {url}: {e}")
|
||||
logging.error(f"Error downloading {url}: {e}")
|
||||
return False
|
||||
|
||||
def get_image_filename(url: str, game_title: str, image_type: str) -> str:
|
||||
|
||||
@@ -7,8 +7,6 @@ import argparse
|
||||
from pathlib import Path
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
from alembic.script import ScriptDirectory
|
||||
from alembic.runtime.environment import EnvironmentContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
# Add current directory to path for imports
|
||||
|
||||
@@ -9,7 +9,7 @@ import asyncio
|
||||
import aiohttp
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import create_engine, select, func
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
try:
|
||||
@@ -191,7 +191,7 @@ class CoverRefreshManager:
|
||||
|
||||
# Commit batch
|
||||
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):
|
||||
"""Run the cover refresh process."""
|
||||
@@ -246,13 +246,13 @@ class CoverRefreshManager:
|
||||
await self.process_batch(games)
|
||||
|
||||
# Show final results
|
||||
print(f"\n✅ Refresh Complete!")
|
||||
print("\n✅ Refresh Complete!")
|
||||
print(f" Games with updated metadata: {self.refreshed_count}")
|
||||
print(f" Images successfully downloaded: {self.download_success_count}")
|
||||
print(f" Failed refreshes: {len(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]:
|
||||
print(f" - {failure}")
|
||||
if len(self.failed_refreshes) > 10:
|
||||
|
||||
157
src/webapp.py
157
src/webapp.py
@@ -5,7 +5,6 @@ from typing import Optional, Annotated
|
||||
from datetime import timedelta, datetime, timezone
|
||||
import re
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, status, Request, Form, Query, BackgroundTasks
|
||||
@@ -26,7 +25,7 @@ try:
|
||||
except ImportError:
|
||||
# Fall back to absolute imports (when run directly)
|
||||
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.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")
|
||||
async def toggle_favorite(
|
||||
game_id: int,
|
||||
@@ -301,6 +347,8 @@ async def download_game(
|
||||
|
||||
# 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
|
||||
# Strip quotes from the title first
|
||||
game_title = game_title.strip('\'"') # Remove leading/trailing quotes
|
||||
# Clean the title for use as filename
|
||||
clean_title = re.sub(r'[^\w\s-]', '', game_title).strip()
|
||||
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()
|
||||
return {"cover_url": cover_url}
|
||||
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:
|
||||
@@ -555,7 +603,7 @@ async def get_cover_url(game_id: int, db: Session = Depends(get_db)):
|
||||
return {"cover_url": cover_url}
|
||||
|
||||
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}
|
||||
|
||||
@@ -564,13 +612,13 @@ async def get_cover_url(game_id: int, db: Session = Depends(get_db)):
|
||||
async def browse_by_tag(
|
||||
tag_name: str,
|
||||
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),
|
||||
current_user: Optional[User_table] = Depends(get_current_user)
|
||||
):
|
||||
"""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))
|
||||
offset = (page - 1) * per_page
|
||||
@@ -624,6 +672,7 @@ async def browse_by_genre(
|
||||
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),
|
||||
current_user: Optional[User_table] = Depends(get_current_user)
|
||||
):
|
||||
@@ -657,18 +706,15 @@ async def browse_by_genre(
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"games": games,
|
||||
"page": page,
|
||||
"current_page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"total_games": total,
|
||||
"total_pages": total_pages,
|
||||
"search": f"genre:{genre_name}",
|
||||
"show_pagination": True,
|
||||
"current_url": f"/browse/genres/{genre_name}",
|
||||
"view": view,
|
||||
"browse_type": "genre",
|
||||
"browse_value": genre_name,
|
||||
"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
|
||||
})
|
||||
|
||||
@@ -807,7 +853,7 @@ async def admin_system_stats(
|
||||
"recent_users": recent_users,
|
||||
"disk_usage": disk_usage,
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
@@ -815,43 +861,110 @@ async def admin_system_stats(
|
||||
async def run_rom_scan():
|
||||
"""Run the ROM scanner subprocess"""
|
||||
try:
|
||||
logging.info("Starting ROM scan subprocess")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"python", "-m", "src",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
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:
|
||||
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():
|
||||
"""Refresh metadata for games without complete metadata"""
|
||||
try:
|
||||
# Run ROM scanner with metadata refresh flag
|
||||
logging.info("Starting metadata refresh subprocess")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"python", "-m", "src", "--refresh-metadata",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
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:
|
||||
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():
|
||||
"""Download missing cover images and screenshots"""
|
||||
try:
|
||||
# Run ROM scanner with image sync flag
|
||||
logging.info("Starting image sync subprocess")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"python", "-m", "src", "--sync-images",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
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:
|
||||
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")
|
||||
async def admin_system_logs(
|
||||
|
||||
@@ -4,47 +4,47 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">Admin Dashboard</h1>
|
||||
<p class="text-gray-400">System overview and management</p>
|
||||
<h1 class="text-3xl font-bold mb-2 text-primary">Admin Dashboard</h1>
|
||||
<p class="text-secondary">System overview and management</p>
|
||||
</div>
|
||||
|
||||
<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="text-3xl text-blue-400 mr-4">🎮</div>
|
||||
<div class="text-3xl text-accent mr-4">🎮</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ total_games }}</p>
|
||||
<p class="text-gray-400 text-sm">Total Games</p>
|
||||
<p class="text-2xl font-bold text-primary">{{ total_games }}</p>
|
||||
<p class="text-secondary text-sm">Total Games</p>
|
||||
</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="text-3xl text-green-400 mr-4">👥</div>
|
||||
<div class="text-3xl text-accent mr-4">👥</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ total_users }}</p>
|
||||
<p class="text-gray-400 text-sm">Total Users</p>
|
||||
<p class="text-2xl font-bold text-primary">{{ total_users }}</p>
|
||||
<p class="text-secondary text-sm">Total Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<a href="/admin/users" class="flex items-center hover:bg-gray-700 rounded transition-colors">
|
||||
<div class="text-3xl text-yellow-400 mr-4">⚙️</div>
|
||||
<div class="bg-secondary rounded-lg p-6 border border-theme">
|
||||
<a href="/admin/users" class="flex items-center hover:bg-tertiary rounded transition-colors">
|
||||
<div class="text-3xl text-accent mr-4">⚙️</div>
|
||||
<div>
|
||||
<p class="text-lg font-semibold">Manage Users</p>
|
||||
<p class="text-gray-400 text-sm">Create & Edit</p>
|
||||
<p class="text-lg font-semibold text-primary">Manage Users</p>
|
||||
<p class="text-secondary text-sm">Create & Edit</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<a href="/" class="flex items-center hover:bg-gray-700 rounded transition-colors">
|
||||
<div class="text-3xl text-purple-400 mr-4">📚</div>
|
||||
<div class="bg-secondary rounded-lg p-6 border border-theme">
|
||||
<a href="/" class="flex items-center hover:bg-tertiary rounded transition-colors">
|
||||
<div class="text-3xl text-accent mr-4">📚</div>
|
||||
<div>
|
||||
<p class="text-lg font-semibold">Browse Games</p>
|
||||
<p class="text-gray-400 text-sm">View Library</p>
|
||||
<p class="text-lg font-semibold text-primary">Browse Games</p>
|
||||
<p class="text-secondary text-sm">View Library</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -363,53 +363,53 @@
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<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 %}
|
||||
<div class="space-y-3">
|
||||
{% 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>
|
||||
<p class="font-medium">{{ game.metadata_obj.title or game.title }}</p>
|
||||
<p class="text-sm text-gray-400">{{ game.path.name }}</p>
|
||||
<p class="font-medium text-primary">{{ game.metadata_obj.title or game.title }}</p>
|
||||
<p class="text-sm text-secondary">{{ game.path.name }}</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a href="/games/{{ game.id }}" class="text-blue-400 hover:text-blue-300 text-sm">View</a>
|
||||
<a href="/admin/games/{{ game.id }}/edit" class="text-yellow-400 hover:text-yellow-300 text-sm">Edit</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-warning-color hover:opacity-80 text-sm transition-colors">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-400">No games found</p>
|
||||
<p class="text-secondary">No games found</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h2 class="text-xl font-bold mb-4">Recent Users</h2>
|
||||
<div class="bg-secondary rounded-lg p-6 border border-theme">
|
||||
<h2 class="text-xl font-bold mb-4 text-primary">Recent Users</h2>
|
||||
{% if recent_users %}
|
||||
<div class="space-y-3">
|
||||
{% 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>
|
||||
<p class="font-medium">{{ user.username }}</p>
|
||||
<p class="text-sm text-gray-400">{{ user.email }}</p>
|
||||
<p class="font-medium text-primary">{{ user.username }}</p>
|
||||
<p class="text-sm text-secondary">{{ user.email }}</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="px-2 py-1 rounded text-xs
|
||||
{% if user.role == 'super' %}bg-red-600
|
||||
{% elif user.role == 'normal' %}bg-blue-600
|
||||
{% else %}bg-yellow-600{% endif %}">
|
||||
<span class="px-2 py-1 rounded text-xs text-white
|
||||
{% if user.role == 'super' %}bg-danger-color
|
||||
{% elif user.role == 'normal' %}bg-accent
|
||||
{% else %}bg-warning-color{% endif %}">
|
||||
{{ user.role.upper() }}
|
||||
</span>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-400">No users found</p>
|
||||
<p class="text-secondary">No users found</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -628,41 +628,41 @@ async function showSystemStats() {
|
||||
|
||||
content.innerHTML = `
|
||||
<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>
|
||||
<p class="text-2xl font-bold">${stats.games}</p>
|
||||
<p class="text-2xl font-bold text-primary">${stats.games}</p>
|
||||
</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>
|
||||
<p class="text-2xl font-bold">${stats.users}</p>
|
||||
<p class="text-2xl font-bold text-primary">${stats.users}</p>
|
||||
</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>
|
||||
<p class="text-2xl font-bold">${stats.metadata}</p>
|
||||
<p class="text-2xl font-bold text-primary">${stats.metadata}</p>
|
||||
</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>
|
||||
<p class="text-2xl font-bold">${stats.tags}</p>
|
||||
<p class="text-2xl font-bold text-primary">${stats.tags}</p>
|
||||
</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>
|
||||
<p class="text-2xl font-bold">${stats.genres}</p>
|
||||
<p class="text-2xl font-bold text-primary">${stats.genres}</p>
|
||||
</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>
|
||||
<p class="text-2xl font-bold">${stats.recent_users}</p>
|
||||
<p class="text-2xl font-bold text-primary">${stats.recent_users}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${stats.disk_usage.total ? `
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-semibold mb-3">Disk Usage</h4>
|
||||
<div class="bg-tertiary p-4 rounded-lg">
|
||||
<div class="flex justify-between mb-2">
|
||||
<h4 class="text-lg font-semibold mb-3 text-primary">Disk Usage</h4>
|
||||
<div class="bg-tertiary p-4 rounded-lg border border-theme">
|
||||
<div class="flex justify-between mb-2 text-secondary">
|
||||
<span>Used: ${formatBytes(stats.disk_usage.used)}</span>
|
||||
<span>Free: ${formatBytes(stats.disk_usage.free)}</span>
|
||||
</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>
|
||||
<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>
|
||||
<h4 class="text-lg font-semibold mb-3">Running Tasks</h4>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-lg font-semibold mb-3 text-primary">Running Tasks</h4>
|
||||
<div class="space-y-2 text-secondary">
|
||||
<div class="flex justify-between">
|
||||
<span>ROM Scan:</span>
|
||||
<span class="${stats.running_tasks.rom_scan ? 'text-accent' : 'text-secondary'}">${stats.running_tasks.rom_scan ? 'Running' : 'Idle'}</span>
|
||||
|
||||
@@ -115,6 +115,78 @@
|
||||
--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 */
|
||||
body {
|
||||
background-color: var(--primary-bg);
|
||||
@@ -130,6 +202,27 @@
|
||||
.text-accent { color: var(--text-accent); }
|
||||
.border-theme { border-color: var(--border-color); }
|
||||
.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>
|
||||
</head>
|
||||
<body class="bg-primary text-primary min-h-screen">
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
{% 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>
|
||||
<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 -->
|
||||
<div class="aspect-[3/4] bg-gray-900 relative overflow-hidden">
|
||||
@@ -221,7 +221,7 @@
|
||||
{% 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 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">
|
||||
<!-- Cover Image -->
|
||||
@@ -450,6 +450,120 @@
|
||||
</div>
|
||||
{% 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">
|
||||
×
|
||||
</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">
|
||||
×
|
||||
</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>
|
||||
function buildUrl(params) {
|
||||
const url = new URL(window.location);
|
||||
@@ -606,5 +720,277 @@
|
||||
// document.addEventListener('DOMContentLoaded', function() {
|
||||
// // 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>
|
||||
{% endblock %}
|
||||
@@ -4,7 +4,6 @@ Test script to download images for existing games that don't have local images y
|
||||
"""
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -50,7 +49,7 @@ async def test_image_downloads():
|
||||
# Update database with local path
|
||||
metadata.cover_image_path = cover_path
|
||||
else:
|
||||
print(f" ✗ Failed to download cover")
|
||||
print(" ✗ Failed to download cover")
|
||||
|
||||
# Download screenshot
|
||||
if metadata.screenshot:
|
||||
@@ -65,11 +64,11 @@ async def test_image_downloads():
|
||||
# Update database with local path
|
||||
metadata.screenshot_path = screenshot_path
|
||||
else:
|
||||
print(f" ✗ Failed to download screenshot")
|
||||
print(" ✗ Failed to download screenshot")
|
||||
|
||||
# Commit the updates
|
||||
session.commit()
|
||||
print(f"\n✓ Database updated with local image paths")
|
||||
print("\n✓ Database updated with local image paths")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_image_downloads())
|
||||
Reference in New Issue
Block a user