Iniital release of DosVault.
This commit is contained in:
1390
templates/admin.html
Normal file
1390
templates/admin.html
Normal file
File diff suppressed because it is too large
Load Diff
180
templates/admin_users.html
Normal file
180
templates/admin_users.html
Normal file
@@ -0,0 +1,180 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Users - DosVault{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">Manage Users</h1>
|
||||
<p class="text-gray-400">Create and manage user accounts</p>
|
||||
</div>
|
||||
<button onclick="showCreateUser()" class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
|
||||
Create New User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">User</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Role</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
{% for user in users %}
|
||||
<tr class="hover:bg-gray-700">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{ user.username }}</p>
|
||||
<p class="text-sm text-gray-400">{{ user.email }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<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 %}">
|
||||
{{ user.role.upper() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 rounded text-xs
|
||||
{% if user.is_active %}bg-green-600{% else %}bg-gray-600{% endif %}">
|
||||
{% if user.is_active %}ACTIVE{% else %}INACTIVE{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'N/A' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex space-x-2">
|
||||
{% if user.id != current_user.id %}
|
||||
<button onclick="toggleUserActive({{ user.id }})"
|
||||
class="{% if user.is_active %}text-red-400 hover:text-red-300{% else %}text-green-400 hover:text-green-300{% endif %}">
|
||||
{% if user.is_active %}Deactivate{% else %}Activate{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="mt-8 flex justify-center">
|
||||
<nav class="flex items-center space-x-2">
|
||||
{% if current_page > 1 %}
|
||||
<a href="?page={{ current_page - 1 }}"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in range(1, total_pages + 1) %}
|
||||
{% if page_num == current_page %}
|
||||
<span class="px-3 py-2 bg-blue-600 rounded text-sm">{{ page_num }}</span>
|
||||
{% elif page_num <= current_page + 2 and page_num >= current_page - 2 %}
|
||||
<a href="?page={{ page_num }}"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="?page={{ current_page + 1 }}"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
Next
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div id="createUserModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-gray-800 border-gray-700">
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium text-white">Create New User</h3>
|
||||
<button onclick="hideCreateUser()" class="text-gray-400 hover:text-white">×</button>
|
||||
</div>
|
||||
<form method="POST" class="space-y-4">
|
||||
<div>
|
||||
<input type="text" name="username" placeholder="Username" required
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<input type="email" name="email" placeholder="Email" required
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<input type="password" name="password" placeholder="Password" required
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<select name="role" required
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select Role</option>
|
||||
<option value="demo">Demo User</option>
|
||||
<option value="normal">Normal User</option>
|
||||
<option value="super">Super User</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<button type="button" onclick="hideCreateUser()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-md">
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showCreateUser() {
|
||||
document.getElementById('createUserModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideCreateUser() {
|
||||
document.getElementById('createUserModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function toggleUserActive(userId) {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/users/${userId}/toggle-active`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Failed to update user status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling user status:', error);
|
||||
alert('Failed to update user status');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
545
templates/base.html
Normal file
545
templates/base.html
Normal file
@@ -0,0 +1,545 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}DosVault{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
/* Default Dark Theme */
|
||||
--primary-bg: #111827;
|
||||
--secondary-bg: #1f2937;
|
||||
--tertiary-bg: #374151;
|
||||
--accent-bg: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-accent: #60a5fa;
|
||||
--border-color: #4b5563;
|
||||
--success-color: #059669;
|
||||
--warning-color: #d97706;
|
||||
--danger-color: #dc2626;
|
||||
--gradient-from: #1f2937;
|
||||
--gradient-to: #111827;
|
||||
}
|
||||
|
||||
/* Tokyo Night Theme */
|
||||
.theme-tokyo-night {
|
||||
--primary-bg: #1a1b26;
|
||||
--secondary-bg: #24283b;
|
||||
--tertiary-bg: #414868;
|
||||
--accent-bg: #7aa2f7;
|
||||
--accent-hover: #565f89;
|
||||
--text-primary: #c0caf5;
|
||||
--text-secondary: #9aa5ce;
|
||||
--text-accent: #7aa2f7;
|
||||
--border-color: #414868;
|
||||
--success-color: #9ece6a;
|
||||
--warning-color: #e0af68;
|
||||
--danger-color: #f7768e;
|
||||
--gradient-from: #24283b;
|
||||
--gradient-to: #1a1b26;
|
||||
}
|
||||
|
||||
/* Cyberpunk Theme */
|
||||
.theme-cyberpunk {
|
||||
--primary-bg: #0d0208;
|
||||
--secondary-bg: #1a0e1a;
|
||||
--tertiary-bg: #2d1b2e;
|
||||
--accent-bg: #ff006e;
|
||||
--accent-hover: #d90856;
|
||||
--text-primary: #00f5ff;
|
||||
--text-secondary: #c77dff;
|
||||
--text-accent: #ff006e;
|
||||
--border-color: #7209b7;
|
||||
--success-color: #39ff14;
|
||||
--warning-color: #ffbe0b;
|
||||
--danger-color: #ff1744;
|
||||
--gradient-from: #1a0e1a;
|
||||
--gradient-to: #0d0208;
|
||||
}
|
||||
|
||||
/* Ultra Retro Theme */
|
||||
.theme-ultra-retro {
|
||||
--primary-bg: #000000;
|
||||
--secondary-bg: #001100;
|
||||
--tertiary-bg: #003300;
|
||||
--accent-bg: #00ff00;
|
||||
--accent-hover: #00cc00;
|
||||
--text-primary: #00ff00;
|
||||
--text-secondary: #00cc00;
|
||||
--text-accent: #00ff00;
|
||||
--border-color: #005500;
|
||||
--success-color: #00ff00;
|
||||
--warning-color: #ffff00;
|
||||
--danger-color: #ff0000;
|
||||
--gradient-from: #001100;
|
||||
--gradient-to: #000000;
|
||||
}
|
||||
|
||||
/* Ocean Theme */
|
||||
.theme-ocean {
|
||||
--primary-bg: #0f172a;
|
||||
--secondary-bg: #1e293b;
|
||||
--tertiary-bg: #334155;
|
||||
--accent-bg: #0ea5e9;
|
||||
--accent-hover: #0284c7;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-accent: #38bdf8;
|
||||
--border-color: #475569;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--danger-color: #ef4444;
|
||||
--gradient-from: #1e293b;
|
||||
--gradient-to: #0f172a;
|
||||
}
|
||||
|
||||
/* Sunset Theme */
|
||||
.theme-sunset {
|
||||
--primary-bg: #451a03;
|
||||
--secondary-bg: #7c2d12;
|
||||
--tertiary-bg: #9a3412;
|
||||
--accent-bg: #ea580c;
|
||||
--accent-hover: #c2410c;
|
||||
--text-primary: #fed7aa;
|
||||
--text-secondary: #fdba74;
|
||||
--text-accent: #fb923c;
|
||||
--border-color: #9a3412;
|
||||
--success-color: #22c55e;
|
||||
--warning-color: #eab308;
|
||||
--danger-color: #ef4444;
|
||||
--gradient-from: #7c2d12;
|
||||
--gradient-to: #451a03;
|
||||
}
|
||||
|
||||
/* Apply CSS custom properties */
|
||||
body {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bg-primary { background-color: var(--primary-bg); }
|
||||
.bg-secondary { background-color: var(--secondary-bg); }
|
||||
.bg-tertiary { background-color: var(--tertiary-bg); }
|
||||
.bg-accent { background-color: var(--accent-bg); }
|
||||
.bg-accent:hover { background-color: var(--accent-hover); }
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.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)); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-primary text-primary min-h-screen">
|
||||
<nav class="bg-secondary border-b border-theme">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<div class="flex-shrink-0">
|
||||
<a href="/" class="flex items-center space-x-3 text-xl font-bold text-blue-400 hover:text-blue-300 transition-colors">
|
||||
<svg class="w-8 h-8" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Vault door -->
|
||||
<circle cx="16" cy="16" r="14" fill="currentColor" opacity="0.1"/>
|
||||
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.3"/>
|
||||
|
||||
<!-- DOS-style pixels -->
|
||||
<rect x="6" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="10" y="6" width="2" height="2" fill="currentColor" opacity="0.8"/>
|
||||
<rect x="20" y="6" width="2" height="2" fill="currentColor" opacity="0.8"/>
|
||||
<rect x="24" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="6" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="24" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
|
||||
<!-- Handle -->
|
||||
<rect x="20" y="14" width="6" height="4" rx="2" fill="currentColor" opacity="0.7"/>
|
||||
<circle cx="24" cy="16" r="1" fill="currentColor"/>
|
||||
</svg>
|
||||
<span>DosVault</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:block">
|
||||
<div class="ml-10 flex items-baseline space-x-4">
|
||||
<a href="/" class="hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||
Browse ROMs
|
||||
</a>
|
||||
{% if current_user and current_user.role != "demo" %}
|
||||
<a href="/favorites" class="hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||
My Favorites
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="toggleGenresSidebar()" class="hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||
Browse Genres
|
||||
</button>
|
||||
{% if current_user and current_user.role == "super" %}
|
||||
<a href="/admin" class="hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||
Admin
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop User Info -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<!-- Theme Picker -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleThemeMenu()" class="bg-gray-700 hover:bg-gray-600 p-2 rounded text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4h4a2 2 0 002-2V5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="theme-menu" class="hidden absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg z-50 border border-gray-700">
|
||||
<div class="py-1">
|
||||
<button onclick="setTheme('default')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||
<div class="w-4 h-4 rounded-full bg-gray-700 mr-3"></div>
|
||||
Default Dark
|
||||
</button>
|
||||
<button onclick="setTheme('tokyo-night')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||
<div class="w-4 h-4 rounded-full bg-purple-600 mr-3"></div>
|
||||
Tokyo Night
|
||||
</button>
|
||||
<button onclick="setTheme('cyberpunk')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||
<div class="w-4 h-4 rounded-full bg-pink-500 mr-3"></div>
|
||||
Cyberpunk
|
||||
</button>
|
||||
<button onclick="setTheme('ultra-retro')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||
<div class="w-4 h-4 rounded-full bg-green-500 mr-3"></div>
|
||||
Ultra Retro
|
||||
</button>
|
||||
<button onclick="setTheme('ocean')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||
<div class="w-4 h-4 rounded-full bg-blue-500 mr-3"></div>
|
||||
Ocean
|
||||
</button>
|
||||
<button onclick="setTheme('sunset')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||
<div class="w-4 h-4 rounded-full bg-orange-500 mr-3"></div>
|
||||
Sunset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user %}
|
||||
<span class="text-sm">
|
||||
Welcome, {{ current_user.username }}
|
||||
{% if current_user.role == "demo" %}
|
||||
<span class="bg-yellow-600 px-2 py-1 rounded text-xs">DEMO</span>
|
||||
{% elif current_user.role == "super" %}
|
||||
<span class="bg-red-600 px-2 py-1 rounded text-xs">ADMIN</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<button onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded text-sm">
|
||||
Logout
|
||||
</button>
|
||||
{% else %}
|
||||
<button onclick="showLogin()" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm">
|
||||
Login
|
||||
</button>
|
||||
<span class="bg-yellow-600 px-2 py-1 rounded text-xs">DEMO MODE</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="md:hidden">
|
||||
<button onclick="toggleMobileMenu()" class="bg-gray-700 inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white">
|
||||
<svg id="hamburger-icon" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg id="close-icon" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div id="mobile-menu" class="md:hidden hidden">
|
||||
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
||||
<a href="/" class="hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium">
|
||||
Browse ROMs
|
||||
</a>
|
||||
{% if current_user and current_user.role != "demo" %}
|
||||
<a href="/favorites" class="hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium">
|
||||
My Favorites
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="toggleGenresSidebar()" class="hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium w-full text-left">
|
||||
Browse Genres
|
||||
</button>
|
||||
{% if current_user and current_user.role == "super" %}
|
||||
<a href="/admin" class="hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium">
|
||||
Admin
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile User Info -->
|
||||
<div class="pt-4 pb-3 border-t border-gray-700">
|
||||
<div class="px-5">
|
||||
<!-- Mobile Theme Picker -->
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-gray-400 mb-2">Theme</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button onclick="setTheme('default')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||
<div class="w-4 h-4 rounded-full bg-gray-700 mb-1"></div>
|
||||
Default
|
||||
</button>
|
||||
<button onclick="setTheme('tokyo-night')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||
<div class="w-4 h-4 rounded-full bg-purple-600 mb-1"></div>
|
||||
Tokyo
|
||||
</button>
|
||||
<button onclick="setTheme('cyberpunk')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||
<div class="w-4 h-4 rounded-full bg-pink-500 mb-1"></div>
|
||||
Cyber
|
||||
</button>
|
||||
<button onclick="setTheme('ultra-retro')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||
<div class="w-4 h-4 rounded-full bg-green-500 mb-1"></div>
|
||||
Retro
|
||||
</button>
|
||||
<button onclick="setTheme('ocean')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||
<div class="w-4 h-4 rounded-full bg-blue-500 mb-1"></div>
|
||||
Ocean
|
||||
</button>
|
||||
<button onclick="setTheme('sunset')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||
<div class="w-4 h-4 rounded-full bg-orange-500 mb-1"></div>
|
||||
Sunset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user %}
|
||||
<div class="text-base font-medium text-white">{{ current_user.username }}</div>
|
||||
<div class="text-sm text-gray-400 mb-3">
|
||||
{% if current_user.role == "demo" %}
|
||||
<span class="bg-yellow-600 px-2 py-1 rounded text-xs">DEMO USER</span>
|
||||
{% elif current_user.role == "super" %}
|
||||
<span class="bg-red-600 px-2 py-1 rounded text-xs">ADMIN</span>
|
||||
{% else %}
|
||||
<span class="bg-green-600 px-2 py-1 rounded text-xs">USER</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded text-sm w-full">
|
||||
Logout
|
||||
</button>
|
||||
{% else %}
|
||||
<button onclick="showLogin()" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm w-full mb-2">
|
||||
Login
|
||||
</button>
|
||||
<span class="bg-yellow-600 px-2 py-1 rounded text-xs">DEMO MODE</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Genres Sidebar -->
|
||||
<div id="genresSidebar" class="fixed inset-y-0 left-0 w-80 bg-gray-800 border-r border-gray-700 transform -translate-x-full transition-transform duration-300 ease-in-out z-50">
|
||||
<div class="h-full flex flex-col p-4">
|
||||
<div class="flex justify-between items-center mb-4 flex-shrink-0">
|
||||
<h3 class="text-lg font-medium text-white">Browse by Genre</h3>
|
||||
<button onclick="toggleGenresSidebar()" class="text-gray-400 hover:text-white">×</button>
|
||||
</div>
|
||||
<div id="genresContainer" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<p class="text-gray-400 text-sm">Loading genres...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div id="genresOverlay" class="fixed inset-0 bg-black bg-opacity-50 z-40 hidden" onclick="toggleGenresSidebar()"></div>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Login Modal -->
|
||||
<div id="loginModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-gray-800 border-gray-700">
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium text-white">Login</h3>
|
||||
<button onclick="hideLogin()" class="text-gray-400 hover:text-white">×</button>
|
||||
</div>
|
||||
<form onsubmit="handleLogin(event)" class="space-y-4">
|
||||
<div>
|
||||
<input type="text" id="username" placeholder="Username" required
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<input type="password" id="password" placeholder="Password" required
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<button type="button" onclick="hideLogin()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-md">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let authToken = localStorage.getItem('authToken');
|
||||
|
||||
function toggleMobileMenu() {
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const hamburgerIcon = document.getElementById('hamburger-icon');
|
||||
const closeIcon = document.getElementById('close-icon');
|
||||
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
hamburgerIcon.classList.toggle('hidden');
|
||||
closeIcon.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function toggleThemeMenu() {
|
||||
const themeMenu = document.getElementById('theme-menu');
|
||||
themeMenu.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function setTheme(themeName) {
|
||||
// Remove all existing theme classes
|
||||
document.body.classList.remove('theme-tokyo-night', 'theme-cyberpunk', 'theme-ultra-retro', 'theme-ocean', 'theme-sunset');
|
||||
|
||||
// Add the selected theme class
|
||||
if (themeName !== 'default') {
|
||||
document.body.classList.add(`theme-${themeName}`);
|
||||
}
|
||||
|
||||
// Save theme preference
|
||||
localStorage.setItem('dosvault-theme', themeName);
|
||||
|
||||
// Hide theme menu
|
||||
document.getElementById('theme-menu').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Load saved theme on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedTheme = localStorage.getItem('dosvault-theme');
|
||||
if (savedTheme && savedTheme !== 'default') {
|
||||
document.body.classList.add(`theme-${savedTheme}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Close theme menu when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const themeMenu = document.getElementById('theme-menu');
|
||||
const themeButton = event.target.closest('[onclick="toggleThemeMenu()"]');
|
||||
|
||||
if (!themeButton && !themeMenu.contains(event.target)) {
|
||||
themeMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('loginModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideLogin() {
|
||||
document.getElementById('loginModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
try {
|
||||
const response = await fetch('/login', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('authToken', data.access_token);
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Login failed. Please check your credentials.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
alert('Login failed. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch('/logout', { method: 'POST' });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Set auth header if token exists
|
||||
if (authToken) {
|
||||
fetch.defaults = {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let genresLoaded = false;
|
||||
|
||||
function toggleGenresSidebar() {
|
||||
const sidebar = document.getElementById('genresSidebar');
|
||||
const overlay = document.getElementById('genresOverlay');
|
||||
const isOpen = !sidebar.classList.contains('-translate-x-full');
|
||||
|
||||
if (isOpen) {
|
||||
sidebar.classList.add('-translate-x-full');
|
||||
overlay.classList.add('hidden');
|
||||
} else {
|
||||
sidebar.classList.remove('-translate-x-full');
|
||||
overlay.classList.remove('hidden');
|
||||
if (!genresLoaded) {
|
||||
loadGenres();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGenres() {
|
||||
try {
|
||||
const response = await fetch('/api/genres');
|
||||
const genres = await response.json();
|
||||
const container = document.getElementById('genresContainer');
|
||||
|
||||
if (genres.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-400 text-sm">No genres found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = genres
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map(genre => `
|
||||
<a href="/browse/genres/${encodeURIComponent(genre.name)}"
|
||||
class="block p-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white">
|
||||
${genre.name} <span class="text-gray-400">(${genre.count})</span>
|
||||
</a>
|
||||
`).join('');
|
||||
|
||||
genresLoaded = true;
|
||||
} catch (error) {
|
||||
console.error('Error loading genres:', error);
|
||||
document.getElementById('genresContainer').innerHTML =
|
||||
'<p class="text-red-400 text-sm">Error loading genres</p>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
76
templates/edit_game.html
Normal file
76
templates/edit_game.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Game - DosVault{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold mb-2">Edit Game Metadata</h1>
|
||||
<p class="text-gray-400">Update the information for: {{ game.title }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<form method="POST" class="space-y-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-300 mb-2">Title</label>
|
||||
<input type="text" id="title" name="title"
|
||||
value="{{ game.metadata_obj.title or game.title }}"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-300 mb-2">Description</label>
|
||||
<textarea id="description" name="description" rows="4"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter game description...">{{ game.metadata_obj.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="year" class="block text-sm font-medium text-gray-300 mb-2">Year</label>
|
||||
<input type="number" id="year" name="year"
|
||||
value="{{ game.metadata_obj.year or '' }}"
|
||||
min="1970" max="2030"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="players" class="block text-sm font-medium text-gray-300 mb-2">Players</label>
|
||||
<input type="number" id="players" name="players"
|
||||
value="{{ game.metadata_obj.players or '' }}"
|
||||
min="1" max="16"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="developer" class="block text-sm font-medium text-gray-300 mb-2">Developer</label>
|
||||
<input type="text" id="developer" name="developer"
|
||||
value="{{ game.metadata_obj.developer or '' }}"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="publisher" class="block text-sm font-medium text-gray-300 mb-2">Publisher</label>
|
||||
<input type="text" id="publisher" name="publisher"
|
||||
value="{{ game.metadata_obj.publisher or '' }}"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-6">
|
||||
<a href="/games/{{ game.id }}"
|
||||
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 rounded-md text-white transition-colors">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-md text-white transition-colors">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
144
templates/favorites.html
Normal file
144
templates/favorites.html
Normal file
@@ -0,0 +1,144 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Favorites - DosVault{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">My Favorites</h1>
|
||||
<p class="text-gray-400">Your personally selected ROM collection</p>
|
||||
</div>
|
||||
|
||||
{% if games %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{% for game in games %}
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-gray-600 transition-colors">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<h3 class="text-lg font-semibold text-blue-400 truncate">{{ game.metadata_obj.title or game.title }}</h3>
|
||||
<button onclick="toggleFavorite({{ game.id }})"
|
||||
class="text-red-600 hover:text-red-500 text-xl"
|
||||
id="favorite-{{ game.id }}">
|
||||
♥
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if game.metadata_obj %}
|
||||
<div class="space-y-2 text-sm text-gray-300">
|
||||
{% if game.metadata_obj.year %}
|
||||
<p><span class="text-gray-400">Year:</span> {{ game.metadata_obj.year }}</p>
|
||||
{% endif %}
|
||||
{% if game.metadata_obj.developer %}
|
||||
<p><span class="text-gray-400">Developer:</span> {{ game.metadata_obj.developer }}</p>
|
||||
{% endif %}
|
||||
{% if game.metadata_obj.description %}
|
||||
<p class="text-xs text-gray-400 line-clamp-3">{{ game.metadata_obj.description[:100] }}{% if game.metadata_obj.description|length > 100 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<a href="/games/{{ game.id }}" class="text-blue-400 hover:text-blue-300 text-sm underline">
|
||||
View Details
|
||||
</a>
|
||||
<button onclick="downloadGame({{ game.id }})"
|
||||
class="bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="mt-12 flex justify-center">
|
||||
<nav class="flex items-center space-x-2">
|
||||
{% if current_page > 1 %}
|
||||
<a href="?page={{ current_page - 1 }}"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in range(1, total_pages + 1) %}
|
||||
{% if page_num == current_page %}
|
||||
<span class="px-3 py-2 bg-blue-600 rounded text-sm">{{ page_num }}</span>
|
||||
{% elif page_num <= current_page + 2 and page_num >= current_page - 2 %}
|
||||
<a href="?page={{ page_num }}"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="?page={{ current_page + 1 }}"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
Next
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">💔</div>
|
||||
<h2 class="text-2xl font-bold mb-2">No favorites yet</h2>
|
||||
<p class="text-gray-400 mb-6">Start browsing and add games to your favorites collection!</p>
|
||||
<a href="/" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg text-white">
|
||||
Browse ROMs
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
async function toggleFavorite(gameId) {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/games/${gameId}/favorite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Remove the game from favorites view
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadGame(gameId) {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/download/${gameId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = response.headers.get('Content-Disposition')?.split('filename=')[1] || 'game.zip';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
288
templates/game_detail.html
Normal file
288
templates/game_detail.html
Normal file
@@ -0,0 +1,288 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ game.metadata_obj.title or game.title }} - DosVault{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<nav class="text-sm text-gray-400 mb-4">
|
||||
<a href="/" class="hover:text-white">Browse ROMs</a>
|
||||
<span class="mx-2">/</span>
|
||||
<span class="text-white">{{ game.metadata_obj.title or game.title }}</span>
|
||||
</nav>
|
||||
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">{{ game.metadata_obj.title or game.title }}</h1>
|
||||
<p class="text-gray-400">{{ game.path.name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
{% if not is_demo %}
|
||||
<button onclick="toggleFavorite({{ game.id }})"
|
||||
class="text-red-400 hover:text-red-300 text-2xl"
|
||||
id="favorite-{{ game.id }}">
|
||||
{% if is_favorite %}♥{% else %}♡{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user and current_user.role == "super" %}
|
||||
<a href="/admin/games/{{ game.id }}/edit"
|
||||
class="bg-yellow-600 hover:bg-yellow-700 px-4 py-2 rounded text-sm">
|
||||
Edit Metadata
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_download %}
|
||||
<button onclick="downloadGame({{ game.id }})"
|
||||
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
|
||||
Download ROM
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed">
|
||||
{% if current_user %}Demo Mode - No Downloads{% else %}Login to Download{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div class="lg:col-span-2">
|
||||
{% if game.metadata_obj and game.metadata_obj.description %}
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 mb-6">
|
||||
<h2 class="text-xl font-bold mb-3">Description</h2>
|
||||
<p class="text-gray-300 leading-relaxed">{{ game.metadata_obj.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h2 class="text-xl font-bold mb-4">Game Information</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{% if game.metadata_obj %}
|
||||
{% if game.metadata_obj.year %}
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Release Year</p>
|
||||
<p class="font-medium">{{ game.metadata_obj.year }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if game.metadata_obj.developer %}
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Developer</p>
|
||||
<p class="font-medium">{{ game.metadata_obj.developer }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if game.metadata_obj.publisher %}
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Publisher</p>
|
||||
<p class="font-medium">{{ game.metadata_obj.publisher }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if game.metadata_obj.players %}
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Players</p>
|
||||
<p class="font-medium">{{ game.metadata_obj.players }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if game.metadata_obj.genre %}
|
||||
<div class="md:col-span-2">
|
||||
<p class="text-gray-400 text-sm mb-2">Genres</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for genre in game.metadata_obj.genre %}
|
||||
<a href="/browse/genres/{{ genre.name | urlencode }}" class="bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-sm transition-colors">{{ genre.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if game.metadata_obj.tags %}
|
||||
<div class="md:col-span-2">
|
||||
<p class="text-gray-400 text-sm mb-2">Tags</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for tag in game.metadata_obj.tags %}
|
||||
<span class="bg-gray-600 px-2 py-1 rounded text-sm">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<p class="text-gray-400 text-sm">File Path</p>
|
||||
<p class="font-mono text-sm bg-gray-700 p-2 rounded">{{ game.path }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
{% if game.metadata_obj and (game.metadata_obj.cover_image_path or game.metadata_obj.cover_image) %}
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<h3 class="text-lg font-bold mb-3">Cover Art</h3>
|
||||
<img {% if game.metadata_obj.cover_image_path %}src="/images/{{ game.metadata_obj.cover_image_path.name }}"{% else %}src="{{ game.metadata_obj.cover_image }}"{% endif %}
|
||||
alt="{{ game.metadata_obj.title or game.title }} cover"
|
||||
class="w-full rounded">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if game.metadata_obj and (game.metadata_obj.screenshot_path or game.metadata_obj.screenshot) %}
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<h3 class="text-lg font-bold mb-3">Screenshot</h3>
|
||||
<div class="relative group cursor-pointer" onclick="openScreenshotModal('{% if game.metadata_obj.screenshot_path %}/images/{{ game.metadata_obj.screenshot_path.name }}{% else %}{{ game.metadata_obj.screenshot }}{% endif %}', '{{ game.metadata_obj.title or game.title }}')">
|
||||
<img {% if game.metadata_obj.screenshot_path %}src="/images/{{ game.metadata_obj.screenshot_path.name }}"{% else %}src="{{ game.metadata_obj.screenshot }}"{% endif %}
|
||||
alt="{{ game.metadata_obj.title or game.title }} screenshot"
|
||||
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-white 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-gray-500 mt-2 text-center">Click to enlarge</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not game.metadata_obj or not game.metadata_obj.description %}
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 text-center">
|
||||
<div class="text-4xl mb-2">📦</div>
|
||||
<p class="text-gray-400 text-sm">No detailed metadata available for this game</p>
|
||||
{% if current_user and current_user.role == "super" %}
|
||||
<a href="/admin/games/{{ game.id }}/edit"
|
||||
class="inline-block mt-2 text-blue-400 hover:text-blue-300 text-sm underline">
|
||||
Add Metadata
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Modal -->
|
||||
<div id="screenshotModal" class="hidden fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4">
|
||||
<div class="relative max-w-4xl max-h-full">
|
||||
<button onclick="closeScreenshotModal()" class="absolute top-4 right-4 text-white hover:text-gray-300 text-3xl z-10 bg-black bg-opacity-50 rounded-full w-12 h-12 flex items-center justify-center">
|
||||
×
|
||||
</button>
|
||||
<img id="screenshotModalImage" src="" alt="" class="max-w-full max-h-full rounded-lg">
|
||||
<div class="absolute bottom-4 left-4 right-4 text-center">
|
||||
<p id="screenshotModalTitle" class="text-white bg-black bg-opacity-50 px-4 py-2 rounded-lg"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openScreenshotModal(imageSrc, title) {
|
||||
const modal = document.getElementById('screenshotModal');
|
||||
const image = document.getElementById('screenshotModalImage');
|
||||
const titleElement = document.getElementById('screenshotModalTitle');
|
||||
|
||||
image.src = imageSrc;
|
||||
image.alt = title + ' screenshot';
|
||||
titleElement.textContent = title + ' - Screenshot';
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden'; // Prevent background scrolling
|
||||
}
|
||||
|
||||
function closeScreenshotModal() {
|
||||
const modal = document.getElementById('screenshotModal');
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = 'auto'; // Restore scrolling
|
||||
}
|
||||
|
||||
// Close modal when clicking outside the image
|
||||
document.getElementById('screenshotModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeScreenshotModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal with Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeScreenshotModal();
|
||||
}
|
||||
});
|
||||
|
||||
async function toggleFavorite(gameId) {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/games/${gameId}/favorite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const favoriteBtn = document.getElementById(`favorite-${gameId}`);
|
||||
const isFavorited = favoriteBtn.textContent.trim() === '♥';
|
||||
|
||||
if (isFavorited) {
|
||||
// Remove from favorites
|
||||
favoriteBtn.textContent = '♡';
|
||||
favoriteBtn.classList.remove('text-red-600');
|
||||
favoriteBtn.classList.add('text-red-400');
|
||||
} else {
|
||||
// Add to favorites
|
||||
favoriteBtn.textContent = '♥';
|
||||
favoriteBtn.classList.remove('text-red-400');
|
||||
favoriteBtn.classList.add('text-red-600');
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
localStorage.removeItem('authToken');
|
||||
showLogin();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadGame(gameId) {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/download/${gameId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = response.headers.get('Content-Disposition')?.split('filename=')[1] || 'game.zip';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} else if (response.status === 401) {
|
||||
localStorage.removeItem('authToken');
|
||||
showLogin();
|
||||
} else {
|
||||
alert('Download failed. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert('Download failed. Please try again.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
610
templates/index.html
Normal file
610
templates/index.html
Normal file
@@ -0,0 +1,610 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}ROM Library - DOS Frontend{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||
<h1 class="text-3xl font-bold mb-2 sm:mb-0">ROM Library</h1>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<!-- Search Form -->
|
||||
<form method="GET" class="flex gap-2">
|
||||
<input type="hidden" name="page" value="1">
|
||||
<input type="hidden" name="per_page" value="{{ per_page }}">
|
||||
<input type="hidden" name="view" value="{{ view }}">
|
||||
<div class="relative">
|
||||
<input type="text" name="search" placeholder="Search games..."
|
||||
value="{% if search and not search.startswith('genre:') and not search.startswith('tag:') %}{{ search }}{% endif %}"
|
||||
class="w-full sm:w-64 px-4 py-2 pl-10 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 21-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Bar -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div class="text-gray-400">
|
||||
{% if is_demo %}
|
||||
<span class="text-yellow-400">Demo Mode:</span> You can browse ROMs but cannot download or favorite them.
|
||||
<button onclick="showLogin()" class="text-blue-400 hover:text-blue-300 underline">Login</button> for full access.
|
||||
{% else %}
|
||||
Showing {{ games|length }} of {{ total_games }} ROMs
|
||||
{% if search %} for "{{ search }}"{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4 items-center">
|
||||
<!-- Results per page -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-gray-400 text-sm">Show:</label>
|
||||
<select onchange="changePerPage(this.value)" class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white text-sm min-w-16">
|
||||
<option value="20" {% if per_page == 20 %}selected{% endif %}>20</option>
|
||||
<option value="50" {% if per_page == 50 %}selected{% endif %}>50</option>
|
||||
<option value="100" {% if per_page == 100 %}selected{% endif %}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="flex bg-gray-700 rounded-lg p-1">
|
||||
<button onclick="changeView('grid')"
|
||||
class="px-4 py-2 rounded text-sm touch-manipulation {{ 'bg-blue-600 text-white' if view == 'grid' else 'text-gray-400 hover:text-white active:bg-gray-600' }}">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
|
||||
</svg>
|
||||
<span class="ml-1 hidden sm:inline">Grid</span>
|
||||
</button>
|
||||
<button onclick="changeView('list')"
|
||||
class="px-4 py-2 rounded text-sm touch-manipulation {{ 'bg-blue-600 text-white' if view == 'list' else 'text-gray-400 hover:text-white active:bg-gray-600' }}">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="ml-1 hidden sm:inline">List</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Games Grid View -->
|
||||
{% if view == 'grid' %}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{% for game in games %}
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden hover:shadow-lg transform hover:-translate-y-1 transition-all duration-200 relative group">
|
||||
<!-- Clickable overlay for the card -->
|
||||
<a href="/games/{{ game.id }}" class="absolute inset-0 z-10 cursor-pointer" aria-label="View {{ game.metadata_obj.title or game.title }} details"></a>
|
||||
|
||||
<!-- Cover Image -->
|
||||
<div class="aspect-[3/4] bg-gray-900 relative overflow-hidden">
|
||||
{% if game.metadata_obj and (game.metadata_obj.cover_image_path or (game.metadata_obj.cover_image and game.metadata_obj.cover_image.startswith('http'))) %}
|
||||
<img data-game-id="{{ game.id }}"
|
||||
{% if game.metadata_obj.cover_image_path %}src="/images/{{ game.metadata_obj.cover_image_path.name }}"{% else %}src="{{ game.metadata_obj.cover_image }}"{% endif %}
|
||||
alt="{{ game.metadata_obj.title or game.title }}"
|
||||
class="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-800 to-gray-900 hidden items-center justify-center">
|
||||
<div class="text-gray-400 text-center p-4">
|
||||
<svg class="w-16 h-16 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2h2a2 2 0 002-2z"></path>
|
||||
</svg>
|
||||
<p class="text-sm font-medium">{{ (game.metadata_obj.title or game.title)[:20] }}{% if (game.metadata_obj.title or game.title)|length > 20 %}...{% endif %}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">DOS Game</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-full h-full bg-gradient-theme flex items-center justify-center relative overflow-hidden">
|
||||
<!-- Background Pattern -->
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="pixel-grid" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<rect width="2" height="2" fill="currentColor" opacity="0.3"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#pixel-grid)"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="text-center p-3 relative z-10">
|
||||
<!-- DosVault Logo -->
|
||||
<div class="mb-4">
|
||||
<svg class="w-20 h-20 mx-auto text-accent" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Vault door with enhanced styling -->
|
||||
<circle cx="16" cy="16" r="15" fill="currentColor" opacity="0.15" stroke="currentColor" stroke-width="0.5"/>
|
||||
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.8"/>
|
||||
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.6"/>
|
||||
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.4"/>
|
||||
|
||||
<!-- Enhanced DOS-style pixels -->
|
||||
<rect x="5" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||
<rect x="10" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="19" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="24" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||
<rect x="5" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||
<rect x="24" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||
<rect x="5" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="25" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
||||
|
||||
<!-- Enhanced Handle -->
|
||||
<rect x="19" y="13" width="8" height="6" rx="3" fill="currentColor" opacity="0.8"/>
|
||||
<circle cx="23" cy="16" r="1.5" fill="var(--primary-bg)"/>
|
||||
<circle cx="23" cy="16" r="0.8" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Game Title -->
|
||||
<div class="mb-2">
|
||||
<h3 class="text-accent font-bold text-sm leading-tight mb-1">
|
||||
{{ (game.metadata_obj.title or game.title)[:18] }}{% if (game.metadata_obj.title or game.title)|length > 18 %}...{% endif %}
|
||||
</h3>
|
||||
<div class="w-12 h-0.5 bg-accent mx-auto opacity-60"></div>
|
||||
</div>
|
||||
|
||||
<!-- Branding -->
|
||||
<div class="text-xs text-secondary opacity-75 font-medium">
|
||||
<div class="mb-1">CLASSIC DOS GAME</div>
|
||||
<div class="text-accent text-[10px] font-bold tracking-wider">DOSVAULT</div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative Corner Elements -->
|
||||
<div class="absolute top-1 left-1">
|
||||
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||
<polygon points="0,0 8,0 0,8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute top-1 right-1">
|
||||
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||
<polygon points="8,0 8,8 0,0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-1">
|
||||
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||
<polygon points="0,8 0,0 8,8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute bottom-1 right-1">
|
||||
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||
<polygon points="8,8 0,8 8,0"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Favorite Button -->
|
||||
{% if not is_demo %}
|
||||
<button onclick="event.stopPropagation(); toggleFavorite({{ game.id }})"
|
||||
class="absolute top-2 right-2 w-8 h-8 bg-black bg-opacity-50 rounded-full flex items-center justify-center {% if game.id in user_favorites %}text-red-600{% else %}text-red-400{% endif %} hover:text-red-300 hover:bg-opacity-75 transition-all z-20"
|
||||
id="favorite-{{ game.id }}">
|
||||
{% if game.id in user_favorites %}♥{% else %}♡{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Game Info -->
|
||||
<div class="p-3">
|
||||
<h3 class="font-semibold text-blue-400 truncate mb-1 text-sm">
|
||||
{{ game.metadata_obj.title or game.title }}
|
||||
</h3>
|
||||
{% if game.metadata_obj and game.metadata_obj.year %}
|
||||
<p class="text-xs text-gray-400 mb-2">{{ game.metadata_obj.year }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500">Click to view details</span>
|
||||
{% if not is_demo %}
|
||||
<button onclick="event.stopPropagation(); downloadGame({{ game.id }})"
|
||||
class="bg-green-600 hover:bg-green-700 px-2 py-1 rounded text-xs z-20 relative">
|
||||
Download
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="bg-gray-600 px-2 py-1 rounded text-xs cursor-not-allowed">
|
||||
Login
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Games List View -->
|
||||
{% if view == 'list' %}
|
||||
<div class="space-y-2">
|
||||
{% for game in games %}
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors hover:shadow-lg transform hover:-translate-y-1 transition-all duration-200 relative group">
|
||||
<!-- Clickable overlay for the card -->
|
||||
<a href="/games/{{ game.id }}" class="absolute inset-0 z-10 cursor-pointer" aria-label="View {{ game.metadata_obj.title or game.title }} details"></a>
|
||||
|
||||
<div class="p-4 flex items-center gap-4">
|
||||
<!-- Cover Image -->
|
||||
<div class="w-16 h-20 bg-gray-900 rounded overflow-hidden flex-shrink-0 relative">
|
||||
{% if game.metadata_obj and (game.metadata_obj.cover_image_path or (game.metadata_obj.cover_image and game.metadata_obj.cover_image.startswith('http'))) %}
|
||||
<img data-game-id="{{ game.id }}"
|
||||
{% if game.metadata_obj.cover_image_path %}src="/images/{{ game.metadata_obj.cover_image_path.name }}"{% else %}src="{{ game.metadata_obj.cover_image }}"{% endif %}
|
||||
alt="{{ game.metadata_obj.title or game.title }}"
|
||||
class="w-full h-full object-cover"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="w-full h-full bg-gradient-theme hidden items-center justify-center relative overflow-hidden">
|
||||
<!-- Background Pattern -->
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="pixel-grid-list-fallback" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<rect width="2" height="2" fill="currentColor" opacity="0.3"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#pixel-grid-list-fallback)"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="text-center p-3 relative z-10">
|
||||
<!-- DosVault Logo -->
|
||||
<div class="mb-4">
|
||||
<svg class="w-20 h-20 mx-auto text-accent" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Vault door with enhanced styling -->
|
||||
<circle cx="16" cy="16" r="15" fill="currentColor" opacity="0.15" stroke="currentColor" stroke-width="0.5"/>
|
||||
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.8"/>
|
||||
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.6"/>
|
||||
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.4"/>
|
||||
|
||||
<!-- Enhanced DOS-style pixels -->
|
||||
<rect x="5" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||
<rect x="10" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="19" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="24" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||
<rect x="5" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||
<rect x="24" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||
<rect x="5" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="25" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
||||
|
||||
<!-- Enhanced Handle -->
|
||||
<rect x="19" y="13" width="8" height="6" rx="3" fill="currentColor" opacity="0.8"/>
|
||||
<circle cx="23" cy="16" r="1.5" fill="var(--primary-bg)"/>
|
||||
<circle cx="23" cy="16" r="0.8" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Game Title -->
|
||||
<div class="mb-2">
|
||||
<h3 class="text-accent font-bold text-sm leading-tight mb-1">
|
||||
{{ (game.metadata_obj.title or game.title)[:18] }}{% if (game.metadata_obj.title or game.title)|length > 18 %}...{% endif %}
|
||||
</h3>
|
||||
<div class="w-12 h-0.5 bg-accent mx-auto opacity-60"></div>
|
||||
</div>
|
||||
|
||||
<!-- Branding -->
|
||||
<div class="text-xs text-secondary opacity-75 font-medium">
|
||||
<div class="mb-1">CLASSIC DOS GAME</div>
|
||||
<div class="text-accent text-[10px] font-bold tracking-wider">DOSVAULT</div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative Corner Elements -->
|
||||
<div class="absolute top-1 left-1">
|
||||
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||
<polygon points="0,0 8,0 0,8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute top-1 right-1">
|
||||
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||
<polygon points="8,0 8,8 0,0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-1">
|
||||
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||
<polygon points="0,8 0,0 8,8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute bottom-1 right-1">
|
||||
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||
<polygon points="8,8 0,8 8,0"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-full h-full bg-gradient-theme flex items-center justify-center relative overflow-hidden">
|
||||
<!-- Compact Background Pattern -->
|
||||
<div class="absolute inset-0 opacity-8">
|
||||
<svg width="100%" height="100%" viewBox="0 0 32 32" fill="none">
|
||||
<defs>
|
||||
<pattern id="pixel-grid-small" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
||||
<rect width="1" height="1" fill="currentColor" opacity="0.2"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#pixel-grid-small)"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Compact Logo -->
|
||||
<div class="text-center relative z-10">
|
||||
<svg class="w-8 h-8 mx-auto text-accent mb-1" viewBox="0 0 32 32" fill="none">
|
||||
<!-- Simplified vault door -->
|
||||
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="1.5" fill="currentColor" opacity="0.1"/>
|
||||
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1" opacity="0.6"/>
|
||||
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.3"/>
|
||||
|
||||
<!-- Minimal pixels -->
|
||||
<rect x="6" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="24" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="6" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="24" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
|
||||
<!-- Compact handle -->
|
||||
<rect x="20" y="14" width="6" height="4" rx="2" fill="currentColor" opacity="0.7"/>
|
||||
<circle cx="22" cy="16" r="0.8" fill="var(--primary-bg)"/>
|
||||
</svg>
|
||||
<div class="text-accent text-[8px] font-bold tracking-wide opacity-75">DOSVAULT</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Game Info -->
|
||||
<div class="flex-grow min-w-0">
|
||||
<h3 class="text-lg font-semibold text-blue-400 truncate">
|
||||
{{ game.metadata_obj.title or game.title }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-4 text-sm text-gray-300 mt-1">
|
||||
{% if game.metadata_obj and game.metadata_obj.year %}
|
||||
<span class="text-gray-400">{{ game.metadata_obj.year }}</span>
|
||||
{% endif %}
|
||||
{% if game.metadata_obj and game.metadata_obj.developer %}
|
||||
<span class="text-gray-400">{{ game.metadata_obj.developer }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if game.metadata_obj and game.metadata_obj.description %}
|
||||
<p class="text-sm text-gray-400 mt-2 line-clamp-2">
|
||||
{{ game.metadata_obj.description[:120] }}{% if game.metadata_obj.description|length > 120 %}...{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
{% if not is_demo %}
|
||||
<button onclick="event.stopPropagation(); toggleFavorite({{ game.id }})"
|
||||
class="w-8 h-8 flex items-center justify-center {% if game.id in user_favorites %}text-red-600{% else %}text-red-400{% endif %} hover:text-red-300 text-xl z-20 relative"
|
||||
id="favorite-{{ game.id }}">
|
||||
{% if game.id in user_favorites %}♥{% else %}♡{% endif %}
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); downloadGame({{ game.id }})"
|
||||
class="px-3 py-2 bg-green-600 hover:bg-green-700 rounded text-sm z-20 relative">
|
||||
Download
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="px-3 py-2 bg-gray-600 rounded text-sm cursor-not-allowed">
|
||||
Login to Download
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="mt-8 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<div class="text-gray-400 text-sm">
|
||||
Page {{ current_page }} of {{ total_pages }}
|
||||
</div>
|
||||
<nav class="flex items-center space-x-1">
|
||||
{% if current_page > 1 %}
|
||||
<a href="javascript:void(0)" onclick="goToPage({{ current_page - 1 }})"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- First page -->
|
||||
{% if current_page > 6 %}
|
||||
<a href="javascript:void(0)" onclick="goToPage(1)"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
1
|
||||
</a>
|
||||
{% if current_page > 7 %}
|
||||
<span class="px-3 py-2 text-gray-400">...</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Page numbers around current page -->
|
||||
{% for page_num in range(max(1, current_page - 4), min(total_pages + 1, current_page + 5)) %}
|
||||
{% if page_num == current_page %}
|
||||
<span class="px-3 py-2 bg-blue-600 rounded text-sm">{{ page_num }}</span>
|
||||
{% else %}
|
||||
<a href="javascript:void(0)" onclick="goToPage({{ page_num }})"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Last page -->
|
||||
{% if current_page < total_pages - 5 %}
|
||||
{% if current_page < total_pages - 6 %}
|
||||
<span class="px-3 py-2 text-gray-400">...</span>
|
||||
{% endif %}
|
||||
<a href="javascript:void(0)" onclick="goToPage({{ total_pages }})"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
{{ total_pages }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="javascript:void(0)" onclick="goToPage({{ current_page + 1 }})"
|
||||
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||
Next
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function buildUrl(params) {
|
||||
const url = new URL(window.location);
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] !== null && params[key] !== '') {
|
||||
url.searchParams.set(key, params[key]);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function changeView(newView) {
|
||||
window.location.href = buildUrl({ view: newView, page: 1 });
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
window.location.href = buildUrl({ per_page: newPerPage, page: 1 });
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
window.location.href = buildUrl({ page: page });
|
||||
}
|
||||
|
||||
async function toggleFavorite(gameId) {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/games/${gameId}/favorite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const favoriteBtn = document.getElementById(`favorite-${gameId}`);
|
||||
const isFavorited = favoriteBtn.textContent.trim() === '♥';
|
||||
|
||||
if (isFavorited) {
|
||||
// Remove from favorites
|
||||
favoriteBtn.textContent = '♡';
|
||||
favoriteBtn.classList.remove('text-red-600');
|
||||
favoriteBtn.classList.add('text-red-400');
|
||||
} else {
|
||||
// Add to favorites
|
||||
favoriteBtn.textContent = '♥';
|
||||
favoriteBtn.classList.remove('text-red-400');
|
||||
favoriteBtn.classList.add('text-red-600');
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
localStorage.removeItem('authToken');
|
||||
showLogin();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadGame(gameId) {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/download/${gameId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = response.headers.get('Content-Disposition')?.split('filename=')[1] || 'game.zip';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} else if (response.status === 401) {
|
||||
localStorage.removeItem('authToken');
|
||||
showLogin();
|
||||
} else {
|
||||
alert('Download failed. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert('Download failed. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Track which covers are being loaded to prevent duplicates
|
||||
const loadingCovers = new Set();
|
||||
|
||||
// Lazy load cover images that failed to load initially
|
||||
async function loadCoverImage(gameId, imgElement) {
|
||||
const loadKey = `cover-${gameId}`;
|
||||
|
||||
// Prevent multiple concurrent requests for the same cover
|
||||
if (loadingCovers.has(loadKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingCovers.add(loadKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/cover/${gameId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.cover_url) {
|
||||
imgElement.src = data.cover_url;
|
||||
imgElement.style.display = 'block';
|
||||
const placeholder = imgElement.nextElementSibling;
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// No cover found, show "No Cover" message
|
||||
const placeholder = imgElement.nextElementSibling;
|
||||
if (placeholder) {
|
||||
const textElement = placeholder.querySelector('p');
|
||||
if (textElement) {
|
||||
textElement.textContent = 'No Cover';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading cover image:', error);
|
||||
// Show error state
|
||||
const placeholder = imgElement.nextElementSibling;
|
||||
if (placeholder) {
|
||||
const textElement = placeholder.querySelector('p');
|
||||
if (textElement) {
|
||||
textElement.textContent = 'No Cover';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loadingCovers.delete(loadKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy loading disabled - you can investigate the CORS issue
|
||||
// document.addEventListener('DOMContentLoaded', function() {
|
||||
// // Lazy loading code commented out
|
||||
// });
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user