GitHub Actions
Sync from GitHub repo
f1a0148
raw
history blame contribute delete
48.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}TTS Arena{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
{% block extra_head %}{% endblock %}
<style>
:root {
--primary-color: #5046e5;
--secondary-color: #f0f0f0;
--text-color: #333;
--light-gray: #f5f5f5;
--border-color: #e0e0e0;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
--radius: 8px;
--success-color: #10b981;
--info-color: #3b82f6;
--warning-color: #f59e0b;
--error-color: #ef4444;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', sans-serif;
}
body {
color: var(--text-color);
display: flex;
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
a {
color: var(--primary-color);
}
.sidebar {
width: 240px;
background-color: var(--light-gray);
padding: 24px 16px;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100vh;
z-index: 1000;
transition: transform 0.3s ease-in-out;
flex-shrink: 0;
}
.logo {
font-size: 24px;
font-weight: 700;
margin-bottom: 32px;
color: var(--primary-color);
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 16px;
margin-bottom: 8px;
border-radius: var(--radius);
cursor: pointer;
transition: background-color 0.2s;
color: var(--text-color);
text-decoration: none;
}
.nav-item.active {
background-color: rgba(80, 70, 229, 0.1);
color: var(--primary-color);
font-weight: 500;
}
.nav-item:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.05);
}
.nav-item svg {
margin-right: 12px;
}
.main-content {
flex: 1;
padding: 32px;
width: 100%;
margin: 0 auto;
overflow-y: auto;
height: 100vh;
}
.main-content-inner {
max-width: 1200px;
width: 100%;
margin: 0 auto;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
margin-bottom: 24px;
}
.tab {
padding: 12px 24px;
cursor: pointer;
position: relative;
font-weight: 500;
}
.tab.active {
color: var(--primary-color);
}
.tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 2px;
background-color: var(--primary-color);
}
.input-container {
display: flex;
margin-bottom: 24px;
align-items: center;
}
.text-input {
flex: 1;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
font-family: 'Inter', sans-serif;
font-size: 1em;
outline: none;
transition: border-color 0.2s;
}
.text-input:focus {
border-color: var(--primary-color);
}
.btn {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius);
padding: 12px 24px;
margin-left: 12px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #4038c7;
}
.icon-btn {
background-color: white;
border: 1px solid var(--border-color);
border-radius: var(--radius);
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.icon-btn:hover {
background-color: var(--light-gray);
}
.players-container {
display: flex;
flex-direction: column;
}
.players-row {
display: flex;
gap: 24px;
margin-bottom: 24px;
}
.player {
flex: 1;
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow);
}
.player-label {
font-weight: 600;
margin-bottom: 12px;
}
.audio-player {
width: 100%;
margin-bottom: 16px;
}
.vote-btn {
width: 100%;
padding: 12px;
background-color: white;
border: 1px solid var(--border-color);
border-radius: var(--radius);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.vote-btn:hover {
background-color: var(--light-gray);
border-color: #ccc;
}
.vote-btn.selected {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.shortcut-key {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background-color: var(--light-gray);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 2px 6px;
font-size: 12px;
font-weight: 600;
}
.vote-btn.selected .shortcut-key {
background-color: rgba(255, 255, 255, 0.2);
color: white;
border-color: transparent;
}
.user-auth {
margin-top: auto;
display: flex;
align-items: center;
padding: 12px 16px;
border-top: 1px solid var(--border-color);
cursor: pointer;
position: relative;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
margin-right: 12px;
}
.user-name {
font-weight: 500;
flex: 1;
}
.user-dropdown {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin: 0 16px;
background-color: white;
border: 1px solid var(--border-color);
border-radius: var(--radius);
box-shadow: var(--shadow);
z-index: 1000;
display: none;
overflow: hidden;
margin-bottom: 8px;
}
.user-dropdown.active {
display: block;
}
.dropdown-item {
padding: 12px 16px;
display: flex;
align-items: center;
transition: background-color 0.2s;
text-decoration: none;
color: var(--text-color);
}
.dropdown-item:hover {
background-color: var(--light-gray);
}
.dropdown-item svg {
margin-right: 12px;
}
.dropdown-divider {
height: 1px;
background-color: var(--border-color);
margin: 4px 0;
}
.user-auth-arrow {
transition: transform 0.2s;
}
.user-auth.active .user-auth-arrow {
transform: rotate(180deg);
}
.login-link {
display: flex;
align-items: center;
padding: 12px 16px;
border-top: 1px solid var(--border-color);
text-decoration: none;
color: var(--text-color);
}
.login-link:hover {
background-color: var(--light-gray);
}
.login-link img {
width: 24px;
height: 24px;
margin-right: 12px;
}
.discord-link {
display: flex;
align-items: center;
padding: 12px 16px;
border-top: 1px solid var(--border-color);
text-decoration: none;
color: var(--text-color);
}
.discord-link:hover {
background-color: var(--light-gray);
color: #5865F2;
}
.discord-link svg {
margin-right: 12px;
}
.sidebar-footer {
margin-top: auto;
display: flex;
flex-direction: column;
}
.mobile-header {
display: none;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.hamburger-menu {
width: 24px;
height: 24px;
cursor: pointer;
}
.current-page {
font-weight: 600;
font-size: 18px;
}
.backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
z-index: 999;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.backdrop.active {
display: block;
opacity: 1;
}
.close-sidebar {
position: absolute;
top: 16px;
right: 16px;
width: 24px;
height: 24px;
cursor: pointer;
display: none;
}
/* Toast styles */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 350px;
}
.toast {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: 8px;
background-color: white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
animation: slideIn 0.3s ease-out forwards;
position: relative;
overflow: hidden;
}
.toast.slide-out {
animation: slideOut 0.3s ease-in forwards;
}
.toast-icon {
margin-right: 10px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-content {
flex: 1;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
}
.toast-close {
margin-left: 10px;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
flex-shrink: 0;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.toast-close:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.05);
}
.toast-progress {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
width: 100%;
transform-origin: left;
}
.toast.info {
border-left-color: var(--info-color);
}
.toast.info .toast-icon {
color: var(--info-color);
}
.toast.info .toast-progress {
background-color: var(--info-color);
}
.toast.success .toast-icon {
color: var(--success-color);
}
.toast.success .toast-progress {
background-color: var(--success-color);
}
.toast.warning .toast-icon {
color: var(--warning-color);
}
.toast.warning .toast-progress {
background-color: var(--warning-color);
}
.toast.error .toast-icon {
color: var(--error-color);
}
.toast.error .toast-progress {
background-color: var(--error-color);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes shrink {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
@media (max-width: 768px) {
body {
flex-direction: column;
}
.mobile-header {
display: flex;
flex-shrink: 0;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 280px;
border-right: 1px solid var(--border-color);
padding: 24px 16px;
height: 100vh;
transform: translateX(-100%);
}
.sidebar.active {
transform: translateX(0);
}
.close-sidebar {
display: block;
}
.logo {
display: block;
}
.players-container {
flex-direction: column;
}
.main-content {
height: calc(100vh - 57px);
overflow-y: auto;
}
.toast-container {
bottom: auto;
top: 16px;
right: 16px;
left: 16px;
max-width: none;
}
@keyframes slideIn {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(-100%);
opacity: 0;
}
}
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--light-gray);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(120, 120, 120, 0.5);
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: rgba(120, 120, 120, 0.5) var(--light-gray);
}
/* For Edge and other browsers */
::-webkit-scrollbar-corner {
background: var(--light-gray);
}
/* Ensure smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Dark mode styles */
@media (prefers-color-scheme: dark) {
:root {
--primary-color: #6c63ff;
--secondary-color: #2d2b38;
--text-color: #e0e0e0;
--light-gray: #1e1e24;
--border-color: #3a3a45;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
--success-color: #10b981;
--info-color: #60a5fa;
--warning-color: #f59e0b;
--error-color: #ef4444;
}
body {
background-color: #121218;
color: var(--text-color);
}
.sidebar {
background-color: var(--light-gray);
border-right-color: var(--border-color);
}
.nav-item.active {
background-color: rgba(108, 99, 255, 0.2);
}
.nav-item:hover:not(.active) {
background-color: rgba(255, 255, 255, 0.05);
}
.text-input,
.select-input,
.textarea {
background-color: var(--light-gray);
color: var(--text-color);
border-color: var(--border-color);
}
.card {
background-color: var(--light-gray);
border-color: var(--border-color);
}
.tab.active::after {
background-color: var(--primary-color);
}
/* Fix vote buttons in dark mode */
.vote-btn {
background-color: var(--light-gray);
color: var(--text-color);
border-color: var(--border-color);
border-radius: var(--radius);
}
.vote-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: var(--border-color);
}
.vote-btn.selected {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.shortcut-key {
background-color: rgba(255, 255, 255, 0.1);
color: var(--text-color);
border-color: var(--border-color);
}
.vote-btn.selected .shortcut-key {
background-color: rgba(255, 255, 255, 0.2);
color: white;
border-color: transparent;
}
/* Fix loading state in dark mode */
.vote-btn:disabled,
.vote-btn.loading {
background-color: var(--light-gray);
border-radius: var(--radius);
}
.vote-loader {
background-color: var(--light-gray);
border-radius: var(--radius);
}
.vote-spinner {
border-color: rgba(108, 99, 255, 0.3);
border-top-color: var(--primary-color);
}
.toast {
background-color: var(--light-gray);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.toast-close:hover {
background-color: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-track {
background: var(--secondary-color);
}
::-webkit-scrollbar-thumb {
background: rgba(180, 180, 180, 0.5);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(200, 200, 200, 0.7);
}
* {
scrollbar-color: rgba(180, 180, 180, 0.5) var(--secondary-color);
}
::-webkit-scrollbar-corner {
background: var(--secondary-color);
}
/* Dark mode loading overlay */
.loading-overlay {
background-color: rgba(18, 18, 24, 0.8);
}
/* Dark mode spinner */
.loader-spinner {
border-color: rgba(108, 99, 255, 0.2);
border-top-color: var(--primary-color);
}
/* Dark mode user dropdown */
.user-dropdown {
background-color: var(--light-gray);
border-color: var(--border-color);
}
.dropdown-item {
color: var(--text-color);
}
.dropdown-item:hover {
background-color: rgba(108, 99, 255, 0.1);
}
.dropdown-divider {
background-color: var(--border-color);
}
.user-avatar {
background-color: var(--primary-color);
}
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.loading-overlay.active {
opacity: 1;
visibility: visible;
}
.loader-spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(80, 70, 229, 0.3);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Login tip overlay */
.login-tip-overlay {
position: absolute;
background-color: white;
border: 1px solid var(--border-color);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 16px;
z-index: 1000;
width: 280px;
display: none;
}
.login-tip-overlay.show {
display: block;
}
.login-tip-content {
font-size: 14px;
margin-bottom: 12px;
}
.login-tip-actions {
display: flex;
justify-content: space-between;
}
.login-tip-close {
font-size: 13px;
color: var(--text-color);
opacity: 0.7;
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.login-now-btn {
font-size: 13px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
text-decoration: none;
}
.login-tip-overlay[data-popper-placement^='top'] .login-tip-caret {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 8px;
overflow: hidden;
}
.login-tip-overlay[data-popper-placement^='top'] .login-tip-caret:after {
content: '';
position: absolute;
width: 12px;
height: 12px;
background: white;
border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
top: -6px;
left: 2px;
transform: rotate(45deg);
}
@media (prefers-color-scheme: dark) {
.login-tip-overlay {
background-color: var(--light-gray);
border-color: var(--border-color);
}
.login-tip-overlay[data-popper-placement^='top'] .login-tip-caret:after {
background: var(--light-gray);
border-color: var(--border-color);
}
.login-tip-close {
color: var(--text-color);
}
}
/* Mobile login banner */
.login-banner {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 85%;
max-width: 320px;
background-color: white;
color: var(--text-color);
border-radius: var(--radius);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 20px;
display: none;
z-index: 9998;
text-align: center;
border: 1px solid var(--border-color);
}
.login-banner-content {
margin-bottom: 16px;
font-size: 15px;
font-weight: 500;
}
.login-banner-actions {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-top: 20px;
}
.login-banner-close {
background: none;
border: 1px solid var(--border-color);
color: var(--text-color);
font-size: 14px;
cursor: pointer;
padding: 10px 16px;
border-radius: var(--radius);
flex: 1;
font-weight: 500;
}
.login-banner-btn {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius);
padding: 10px 16px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
flex: 1;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.login-banner {
background-color: var(--light-gray);
border-color: var(--border-color);
}
.login-banner-close {
border-color: var(--border-color);
background-color: rgba(255, 255, 255, 0.05);
}
}
</style>
</head>
<body>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay">
<div class="loader-spinner"></div>
</div>
<div class="mobile-header">
<div class="hamburger-menu" onclick="toggleSidebar()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6H20M4 12H20M4 18H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="current-page">{% block current_page %}Arena{% endblock %}</div>
</div>
<div class="backdrop" onclick="toggleSidebar()"></div>
<div class="sidebar">
<div class="close-sidebar" onclick="toggleSidebar()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="logo">TTS Arena</div>
<nav>
<a href="{{ url_for('arena') }}" class="nav-item {% if request.path == '/' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-dices"><rect width="12" height="12" x="2" y="10" rx="2" ry="2"/><path d="m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6"/><path d="M6 18h.01"/><path d="M10 14h.01"/><path d="M15 6h.01"/><path d="M18 9h.01"/></svg>
Arena
</a>
<a href="{{ url_for('leaderboard') }}" class="nav-item {% if request.path == '/leaderboard' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trophy"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
Leaderboard
</a>
<a href="{{ url_for('about') }}" class="nav-item {% if request.path == '/about' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
About
</a>
<!-- Admin Panel Link - Only visible to admin users -->
{% if g.is_admin %}
<a href="{{ url_for('admin.index') }}" class="nav-item {% if '/admin' in request.path %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/></svg>
Admin Panel
</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<a href="https://discord.gg/HB8fMR6GTr" target="_blank" rel="noopener noreferrer" class="discord-link">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 127.14 96.36" fill="currentColor">
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
</svg>
Join our Discord
</a>
{% if current_user.is_authenticated %}
<div class="user-auth" onclick="toggleUserDropdown(event)">
<div class="user-name">{{ current_user.username }}</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="user-auth-arrow">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<div class="user-dropdown">
<a href="{{ url_for('auth.logout') }}" class="dropdown-item">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Logout
</a>
</div>
</div>
{% else %}
<a href="{{ url_for('auth.login', next=request.path) }}" class="login-link">
<img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face">
Login
</a>
<!-- Login tip overlay -->
<div id="login-tip-overlay" class="login-tip-overlay">
<div class="login-tip-content">
Log in to track your votes, see personalized leaderboards, and more!
</div>
<div class="login-tip-actions">
<button class="login-tip-close" onclick="dismissLoginTip()">Don't show again</button>
<a href="{{ url_for('auth.login', next=request.path) }}" class="login-now-btn">Login now</a>
</div>
<div class="login-tip-caret"></div>
</div>
{% endif %}
</div>
</div>
<div class="main-content">
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<script>
document.addEventListener('DOMContentLoaded', function () {
openToast('{{ message }}', '{{ category }}');
});
</script>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="main-content-inner">
{% block content %}{% endblock %}
</div>
</div>
<!-- Toast container -->
<div class="toast-container" id="toast-container"></div>
{% if not current_user.is_authenticated %}
<!-- Mobile login banner -->
<div id="login-banner" class="login-banner">
<div class="login-banner-content">
Log in to track your votes and see personalized leaderboards!
</div>
<div class="login-banner-actions">
<button class="login-banner-close" onclick="dismissLoginTip()">No thanks</button>
<a href="{{ url_for('auth.login', next=request.path) }}" class="login-banner-btn">Login</a>
</div>
</div>
{% endif %}
{% block extra_scripts %}{% endblock %}
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script>
function toggleSidebar() {
const sidebar = document.querySelector('.sidebar');
const backdrop = document.querySelector('.backdrop');
sidebar.classList.toggle('active');
backdrop.classList.toggle('active');
}
function toggleUserDropdown(event) {
event.stopPropagation();
const userAuth = document.querySelector('.user-auth');
const userDropdown = document.querySelector('.user-dropdown');
userAuth.classList.toggle('active');
userDropdown.classList.toggle('active');
}
// Function to check if the login tip has been dismissed
function isLoginTipDismissed() {
try {
return localStorage.getItem('login_tip_dismissed') === 'true';
} catch (error) {
// Fallback if localStorage is blocked
console.warn('localStorage access failed:', error);
return false;
}
}
// Function to set localStorage when login tip is dismissed
function dismissLoginTip() {
try {
// Store the preference in localStorage
localStorage.setItem('login_tip_dismissed', 'true');
// Hide all login notifications
const loginTip = document.getElementById('login-tip-overlay');
const loginBanner = document.getElementById('login-banner');
const backdrop = document.querySelector('.login-backdrop');
if (loginTip) {
loginTip.classList.remove('show');
}
if (loginBanner) {
loginBanner.style.display = 'none';
}
if (backdrop) {
backdrop.style.display = 'none';
}
} catch (error) {
console.warn('localStorage write failed:', error);
// Still hide the tips even if localStorage fails
const loginTip = document.getElementById('login-tip-overlay');
const loginBanner = document.getElementById('login-banner');
const backdrop = document.querySelector('.login-backdrop');
if (loginTip) {
loginTip.classList.remove('show');
}
if (loginBanner) {
loginBanner.style.display = 'none';
}
if (backdrop) {
backdrop.style.display = 'none';
}
}
}
// Loading overlay functionality
document.addEventListener('DOMContentLoaded', function () {
// Show login tip if user is not logged in and hasn't dismissed it
const loginTipOverlay = document.getElementById('login-tip-overlay');
const loginBanner = document.getElementById('login-banner');
const loginLink = document.querySelector('.login-link');
if (loginLink && !isLoginTipDismissed()) {
// Check screen width to determine which login notification to show
if (window.innerWidth <= 768) {
// Create and add a backdrop for the login banner
const backdrop = document.createElement('div');
backdrop.className = 'login-backdrop';
backdrop.style.position = 'fixed';
backdrop.style.top = '0';
backdrop.style.left = '0';
backdrop.style.width = '100%';
backdrop.style.height = '100%';
backdrop.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
backdrop.style.zIndex = '9997';
backdrop.style.display = 'none';
document.body.appendChild(backdrop);
// Show mobile banner with backdrop
if (loginBanner) {
loginBanner.style.display = 'block';
backdrop.style.display = 'block';
// Add event listener to close banner when clicking backdrop
backdrop.addEventListener('click', function() {
dismissLoginTip();
});
}
} else {
// Show desktop popover
if (loginTipOverlay) {
// Position the overlay with Popper.js
const popperInstance = Popper.createPopper(loginLink, loginTipOverlay, {
placement: 'top',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 10],
},
},
],
});
loginTipOverlay.classList.add('show');
popperInstance.update();
}
}
}
// Handle resize events to switch between banner and popover
window.addEventListener('resize', function() {
if (isLoginTipDismissed()) return;
const backdrop = document.querySelector('.login-backdrop');
if (window.innerWidth <= 768) {
// Switch to mobile banner
if (loginTipOverlay) {
loginTipOverlay.classList.remove('show');
}
if (loginBanner && backdrop) {
loginBanner.style.display = 'block';
backdrop.style.display = 'block';
}
} else {
// Switch to desktop popover
if (loginBanner && backdrop) {
loginBanner.style.display = 'none';
backdrop.style.display = 'none';
}
if (loginTipOverlay && loginLink) {
const popperInstance = Popper.createPopper(loginLink, loginTipOverlay, {
placement: 'top',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 10],
},
},
],
});
loginTipOverlay.classList.add('show');
popperInstance.update();
}
}
});
// Hide the loading overlay when page has loaded
const loadingOverlay = document.getElementById('loading-overlay');
loadingOverlay.classList.remove('active');
// Override fetch to handle Turnstile verification errors
const originalFetch = window.fetch;
window.fetch = async function (url, options) {
try {
const response = await originalFetch(url, options);
// If we get a 403 error with a specific error message, handle verification
if (response.status === 403) {
const data = await response.clone().json();
if (data && (data.error === "Turnstile verification required" || data.error === "Turnstile verification expired")) {
// Redirect to Turnstile verification page with the current URL as the redirect target
window.location.href = "/turnstile?redirect_url=" + encodeURIComponent(window.location.href);
return new Response(JSON.stringify({ redirecting: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
}
return response;
} catch (error) {
return Promise.reject(error);
}
};
});
// Close dropdown when clicking outside
document.addEventListener('click', function (event) {
const userDropdown = document.querySelector('.user-dropdown');
const userAuth = document.querySelector('.user-auth');
if (userDropdown && userAuth && userDropdown.classList.contains('active') && !userAuth.contains(event.target)) {
userAuth.classList.remove('active');
userDropdown.classList.remove('active');
}
});
// Toast functionality
function openToast(message, type = 'info', duration = 5000) {
const toastContainer = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
// Generate icon based on type
let iconSvg = '';
if (type === 'info') {
iconSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>';
} else if (type === 'success') {
iconSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
} else if (type === 'warning') {
iconSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>';
} else if (type === 'error') {
iconSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
}
toast.innerHTML = `
<div class="toast-icon">${iconSvg}</div>
<div class="toast-content">${message}</div>
<div class="toast-close" onclick="closeToast(this.parentNode)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
<div class="toast-progress"></div>
`;
toastContainer.appendChild(toast);
// Animate progress bar
const progressBar = toast.querySelector('.toast-progress');
progressBar.style.animation = `shrink ${duration / 1000}s linear forwards`;
progressBar.style.transformOrigin = 'left';
progressBar.style.transform = 'scaleX(1)';
// Auto-remove toast after duration
const timeoutId = setTimeout(() => {
closeToast(toast);
}, duration);
// Store timeout ID on the toast element
toast.dataset.timeoutId = timeoutId;
return toast;
}
function closeToast(toast) {
// Clear the timeout to prevent duplicate removal attempts
if (toast.dataset.timeoutId) {
clearTimeout(parseInt(toast.dataset.timeoutId));
}
// Add slide-out animation
toast.classList.add('slide-out');
// Remove toast after animation completes
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}
</script>
</body>
</html>