/
vaskaz
/
flexin_flow
Обзор
Документация
Войти
/
vaskaz
/
flexin_flow
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
static/index.html
645 строк
20 KB
vaskaz
newborn
10 июн 2026, 00:16
10 июн 2026, 00:16
dc9da65
Код
Авторство
О чём код?
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Retro Library</title> <script src="https://cdn.tailwindcss.com"></script> <style> @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Inter:wght@400;500;600;700&display=swap'); :root { --bg: #0f0f1a; --card-bg: #1a1a2e; --accent: #e94560; --accent2: #0f3460; --text: #eaeaea; --text-muted: #8888aa; --border: #2a2a4a; } * { box-sizing: border-box; } body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; margin: 0; } .pixel-font { font-family: 'Press Start 2P', monospace; } .header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border-bottom: 2px solid var(--accent); } .card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; transition: all 0.2s ease; cursor: pointer; overflow: hidden; } .card:hover { transform: translateY(-4px); border-color: var(--accent); box-shadow: 0 8px 24px rgba(233, 69, 96, 0.15); } .card-cover { width: 100%; aspect-ratio: 3/4; object-fit: contain; background: linear-gradient(135deg, #16213e, #1a1a2e); display: flex; align-items: center; justify-content: center; font-size: 3rem; } .card-title { font-weight: 600; font-size: 0.9rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .platform-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } /* Platform colors */ .plat-nes { background: #c0392b; } .plat-snes { background: #8e44ad; } .plat-n64 { background: #2980b9; } .plat-gb, .plat-gbc { background: #27ae60; } .plat-gba { background: #2ecc71; color: #000; } .plat-psx, .plat-ps2, .plat-psp { background: #1a5276; } .plat-genesis { background: #e67e22; } .plat-saturn { background: #c0392b; } .plat-dreamcast { background: #2c3e50; border: 1px solid #e94560; } .plat-nds { background: #2c3e50; } .plat-3ds { background: #e74c3c; } .plat-unknown { background: #555; } /* Scrollbar */ ::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-track { background: var(--bg); } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--accent); } /* Modal */ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 50; display: none; align-items: center; justify-content: center; padding: 1rem; } .modal-overlay.active { display: flex; } .modal-content { background: var(--card-bg); border: 1px solid var(--border); border-radius: 16px; max-width: 720px; width: 100%; max-height: 90vh; overflow-y: auto; padding: 2rem; position: relative; } /* Loading spinner */ .spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Responsive grid */ .games-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; } .empty-state { text-align: center; padding: 4rem 2rem; color: var(--text-muted); } .btn { padding: 0.5rem 1.25rem; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; border: none; font-size: 0.875rem; } .btn-primary { background: var(--accent); color: white; } .btn-primary:hover { background: #d63e55; } .btn-secondary { background: var(--accent2); color: white; border: 1px solid var(--border); } .btn-secondary:hover { background: #1a4a7a; } .btn-danger { background: #c0392b; color: white; } .btn-danger:hover { background: #a93226; } input, select { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.5rem 0.75rem; color: var(--text); font-size: 0.875rem; outline: none; transition: border-color 0.2s; } input:focus, select:focus { border-color: var(--accent); } input::placeholder { color: var(--text-muted); } .stat-box { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; } .stat-value { font-size: 1.5rem; font-weight: 700; color: var(--accent); } .stat-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } .detail-cover { width: 200px; height: 267px; object-fit: contain; background: linear-gradient(135deg, #16213e, #1a1a2e); border-radius: 12px; flex-shrink: 0; } @media (max-width: 640px) { .games-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; } .modal-content { padding: 1rem; } .detail-cover { width: 140px; height: 187px; } } </style> </head> <body> <!-- Header --> <header class="header px-4 py-3 flex items-center justify-between flex-wrap gap-2"> <div class="flex items-center gap-3"> <span class="pixel-font text-xl" style="color:var(--accent)">★</span> <h1 class="text-xl font-bold">Retro Library</h1> <span id="romCount" class="text-sm" style="color:var(--text-muted)">0 ROMs</span> </div> <div class="flex items-center gap-2"> <button onclick="triggerEnrich()" class="btn btn-secondary text-sm" id="enrichBtn"> ★ Enrich </button> <button onclick="triggerScan()" class="btn btn-primary text-sm"> ↻ Scan </button> <a href="/api/platforms" target="_blank" class="btn btn-secondary text-sm hidden sm:inline-block"> API </a> </div> </header> <!-- Main content --> <main class="max-w-7xl mx-auto p-4"> <!-- Stats bar --> <div class="flex flex-wrap gap-3 mb-4" id="statsBar"></div> <!-- Filters --> <div class="flex flex-wrap gap-3 mb-4 items-end"> <div class="flex-1 min-w-[200px]"> <label class="text-xs font-medium block mb-1" style="color:var(--text-muted)">Search</label> <input type="text" id="searchInput" placeholder="Search games..." class="w-full" oninput="debouncedSearch()"> </div> <div> <label class="text-xs font-medium block mb-1" style="color:var(--text-muted)">Platform</label> <select id="platformFilter" onchange="loadGames()" class="min-w-[180px]"> <option value="">All Platforms</option> </select> </div> <div class="flex items-center gap-2 pb-[2px]"> <label class="text-xs font-medium" style="color:var(--text-muted)"> <input type="checkbox" id="compactView" onchange="renderGames()" class="mr-1"> Compact </label> </div> </div> <!-- Loading & scan bar --> <div id="scanBar" class="mb-4 hidden"> <div class="flex items-center gap-3 p-3 rounded-lg" style="background:var(--card-bg); border:1px solid var(--border);"> <div class="spinner"></div> <span id="scanStatus" style="color:var(--text-muted)">Scanning...</span> </div> </div> <!-- Enrich bar --> <div id="enrichBar" class="mb-4 hidden"> <div class="flex items-center gap-3 p-3 rounded-lg" style="background:var(--card-bg); border:1px solid var(--border);"> <div class="spinner"></div> <span id="enrichStatus" style="color:var(--text-muted)">Enriching metadata...</span> </div> </div> <!-- Error bar --> <div id="errorBar" class="mb-4 hidden"> <div class="flex items-center gap-3 p-3 rounded-lg" style="background:#2c1010; border:1px solid #c0392b;"> <span id="errorText" style="color:#e74c3c;"></span> <button onclick="this.parentElement.parentElement.classList.add('hidden')" class="ml-auto text-sm" style="color:var(--text-muted)">✕</button> </div> </div> <!-- Games grid --> <div id="gamesContainer"> <div class="empty-state"> <div class="text-5xl mb-3">🎮</div> <h3 class="text-lg font-semibold mb-1">No games yet</h3> <p class="text-sm">Set <code style="background:#1a1a2e; padding:2px 6px; border-radius:4px;">RETRO_LIBRARY_PATH</code> and click Scan</p> </div> </div> <!-- Pagination --> <div id="pagination" class="flex items-center justify-center gap-3 mt-6 hidden"> <button onclick="changePage(-1)" class="btn btn-secondary">← Prev</button> <span id="pageInfo" class="text-sm" style="color:var(--text-muted)"></span> <button onclick="changePage(1)" class="btn btn-secondary">Next →</button> </div> </main> <!-- Detail Modal --> <div class="modal-overlay" id="detailModal" onclick="if(event.target===this)closeDetail()"> <div class="modal-content" id="detailContent"> <button onclick="closeDetail()" class="absolute top-4 right-4 text-2xl leading-none" style="color:var(--text-muted)">×</button> <div id="detailBody" class="flex flex-col sm:flex-row gap-6"> <div class="flex justify-center sm:block"> <div class="detail-cover flex items-center justify-center text-6xl" id="detailCover">🎮</div> </div> <div class="flex-1 min-w-0"> <h2 id="detailTitle" class="text-2xl font-bold mb-2">Loading...</h2> <div class="flex flex-wrap gap-2 mb-4"> <span class="platform-badge" id="detailPlatformBadge">NES</span> <span class="text-sm" style="color:var(--text-muted)" id="detailSize">0 MB</span> </div> <div class="mb-4"> <label class="text-xs font-medium block mb-1" style="color:var(--text-muted)">Emulator</label> <p id="detailEmulator" class="text-sm">-</p> </div> <div class="mb-4"> <label class="text-xs font-medium block mb-1" style="color:var(--text-muted)">Filename</label> <p id="detailFilename" class="text-sm truncate font-mono" style="color:var(--text-muted)">-</p> </div> <div class="mb-4"> <label class="text-xs font-medium block mb-1" style="color:var(--text-muted)">Description</label> <p id="detailDescription" class="text-sm" style="color:var(--text-muted)">No description available.</p> </div> <div class="flex gap-2"> <a id="downloadLink" class="btn btn-primary inline-flex items-center gap-1 no-underline" href="#"> ⬇ Download ROM </a> <button onclick="closeDetail()" class="btn btn-secondary">Close</button> </div> </div> </div> </div> </div> <script> // ── State ── let allRoms = []; let filteredRoms = []; let currentPage = 1; const PER_PAGE = 48; // ── Init ── document.addEventListener('DOMContentLoaded', () => { loadStats(); loadPlatforms(); loadGames(); }); // ── API calls ── async function api(path) { const res = await fetch(path); if (!res.ok) throw new Error(await res.text()); return res.json(); } async function loadStats() { try { const stats = await api('/api/stats'); document.getElementById('romCount').textContent = `${stats.total_roms || 0} ROMs`; const bar = document.getElementById('statsBar'); const sizeStr = stats.total_size_bytes > 0 ? formatBytes(stats.total_size_bytes) : '0 B'; bar.innerHTML = ` <div class="stat-box"><div class="stat-value">${stats.total_roms || 0}</div><div class="stat-label">Games</div></div> <div class="stat-box"><div class="stat-value">${stats.platforms || 0}</div><div class="stat-label">Platforms</div></div> <div class="stat-box"><div class="stat-value">${sizeStr}</div><div class="stat-label">Total Size</div></div> `; } catch(e) { console.error('Stats error:', e); } } async function loadPlatforms() { try { const platforms = await api('/api/platforms'); const sel = document.getElementById('platformFilter'); const currentVal = sel.value; sel.innerHTML = '<option value="">All Platforms</option>'; for (const p of platforms) { const opt = document.createElement('option'); opt.value = p.platform; opt.textContent = `${p.platform_name} (${p.count})`; sel.appendChild(opt); } if (currentVal) sel.value = currentVal; } catch(e) { console.error('Platforms error:', e); } } async function loadGames() { const platform = document.getElementById('platformFilter').value; const search = document.getElementById('searchInput').value.trim(); try { const params = new URLSearchParams({ per_page: '1000' }); if (platform) params.set('platform', platform); if (search) params.set('search', search); const data = await api(`/api/roms?${params}`); allRoms = data.results; currentPage = 1; renderGames(); loadStats(); } catch(e) { console.error('Load error:', e); document.getElementById('gamesContainer').innerHTML = ` <div class="empty-state"> <div class="text-5xl mb-3">⚠</div> <h3 class="text-lg font-semibold">Failed to load</h3> <p class="text-sm">${e.message}</p> </div> `; } } // ── Render ── function renderGames() { const container = document.getElementById('gamesContainer'); const compact = document.getElementById('compactView').checked; if (allRoms.length === 0) { container.innerHTML = ` <div class="empty-state"> <div class="text-5xl mb-3">🔎</div> <h3 class="text-lg font-semibold">No games found</h3> <p class="text-sm">Try different filters or click Scan to add games</p> </div> `; document.getElementById('pagination').classList.add('hidden'); return; } // Paginate const totalPages = Math.ceil(allRoms.length / PER_PAGE); const start = (currentPage - 1) * PER_PAGE; const end = start + PER_PAGE; const pageRoms = allRoms.slice(start, end); // Build grid let html = '<div class="games-grid">'; for (const rom of pageRoms) { const platClass = `plat-${rom.platform}`; const title = rom.title || rom.filename; const coverHtml = rom.cover_url ? `<img src="/covers/${rom.cover_url}" alt="${title}" class="card-cover" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="card-cover" style="display:none">🎮</div>` : `<div class="card-cover">🎮</div>`; if (compact) { html += ` <div class="card p-3 flex items-center gap-3" onclick="openDetail(${rom.id})" style="aspect-ratio:auto"> <div class="w-10 h-10 rounded flex items-center justify-center text-lg flex-shrink-0" style="background:var(--border)">🎮</div> <div class="min-w-0 flex-1"> <div class="card-title text-sm">${escHtml(title)}</div> <span class="platform-badge ${platClass} text-xs">${rom.platform_name || rom.platform}</span> </div> </div> `; } else { html += ` <div class="card" onclick="openDetail(${rom.id})"> ${coverHtml} <div class="p-3"> <div class="card-title">${escHtml(title)}</div> <div class="flex items-center justify-between mt-2"> <span class="platform-badge ${platClass}">${rom.platform_name || rom.platform}</span> <span class="text-xs" style="color:var(--text-muted)">${formatBytes(rom.size_bytes)}</span> </div> </div> </div> `; } } html += '</div>'; container.innerHTML = html; // Pagination controls const pagEl = document.getElementById('pagination'); if (totalPages > 1) { pagEl.classList.remove('hidden'); document.getElementById('pageInfo').textContent = `Page ${currentPage} / ${totalPages}`; } else { pagEl.classList.add('hidden'); } } function changePage(delta) { const totalPages = Math.ceil(allRoms.length / PER_PAGE); const newPage = currentPage + delta; if (newPage < 1 || newPage > totalPages) return; currentPage = newPage; renderGames(); window.scrollTo({ top: 0, behavior: 'smooth' }); } // ── Detail modal ── async function openDetail(id) { try { const rom = await api(`/api/roms/${id}`); document.getElementById('detailModal').classList.add('active'); document.getElementById('detailTitle').textContent = rom.title || rom.filename; document.getElementById('detailSize').textContent = rom.size_str || formatBytes(rom.size_bytes); document.getElementById('detailEmulator').textContent = rom.emulator || 'Not specified'; document.getElementById('detailFilename').textContent = rom.filename; document.getElementById('detailDescription').textContent = rom.description || 'No description available.'; const badge = document.getElementById('detailPlatformBadge'); badge.textContent = rom.platform_name || rom.platform; badge.className = `platform-badge plat-${rom.platform}`; const coverEl = document.getElementById('detailCover'); if (rom.cover_exists && rom.cover_url) { coverEl.innerHTML = `<img src="/covers/${rom.cover_url}" alt="${rom.title}" style="width:100%;height:100%;object-fit:contain;border-radius:12px;" onerror="this.outerHTML='🎮'">`; } else { coverEl.textContent = '🎮'; } document.getElementById('downloadLink').href = `/api/download/${id}`; document.getElementById('detailBody').style.display = ''; } catch(e) { showError('Failed to load game details: ' + e.message); } } function closeDetail() { document.getElementById('detailModal').classList.remove('active'); } // ── Scan ── async function triggerScan() { const bar = document.getElementById('scanBar'); const status = document.getElementById('scanStatus'); bar.classList.remove('hidden'); status.textContent = 'Scanning games...'; try { const result = await fetch('/api/scan', { method: 'POST' }); if (!result.ok) { const err = await result.text(); throw new Error(err); } const data = await result.json(); if (data.status === 'error') throw new Error(data.error); const s = data.stats || {}; status.textContent = `Done! Found ${s.found || 0} files, added ${s.added || 0} games${s.errors ? `, ${s.errors} errors` : ''} (${s.elapsed_sec || 0}s)`; // Refresh await Promise.all([loadGames(), loadPlatforms(), loadStats()]); setTimeout(() => bar.classList.add('hidden'), 5000); } catch(e) { status.textContent = `Error: ${e.message}`; showError(e.message); setTimeout(() => bar.classList.add('hidden'), 8000); } } // ── Enrich ── async function triggerEnrich() { const bar = document.getElementById('enrichBar'); const status = document.getElementById('enrichStatus'); const btn = document.getElementById('enrichBtn'); bar.classList.remove('hidden'); status.textContent = 'Enriching metadata from Wikipedia...'; btn.disabled = true; btn.style.opacity = '0.5'; try { const result = await fetch('/api/enrich', { method: 'POST' }); if (!result.ok) { const err = await result.text(); throw new Error(err); } const data = await result.json(); const s = data.stats || {}; status.textContent = `Done! ${s.enriched || 0} enriched, ${s.not_found || 0} not found, ${s.errors || 0} errors`; // Refresh to show covers & descriptions await Promise.all([loadGames(), loadPlatforms(), loadStats()]); setTimeout(() => bar.classList.add('hidden'), 6000); } catch(e) { status.textContent = `Error: ${e.message}`; showError(e.message); setTimeout(() => bar.classList.add('hidden'), 8000); } finally { btn.disabled = false; btn.style.opacity = '1'; } } // ── Search debounce ── let searchTimer; function debouncedSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(loadGames, 300); } // ── Helpers ── function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function escHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function showError(msg) { const bar = document.getElementById('errorBar'); document.getElementById('errorText').textContent = msg; bar.classList.remove('hidden'); setTimeout(() => bar.classList.add('hidden'), 8000); } // Close modal on Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); }); </script> </body> </html>