Spaces:
				
			
			
	
			
			
					
		Running
		
			on 
			
			CPU Upgrade
	
	
	
			
			
	
	
	
	
		
		
					
		Running
		
			on 
			
			CPU Upgrade
	
		GitHub Actions
		
	commited on
		
		
					Commit 
							
							·
						
						f1a0148
	
1
								Parent(s):
							
							16a9bac
								
Sync from GitHub repo
Browse files- .env.example +15 -0
- .github/workflows/sync-to-hf.yaml +43 -0
- .gitignore +21 -147
- COPYING +15 -0
- LICENSE.APACHE +201 -0
- LICENSE.MIT +21 -0
- README.md +8 -22
- TURNSTILE.md +58 -0
- admin.py +401 -0
- app.py +1065 -2
- auth.py +104 -0
- models.py +575 -0
- requirements.txt +14 -8
- static/closed.svg +1 -0
- static/css/waveplayer.css +134 -0
- static/huggingface.svg +42 -0
- static/js/waveplayer.js +310 -0
- static/open.svg +1 -0
- static/twitter.svg +3 -0
- templates/about.html +415 -0
- templates/admin/activity.html +139 -0
- templates/admin/base.html +539 -0
- templates/admin/edit_model.html +62 -0
- templates/admin/index.html +213 -0
- templates/admin/models.html +79 -0
- templates/admin/statistics.html +173 -0
- templates/admin/user_detail.html +145 -0
- templates/admin/users.html +47 -0
- templates/admin/votes.html +77 -0
- templates/arena.html +1959 -0
- templates/base.html +1446 -0
- templates/email.html +174 -0
- templates/leaderboard.html +1413 -0
- templates/turnstile.html +277 -0
- tts.old.py +117 -0
- tts.py +245 -0
    	
        .env.example
    ADDED
    
    | @@ -0,0 +1,15 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            SECRET_KEY=your-secret-key-here
         | 
| 2 | 
            +
            OAUTH_CLIENT_ID=your-huggingface-client-id
         | 
| 3 | 
            +
            OAUTH_CLIENT_SECRET=your-huggingface-client-secret
         | 
| 4 | 
            +
            DATABASE_URI=sqlite:///tts_arena.db 
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            FAL_KEY=
         | 
| 7 | 
            +
            PLAY_USERID=
         | 
| 8 | 
            +
            PLAY_SECRETKEY=
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            TURNSTILE_ENABLED=
         | 
| 11 | 
            +
            TURNSTILE_SITE_KEY=
         | 
| 12 | 
            +
            TURNSTILE_SECRET_KEY=
         | 
| 13 | 
            +
            TURNSTILE_TIMEOUT_HOURS=24
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            HF_TOKEN=your-huggingface-token-here
         | 
    	
        .github/workflows/sync-to-hf.yaml
    ADDED
    
    | @@ -0,0 +1,43 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            name: Sync to Hugging Face Space
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            on:
         | 
| 4 | 
            +
              push:
         | 
| 5 | 
            +
                branches:
         | 
| 6 | 
            +
                  - main
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            jobs:
         | 
| 9 | 
            +
              sync-to-hf:
         | 
| 10 | 
            +
                runs-on: ubuntu-latest
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                steps:
         | 
| 13 | 
            +
                  - name: Checkout repository
         | 
| 14 | 
            +
                    uses: actions/checkout@v3
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  - name: Set up Git
         | 
| 17 | 
            +
                    run: |
         | 
| 18 | 
            +
                      git config --global user.email "[email protected]"
         | 
| 19 | 
            +
                      git config --global user.name "GitHub Actions"
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  - name: Push to Hugging Face Space
         | 
| 22 | 
            +
                    env:
         | 
| 23 | 
            +
                      HF_TOKEN: ${{ secrets.HF_TOKEN }}
         | 
| 24 | 
            +
                    run: |
         | 
| 25 | 
            +
                      # Replace these with your HF username and space name
         | 
| 26 | 
            +
                      HF_USERNAME="TTS-AGI"
         | 
| 27 | 
            +
                      SPACE_NAME="TTS-Arena-V2"
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                      # Clone the HF space repo
         | 
| 30 | 
            +
                      git clone https://$HF_USERNAME:[email protected]/spaces/$HF_USERNAME/$SPACE_NAME hf-space
         | 
| 31 | 
            +
                      
         | 
| 32 | 
            +
                      # Copy all files to the space repo (except .git and hf-space folder)
         | 
| 33 | 
            +
                      rsync -av --exclude='.git' --exclude='hf-space' ./ hf-space/
         | 
| 34 | 
            +
                      
         | 
| 35 | 
            +
                      # Rename SPACES_README.md to README.md for Hugging Face
         | 
| 36 | 
            +
                      if [ -f hf-space/SPACES_README.md ]; then
         | 
| 37 | 
            +
                        mv hf-space/SPACES_README.md hf-space/README.md
         | 
| 38 | 
            +
                      fi
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                      cd hf-space
         | 
| 41 | 
            +
                      git add .
         | 
| 42 | 
            +
                      git commit -m "Sync from GitHub repo" || echo "No changes to commit"
         | 
| 43 | 
            +
                      git push
         | 
    	
        .gitignore
    CHANGED
    
    | @@ -1,12 +1,8 @@ | |
| 1 | 
            -
            #  | 
| 2 | 
             
            __pycache__/
         | 
| 3 | 
             
            *.py[cod]
         | 
| 4 | 
             
            *$py.class
         | 
| 5 | 
            -
             | 
| 6 | 
            -
            # C extensions
         | 
| 7 | 
             
            *.so
         | 
| 8 | 
            -
             | 
| 9 | 
            -
            # Distribution / packaging
         | 
| 10 | 
             
            .Python
         | 
| 11 | 
             
            build/
         | 
| 12 | 
             
            develop-eggs/
         | 
| @@ -20,114 +16,11 @@ parts/ | |
| 20 | 
             
            sdist/
         | 
| 21 | 
             
            var/
         | 
| 22 | 
             
            wheels/
         | 
| 23 | 
            -
            share/python-wheels/
         | 
| 24 | 
             
            *.egg-info/
         | 
| 25 | 
             
            .installed.cfg
         | 
| 26 | 
             
            *.egg
         | 
| 27 | 
            -
            MANIFEST
         | 
| 28 | 
            -
             | 
| 29 | 
            -
            # PyInstaller
         | 
| 30 | 
            -
            #  Usually these files are written by a python script from a template
         | 
| 31 | 
            -
            #  before PyInstaller builds the exe, so as to inject date/other infos into it.
         | 
| 32 | 
            -
            *.manifest
         | 
| 33 | 
            -
            *.spec
         | 
| 34 | 
            -
             | 
| 35 | 
            -
            # Installer logs
         | 
| 36 | 
            -
            pip-log.txt
         | 
| 37 | 
            -
            pip-delete-this-directory.txt
         | 
| 38 | 
            -
             | 
| 39 | 
            -
            # Unit test / coverage reports
         | 
| 40 | 
            -
            htmlcov/
         | 
| 41 | 
            -
            .tox/
         | 
| 42 | 
            -
            .nox/
         | 
| 43 | 
            -
            .coverage
         | 
| 44 | 
            -
            .coverage.*
         | 
| 45 | 
            -
            .cache
         | 
| 46 | 
            -
            nosetests.xml
         | 
| 47 | 
            -
            coverage.xml
         | 
| 48 | 
            -
            *.cover
         | 
| 49 | 
            -
            *.py,cover
         | 
| 50 | 
            -
            .hypothesis/
         | 
| 51 | 
            -
            .pytest_cache/
         | 
| 52 | 
            -
            cover/
         | 
| 53 | 
            -
             | 
| 54 | 
            -
            # Translations
         | 
| 55 | 
            -
            *.mo
         | 
| 56 | 
            -
            *.pot
         | 
| 57 | 
            -
             | 
| 58 | 
            -
            # Django stuff:
         | 
| 59 | 
            -
            *.log
         | 
| 60 | 
            -
            local_settings.py
         | 
| 61 | 
            -
            db.sqlite3
         | 
| 62 | 
            -
            db.sqlite3-journal
         | 
| 63 | 
            -
             | 
| 64 | 
            -
            # Flask stuff:
         | 
| 65 | 
            -
            instance/
         | 
| 66 | 
            -
            .webassets-cache
         | 
| 67 | 
            -
             | 
| 68 | 
            -
            # Scrapy stuff:
         | 
| 69 | 
            -
            .scrapy
         | 
| 70 | 
            -
             | 
| 71 | 
            -
            # Sphinx documentation
         | 
| 72 | 
            -
            docs/_build/
         | 
| 73 | 
            -
             | 
| 74 | 
            -
            # PyBuilder
         | 
| 75 | 
            -
            .pybuilder/
         | 
| 76 | 
            -
            target/
         | 
| 77 | 
            -
             | 
| 78 | 
            -
            # Jupyter Notebook
         | 
| 79 | 
            -
            .ipynb_checkpoints
         | 
| 80 | 
            -
             | 
| 81 | 
            -
            # IPython
         | 
| 82 | 
            -
            profile_default/
         | 
| 83 | 
            -
            ipython_config.py
         | 
| 84 | 
            -
             | 
| 85 | 
            -
            # pyenv
         | 
| 86 | 
            -
            #   For a library or package, you might want to ignore these files since the code is
         | 
| 87 | 
            -
            #   intended to run in multiple environments; otherwise, check them in:
         | 
| 88 | 
            -
            # .python-version
         | 
| 89 | 
            -
             | 
| 90 | 
            -
            # pipenv
         | 
| 91 | 
            -
            #   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
         | 
| 92 | 
            -
            #   However, in case of collaboration, if having platform-specific dependencies or dependencies
         | 
| 93 | 
            -
            #   having no cross-platform support, pipenv may install dependencies that don't work, or not
         | 
| 94 | 
            -
            #   install all needed dependencies.
         | 
| 95 | 
            -
            #Pipfile.lock
         | 
| 96 | 
            -
             | 
| 97 | 
            -
            # UV
         | 
| 98 | 
            -
            #   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
         | 
| 99 | 
            -
            #   This is especially recommended for binary packages to ensure reproducibility, and is more
         | 
| 100 | 
            -
            #   commonly ignored for libraries.
         | 
| 101 | 
            -
            #uv.lock
         | 
| 102 |  | 
| 103 | 
            -
            #  | 
| 104 | 
            -
            #   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
         | 
| 105 | 
            -
            #   This is especially recommended for binary packages to ensure reproducibility, and is more
         | 
| 106 | 
            -
            #   commonly ignored for libraries.
         | 
| 107 | 
            -
            #   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
         | 
| 108 | 
            -
            #poetry.lock
         | 
| 109 | 
            -
             | 
| 110 | 
            -
            # pdm
         | 
| 111 | 
            -
            #   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
         | 
| 112 | 
            -
            #pdm.lock
         | 
| 113 | 
            -
            #   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
         | 
| 114 | 
            -
            #   in version control.
         | 
| 115 | 
            -
            #   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
         | 
| 116 | 
            -
            .pdm.toml
         | 
| 117 | 
            -
            .pdm-python
         | 
| 118 | 
            -
            .pdm-build/
         | 
| 119 | 
            -
             | 
| 120 | 
            -
            # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
         | 
| 121 | 
            -
            __pypackages__/
         | 
| 122 | 
            -
             | 
| 123 | 
            -
            # Celery stuff
         | 
| 124 | 
            -
            celerybeat-schedule
         | 
| 125 | 
            -
            celerybeat.pid
         | 
| 126 | 
            -
             | 
| 127 | 
            -
            # SageMath parsed files
         | 
| 128 | 
            -
            *.sage.py
         | 
| 129 | 
            -
             | 
| 130 | 
            -
            # Environments
         | 
| 131 | 
             
            .env
         | 
| 132 | 
             
            .venv
         | 
| 133 | 
             
            env/
         | 
| @@ -135,42 +28,23 @@ venv/ | |
| 135 | 
             
            ENV/
         | 
| 136 | 
             
            env.bak/
         | 
| 137 | 
             
            venv.bak/
         | 
|  | |
| 138 |  | 
| 139 | 
            -
            #  | 
| 140 | 
            -
             | 
| 141 | 
            -
             | 
| 142 | 
            -
             | 
| 143 | 
            -
             | 
| 144 | 
            -
             | 
| 145 | 
            -
             | 
| 146 | 
            -
             | 
| 147 | 
            -
            / | 
| 148 | 
            -
             | 
| 149 | 
            -
             | 
| 150 | 
            -
             | 
| 151 | 
            -
             | 
| 152 | 
            -
             | 
| 153 | 
            -
             | 
| 154 | 
            -
             | 
| 155 | 
            -
             | 
| 156 | 
            -
             | 
| 157 | 
            -
            # pytype static type analyzer
         | 
| 158 | 
            -
            .pytype/
         | 
| 159 | 
            -
             | 
| 160 | 
            -
            # Cython debug symbols
         | 
| 161 | 
            -
            cython_debug/
         | 
| 162 | 
            -
             | 
| 163 | 
            -
            # PyCharm
         | 
| 164 | 
            -
            #  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
         | 
| 165 | 
            -
            #  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
         | 
| 166 | 
            -
            #  and can be added to the global gitignore or merged into this file.  For a more nuclear
         | 
| 167 | 
            -
            #  option (not recommended) you can uncomment the following to ignore the entire idea folder.
         | 
| 168 | 
            -
            #.idea/
         | 
| 169 | 
            -
             | 
| 170 | 
            -
            # Ruff stuff:
         | 
| 171 | 
            -
            .ruff_cache/
         | 
| 172 | 
            -
             | 
| 173 | 
            -
            # PyPI configuration file
         | 
| 174 | 
            -
            .pypirc
         | 
| 175 | 
            -
             | 
| 176 | 
            -
            *.db
         | 
|  | |
| 1 | 
            +
            # Python
         | 
| 2 | 
             
            __pycache__/
         | 
| 3 | 
             
            *.py[cod]
         | 
| 4 | 
             
            *$py.class
         | 
|  | |
|  | |
| 5 | 
             
            *.so
         | 
|  | |
|  | |
| 6 | 
             
            .Python
         | 
| 7 | 
             
            build/
         | 
| 8 | 
             
            develop-eggs/
         | 
|  | |
| 16 | 
             
            sdist/
         | 
| 17 | 
             
            var/
         | 
| 18 | 
             
            wheels/
         | 
|  | |
| 19 | 
             
            *.egg-info/
         | 
| 20 | 
             
            .installed.cfg
         | 
| 21 | 
             
            *.egg
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 22 |  | 
| 23 | 
            +
            # Environment and local development
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 24 | 
             
            .env
         | 
| 25 | 
             
            .venv
         | 
| 26 | 
             
            env/
         | 
|  | |
| 28 | 
             
            ENV/
         | 
| 29 | 
             
            env.bak/
         | 
| 30 | 
             
            venv.bak/
         | 
| 31 | 
            +
            .flaskenv
         | 
| 32 |  | 
| 33 | 
            +
            # Database
         | 
| 34 | 
            +
            instance/
         | 
| 35 | 
            +
            *.db
         | 
| 36 | 
            +
            *.sqlite
         | 
| 37 | 
            +
            *.sqlite3
         | 
| 38 | 
            +
             | 
| 39 | 
            +
            # IDE
         | 
| 40 | 
            +
            .idea/
         | 
| 41 | 
            +
            .vscode/
         | 
| 42 | 
            +
            *.swp
         | 
| 43 | 
            +
            *.swo
         | 
| 44 | 
            +
             | 
| 45 | 
            +
            # OS
         | 
| 46 | 
            +
            .DS_Store
         | 
| 47 | 
            +
            Thumbs.db 
         | 
| 48 | 
            +
             | 
| 49 | 
            +
            # Uploads
         | 
| 50 | 
            +
            static/temp_audio
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
    	
        COPYING
    ADDED
    
    | @@ -0,0 +1,15 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            This project is dual-licensed under the MIT license and the Apache 2.0 license. See the LICENSE.MIT and LICENSE.APACHE files respectively for details.
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            Copyright 2025 mrfakename
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            Licensed under the Apache License, Version 2.0 (the "License");
         | 
| 6 | 
            +
            you may not use this file except in compliance with the License.
         | 
| 7 | 
            +
            You may obtain a copy of the License at
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                http://www.apache.org/licenses/LICENSE-2.0
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            Unless required by applicable law or agreed to in writing, software
         | 
| 12 | 
            +
            distributed under the License is distributed on an "AS IS" BASIS,
         | 
| 13 | 
            +
            WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
         | 
| 14 | 
            +
            See the License for the specific language governing permissions and
         | 
| 15 | 
            +
            limitations under the License.
         | 
    	
        LICENSE.APACHE
    ADDED
    
    | @@ -0,0 +1,201 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
                                             Apache License
         | 
| 2 | 
            +
                                       Version 2.0, January 2004
         | 
| 3 | 
            +
                                    http://www.apache.org/licenses/
         | 
| 4 | 
            +
             | 
| 5 | 
            +
               TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
         | 
| 6 | 
            +
             | 
| 7 | 
            +
               1. Definitions.
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                  "License" shall mean the terms and conditions for use, reproduction,
         | 
| 10 | 
            +
                  and distribution as defined by Sections 1 through 9 of this document.
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  "Licensor" shall mean the copyright owner or entity authorized by
         | 
| 13 | 
            +
                  the copyright owner that is granting the License.
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                  "Legal Entity" shall mean the union of the acting entity and all
         | 
| 16 | 
            +
                  other entities that control, are controlled by, or are under common
         | 
| 17 | 
            +
                  control with that entity. For the purposes of this definition,
         | 
| 18 | 
            +
                  "control" means (i) the power, direct or indirect, to cause the
         | 
| 19 | 
            +
                  direction or management of such entity, whether by contract or
         | 
| 20 | 
            +
                  otherwise, or (ii) ownership of fifty percent (50%) or more of the
         | 
| 21 | 
            +
                  outstanding shares, or (iii) beneficial ownership of such entity.
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  "You" (or "Your") shall mean an individual or Legal Entity
         | 
| 24 | 
            +
                  exercising permissions granted by this License.
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                  "Source" form shall mean the preferred form for making modifications,
         | 
| 27 | 
            +
                  including but not limited to software source code, documentation
         | 
| 28 | 
            +
                  source, and configuration files.
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                  "Object" form shall mean any form resulting from mechanical
         | 
| 31 | 
            +
                  transformation or translation of a Source form, including but
         | 
| 32 | 
            +
                  not limited to compiled object code, generated documentation,
         | 
| 33 | 
            +
                  and conversions to other media types.
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                  "Work" shall mean the work of authorship, whether in Source or
         | 
| 36 | 
            +
                  Object form, made available under the License, as indicated by a
         | 
| 37 | 
            +
                  copyright notice that is included in or attached to the work
         | 
| 38 | 
            +
                  (an example is provided in the Appendix below).
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                  "Derivative Works" shall mean any work, whether in Source or Object
         | 
| 41 | 
            +
                  form, that is based on (or derived from) the Work and for which the
         | 
| 42 | 
            +
                  editorial revisions, annotations, elaborations, or other modifications
         | 
| 43 | 
            +
                  represent, as a whole, an original work of authorship. For the purposes
         | 
| 44 | 
            +
                  of this License, Derivative Works shall not include works that remain
         | 
| 45 | 
            +
                  separable from, or merely link (or bind by name) to the interfaces of,
         | 
| 46 | 
            +
                  the Work and Derivative Works thereof.
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                  "Contribution" shall mean any work of authorship, including
         | 
| 49 | 
            +
                  the original version of the Work and any modifications or additions
         | 
| 50 | 
            +
                  to that Work or Derivative Works thereof, that is intentionally
         | 
| 51 | 
            +
                  submitted to Licensor for inclusion in the Work by the copyright owner
         | 
| 52 | 
            +
                  or by an individual or Legal Entity authorized to submit on behalf of
         | 
| 53 | 
            +
                  the copyright owner. For the purposes of this definition, "submitted"
         | 
| 54 | 
            +
                  means any form of electronic, verbal, or written communication sent
         | 
| 55 | 
            +
                  to the Licensor or its representatives, including but not limited to
         | 
| 56 | 
            +
                  communication on electronic mailing lists, source code control systems,
         | 
| 57 | 
            +
                  and issue tracking systems that are managed by, or on behalf of, the
         | 
| 58 | 
            +
                  Licensor for the purpose of discussing and improving the Work, but
         | 
| 59 | 
            +
                  excluding communication that is conspicuously marked or otherwise
         | 
| 60 | 
            +
                  designated in writing by the copyright owner as "Not a Contribution."
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                  "Contributor" shall mean Licensor and any individual or Legal Entity
         | 
| 63 | 
            +
                  on behalf of whom a Contribution has been received by Licensor and
         | 
| 64 | 
            +
                  subsequently incorporated within the Work.
         | 
| 65 | 
            +
             | 
| 66 | 
            +
               2. Grant of Copyright License. Subject to the terms and conditions of
         | 
| 67 | 
            +
                  this License, each Contributor hereby grants to You a perpetual,
         | 
| 68 | 
            +
                  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
         | 
| 69 | 
            +
                  copyright license to reproduce, prepare Derivative Works of,
         | 
| 70 | 
            +
                  publicly display, publicly perform, sublicense, and distribute the
         | 
| 71 | 
            +
                  Work and such Derivative Works in Source or Object form.
         | 
| 72 | 
            +
             | 
| 73 | 
            +
               3. Grant of Patent License. Subject to the terms and conditions of
         | 
| 74 | 
            +
                  this License, each Contributor hereby grants to You a perpetual,
         | 
| 75 | 
            +
                  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
         | 
| 76 | 
            +
                  (except as stated in this section) patent license to make, have made,
         | 
| 77 | 
            +
                  use, offer to sell, sell, import, and otherwise transfer the Work,
         | 
| 78 | 
            +
                  where such license applies only to those patent claims licensable
         | 
| 79 | 
            +
                  by such Contributor that are necessarily infringed by their
         | 
| 80 | 
            +
                  Contribution(s) alone or by combination of their Contribution(s)
         | 
| 81 | 
            +
                  with the Work to which such Contribution(s) was submitted. If You
         | 
| 82 | 
            +
                  institute patent litigation against any entity (including a
         | 
| 83 | 
            +
                  cross-claim or counterclaim in a lawsuit) alleging that the Work
         | 
| 84 | 
            +
                  or a Contribution incorporated within the Work constitutes direct
         | 
| 85 | 
            +
                  or contributory patent infringement, then any patent licenses
         | 
| 86 | 
            +
                  granted to You under this License for that Work shall terminate
         | 
| 87 | 
            +
                  as of the date such litigation is filed.
         | 
| 88 | 
            +
             | 
| 89 | 
            +
               4. Redistribution. You may reproduce and distribute copies of the
         | 
| 90 | 
            +
                  Work or Derivative Works thereof in any medium, with or without
         | 
| 91 | 
            +
                  modifications, and in Source or Object form, provided that You
         | 
| 92 | 
            +
                  meet the following conditions:
         | 
| 93 | 
            +
             | 
| 94 | 
            +
                  (a) You must give any other recipients of the Work or
         | 
| 95 | 
            +
                      Derivative Works a copy of this License; and
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                  (b) You must cause any modified files to carry prominent notices
         | 
| 98 | 
            +
                      stating that You changed the files; and
         | 
| 99 | 
            +
             | 
| 100 | 
            +
                  (c) You must retain, in the Source form of any Derivative Works
         | 
| 101 | 
            +
                      that You distribute, all copyright, patent, trademark, and
         | 
| 102 | 
            +
                      attribution notices from the Source form of the Work,
         | 
| 103 | 
            +
                      excluding those notices that do not pertain to any part of
         | 
| 104 | 
            +
                      the Derivative Works; and
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                  (d) If the Work includes a "NOTICE" text file as part of its
         | 
| 107 | 
            +
                      distribution, then any Derivative Works that You distribute must
         | 
| 108 | 
            +
                      include a readable copy of the attribution notices contained
         | 
| 109 | 
            +
                      within such NOTICE file, excluding those notices that do not
         | 
| 110 | 
            +
                      pertain to any part of the Derivative Works, in at least one
         | 
| 111 | 
            +
                      of the following places: within a NOTICE text file distributed
         | 
| 112 | 
            +
                      as part of the Derivative Works; within the Source form or
         | 
| 113 | 
            +
                      documentation, if provided along with the Derivative Works; or,
         | 
| 114 | 
            +
                      within a display generated by the Derivative Works, if and
         | 
| 115 | 
            +
                      wherever such third-party notices normally appear. The contents
         | 
| 116 | 
            +
                      of the NOTICE file are for informational purposes only and
         | 
| 117 | 
            +
                      do not modify the License. You may add Your own attribution
         | 
| 118 | 
            +
                      notices within Derivative Works that You distribute, alongside
         | 
| 119 | 
            +
                      or as an addendum to the NOTICE text from the Work, provided
         | 
| 120 | 
            +
                      that such additional attribution notices cannot be construed
         | 
| 121 | 
            +
                      as modifying the License.
         | 
| 122 | 
            +
             | 
| 123 | 
            +
                  You may add Your own copyright statement to Your modifications and
         | 
| 124 | 
            +
                  may provide additional or different license terms and conditions
         | 
| 125 | 
            +
                  for use, reproduction, or distribution of Your modifications, or
         | 
| 126 | 
            +
                  for any such Derivative Works as a whole, provided Your use,
         | 
| 127 | 
            +
                  reproduction, and distribution of the Work otherwise complies with
         | 
| 128 | 
            +
                  the conditions stated in this License.
         | 
| 129 | 
            +
             | 
| 130 | 
            +
               5. Submission of Contributions. Unless You explicitly state otherwise,
         | 
| 131 | 
            +
                  any Contribution intentionally submitted for inclusion in the Work
         | 
| 132 | 
            +
                  by You to the Licensor shall be under the terms and conditions of
         | 
| 133 | 
            +
                  this License, without any additional terms or conditions.
         | 
| 134 | 
            +
                  Notwithstanding the above, nothing herein shall supersede or modify
         | 
| 135 | 
            +
                  the terms of any separate license agreement you may have executed
         | 
| 136 | 
            +
                  with Licensor regarding such Contributions.
         | 
| 137 | 
            +
             | 
| 138 | 
            +
               6. Trademarks. This License does not grant permission to use the trade
         | 
| 139 | 
            +
                  names, trademarks, service marks, or product names of the Licensor,
         | 
| 140 | 
            +
                  except as required for reasonable and customary use in describing the
         | 
| 141 | 
            +
                  origin of the Work and reproducing the content of the NOTICE file.
         | 
| 142 | 
            +
             | 
| 143 | 
            +
               7. Disclaimer of Warranty. Unless required by applicable law or
         | 
| 144 | 
            +
                  agreed to in writing, Licensor provides the Work (and each
         | 
| 145 | 
            +
                  Contributor provides its Contributions) on an "AS IS" BASIS,
         | 
| 146 | 
            +
                  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
         | 
| 147 | 
            +
                  implied, including, without limitation, any warranties or conditions
         | 
| 148 | 
            +
                  of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
         | 
| 149 | 
            +
                  PARTICULAR PURPOSE. You are solely responsible for determining the
         | 
| 150 | 
            +
                  appropriateness of using or redistributing the Work and assume any
         | 
| 151 | 
            +
                  risks associated with Your exercise of permissions under this License.
         | 
| 152 | 
            +
             | 
| 153 | 
            +
               8. Limitation of Liability. In no event and under no legal theory,
         | 
| 154 | 
            +
                  whether in tort (including negligence), contract, or otherwise,
         | 
| 155 | 
            +
                  unless required by applicable law (such as deliberate and grossly
         | 
| 156 | 
            +
                  negligent acts) or agreed to in writing, shall any Contributor be
         | 
| 157 | 
            +
                  liable to You for damages, including any direct, indirect, special,
         | 
| 158 | 
            +
                  incidental, or consequential damages of any character arising as a
         | 
| 159 | 
            +
                  result of this License or out of the use or inability to use the
         | 
| 160 | 
            +
                  Work (including but not limited to damages for loss of goodwill,
         | 
| 161 | 
            +
                  work stoppage, computer failure or malfunction, or any and all
         | 
| 162 | 
            +
                  other commercial damages or losses), even if such Contributor
         | 
| 163 | 
            +
                  has been advised of the possibility of such damages.
         | 
| 164 | 
            +
             | 
| 165 | 
            +
               9. Accepting Warranty or Additional Liability. While redistributing
         | 
| 166 | 
            +
                  the Work or Derivative Works thereof, You may choose to offer,
         | 
| 167 | 
            +
                  and charge a fee for, acceptance of support, warranty, indemnity,
         | 
| 168 | 
            +
                  or other liability obligations and/or rights consistent with this
         | 
| 169 | 
            +
                  License. However, in accepting such obligations, You may act only
         | 
| 170 | 
            +
                  on Your own behalf and on Your sole responsibility, not on behalf
         | 
| 171 | 
            +
                  of any other Contributor, and only if You agree to indemnify,
         | 
| 172 | 
            +
                  defend, and hold each Contributor harmless for any liability
         | 
| 173 | 
            +
                  incurred by, or claims asserted against, such Contributor by reason
         | 
| 174 | 
            +
                  of your accepting any such warranty or additional liability.
         | 
| 175 | 
            +
             | 
| 176 | 
            +
               END OF TERMS AND CONDITIONS
         | 
| 177 | 
            +
             | 
| 178 | 
            +
               APPENDIX: How to apply the Apache License to your work.
         | 
| 179 | 
            +
             | 
| 180 | 
            +
                  To apply the Apache License to your work, attach the following
         | 
| 181 | 
            +
                  boilerplate notice, with the fields enclosed by brackets "[]"
         | 
| 182 | 
            +
                  replaced with your own identifying information. (Don't include
         | 
| 183 | 
            +
                  the brackets!)  The text should be enclosed in the appropriate
         | 
| 184 | 
            +
                  comment syntax for the file format. We also recommend that a
         | 
| 185 | 
            +
                  file or class name and description of purpose be included on the
         | 
| 186 | 
            +
                  same "printed page" as the copyright notice for easier
         | 
| 187 | 
            +
                  identification within third-party archives.
         | 
| 188 | 
            +
             | 
| 189 | 
            +
               Copyright [yyyy] [name of copyright owner]
         | 
| 190 | 
            +
             | 
| 191 | 
            +
               Licensed under the Apache License, Version 2.0 (the "License");
         | 
| 192 | 
            +
               you may not use this file except in compliance with the License.
         | 
| 193 | 
            +
               You may obtain a copy of the License at
         | 
| 194 | 
            +
             | 
| 195 | 
            +
                   http://www.apache.org/licenses/LICENSE-2.0
         | 
| 196 | 
            +
             | 
| 197 | 
            +
               Unless required by applicable law or agreed to in writing, software
         | 
| 198 | 
            +
               distributed under the License is distributed on an "AS IS" BASIS,
         | 
| 199 | 
            +
               WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
         | 
| 200 | 
            +
               See the License for the specific language governing permissions and
         | 
| 201 | 
            +
               limitations under the License.
         | 
    	
        LICENSE.MIT
    ADDED
    
    | @@ -0,0 +1,21 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            MIT License
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            Copyright (c) 2025 mrfakename
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            Permission is hereby granted, free of charge, to any person obtaining a copy
         | 
| 6 | 
            +
            of this software and associated documentation files (the "Software"), to deal
         | 
| 7 | 
            +
            in the Software without restriction, including without limitation the rights
         | 
| 8 | 
            +
            to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
         | 
| 9 | 
            +
            copies of the Software, and to permit persons to whom the Software is
         | 
| 10 | 
            +
            furnished to do so, subject to the following conditions:
         | 
| 11 | 
            +
             | 
| 12 | 
            +
            The above copyright notice and this permission notice shall be included in all
         | 
| 13 | 
            +
            copies or substantial portions of the Software.
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
         | 
| 16 | 
            +
            IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
         | 
| 17 | 
            +
            FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
         | 
| 18 | 
            +
            AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
         | 
| 19 | 
            +
            LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
         | 
| 20 | 
            +
            OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
         | 
| 21 | 
            +
            SOFTWARE.
         | 
    	
        README.md
    CHANGED
    
    | @@ -1,29 +1,15 @@ | |
| 1 | 
             
            ---
         | 
| 2 | 
            -
            title: TTS Arena
         | 
| 3 | 
            -
            sdk: gradio
         | 
| 4 | 
            -
            app_file: app.py
         | 
| 5 | 
            -
            license: zlib
         | 
| 6 | 
            -
            tags:
         | 
| 7 | 
            -
            - arena
         | 
| 8 | 
             
            emoji: 🏆
         | 
| 9 | 
             
            colorFrom: blue
         | 
| 10 | 
             
            colorTo: blue
         | 
| 11 | 
            -
             | 
| 12 | 
            -
             | 
| 13 | 
            -
             | 
| 14 | 
            -
            short_description: Vote on the latest TTS models!
         | 
| 15 | 
            -
            ---
         | 
| 16 | 
            -
             | 
| 17 | 
            -
            # TTS Arena
         | 
| 18 | 
            -
             | 
| 19 | 
            -
            The codebase for TTS Arena v2.
         | 
| 20 | 
            -
             | 
| 21 | 
            -
            The TTS Arena is a Gradio app with several components. Please refer to the `app` directory for more information.
         | 
| 22 |  | 
| 23 | 
            -
             | 
|  | |
| 24 |  | 
| 25 | 
            -
             | 
| 26 | 
            -
            RUNNING_LOCALLY=1 python app.py
         | 
| 27 | 
            -
            ```
         | 
| 28 |  | 
| 29 | 
            -
             | 
|  | |
| 1 | 
             
            ---
         | 
| 2 | 
            +
            title: TTS Arena V2 (Beta)
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
| 3 | 
             
            emoji: 🏆
         | 
| 4 | 
             
            colorFrom: blue
         | 
| 5 | 
             
            colorTo: blue
         | 
| 6 | 
            +
            sdk: gradio
         | 
| 7 | 
            +
            app_file: app.py
         | 
| 8 | 
            +
            short_description: (Private) Vote on the latest TTS models!
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 9 |  | 
| 10 | 
            +
            hf_oauth: true
         | 
| 11 | 
            +
            ---
         | 
| 12 |  | 
| 13 | 
            +
            Please see the [GitHub repo](https://github.com/TTS-AGI/TTS-Arena-V2) for information.
         | 
|  | |
|  | |
| 14 |  | 
| 15 | 
            +
            Join the [Discord server](https://discord.gg/HB8fMR6GTr) for updates and support.
         | 
    	
        TURNSTILE.md
    ADDED
    
    | @@ -0,0 +1,58 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            # Cloudflare Turnstile Integration
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            TTS Arena supports Cloudflare Turnstile for bot protection. This guide explains how to set up and configure Turnstile for your deployment.
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            ## What is Cloudflare Turnstile?
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            Cloudflare Turnstile is a CAPTCHA alternative that provides protection against bots and malicious traffic while maintaining a user-friendly experience. Unlike traditional CAPTCHAs, Turnstile uses a variety of signals to detect bots without forcing legitimate users to solve frustrating puzzles.
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            ## Setup Instructions
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            ### 1. Register for Cloudflare Turnstile
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            1. Create a Cloudflare account or log in to your existing account
         | 
| 14 | 
            +
            2. Go to the [Turnstile dashboard](https://dash.cloudflare.com/?to=/:account/turnstile)
         | 
| 15 | 
            +
            3. Click "Add Site" and follow the instructions
         | 
| 16 | 
            +
            4. Create a new site key
         | 
| 17 | 
            +
               - Choose "Managed" or "Invisible" mode (Managed is recommended for better balance of security and user experience)
         | 
| 18 | 
            +
               - Set an appropriate domain policy
         | 
| 19 | 
            +
               - Create the site key
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            Once created, you'll receive a **Site Key** (public) and **Secret Key** (private).
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            ### 2. Configure Environment Variables
         | 
| 24 | 
            +
             | 
| 25 | 
            +
            Add the following environment variables to your deployment:
         | 
| 26 | 
            +
             | 
| 27 | 
            +
            ```
         | 
| 28 | 
            +
            TURNSTILE_ENABLED=true
         | 
| 29 | 
            +
            TURNSTILE_SITE_KEY=your_site_key_here
         | 
| 30 | 
            +
            TURNSTILE_SECRET_KEY=your_secret_key_here
         | 
| 31 | 
            +
            TURNSTILE_TIMEOUT_HOURS=24
         | 
| 32 | 
            +
            ```
         | 
| 33 | 
            +
             | 
| 34 | 
            +
            | Variable | Description |
         | 
| 35 | 
            +
            |----------|-------------|
         | 
| 36 | 
            +
            | `TURNSTILE_ENABLED` | Set to `true` to enable Turnstile protection |
         | 
| 37 | 
            +
            | `TURNSTILE_SITE_KEY` | Your Cloudflare Turnstile site key |
         | 
| 38 | 
            +
            | `TURNSTILE_SECRET_KEY` | Your Cloudflare Turnstile secret key |
         | 
| 39 | 
            +
            | `TURNSTILE_TIMEOUT_HOURS` | How often users need to verify (default: 24 hours) |
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            ### 3. Implementation Details
         | 
| 42 | 
            +
             | 
| 43 | 
            +
            When Turnstile is enabled:
         | 
| 44 | 
            +
            - All routes and API endpoints require Turnstile verification
         | 
| 45 | 
            +
            - Users are redirected to a verification page when they first visit
         | 
| 46 | 
            +
            - Verification status is stored in the session
         | 
| 47 | 
            +
            - Re-verification is required after the timeout period
         | 
| 48 | 
            +
            - API requests receive a 403 error if not verified
         | 
| 49 | 
            +
             | 
| 50 | 
            +
            ## Customization
         | 
| 51 | 
            +
             | 
| 52 | 
            +
            The Turnstile verification page uses the same styling as the main application, providing a seamless user experience. You can customize the appearance by modifying `templates/turnstile.html`.
         | 
| 53 | 
            +
             | 
| 54 | 
            +
            ## Troubleshooting
         | 
| 55 | 
            +
             | 
| 56 | 
            +
            - **Verification Loops**: If users get stuck in verification loops, check that cookies are being properly stored (ensure proper cookie settings and no browser extensions blocking cookies)
         | 
| 57 | 
            +
            - **API Errors**: If API clients receive 403 errors, they need to implement Turnstile verification
         | 
| 58 | 
            +
            - **Missing Environment Variables**: Ensure all required environment variables are set correctly 
         | 
    	
        admin.py
    ADDED
    
    | @@ -0,0 +1,401 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            from flask import Blueprint, render_template, current_app, jsonify, request, redirect, url_for, flash
         | 
| 2 | 
            +
            from models import db, User, Model, Vote, EloHistory, ModelType
         | 
| 3 | 
            +
            from auth import admin_required
         | 
| 4 | 
            +
            from sqlalchemy import func, desc, extract
         | 
| 5 | 
            +
            from datetime import datetime, timedelta
         | 
| 6 | 
            +
            import json
         | 
| 7 | 
            +
            import os
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            admin = Blueprint("admin", __name__, url_prefix="/admin")
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            @admin.route("/")
         | 
| 12 | 
            +
            @admin_required
         | 
| 13 | 
            +
            def index():
         | 
| 14 | 
            +
                """Admin dashboard homepage"""
         | 
| 15 | 
            +
                # Get count statistics
         | 
| 16 | 
            +
                stats = {
         | 
| 17 | 
            +
                    "total_users": User.query.count(),
         | 
| 18 | 
            +
                    "total_votes": Vote.query.count(),
         | 
| 19 | 
            +
                    "tts_votes": Vote.query.filter_by(model_type=ModelType.TTS).count(),
         | 
| 20 | 
            +
                    "conversational_votes": Vote.query.filter_by(model_type=ModelType.CONVERSATIONAL).count(),
         | 
| 21 | 
            +
                    "tts_models": Model.query.filter_by(model_type=ModelType.TTS).count(),
         | 
| 22 | 
            +
                    "conversational_models": Model.query.filter_by(model_type=ModelType.CONVERSATIONAL).count(),
         | 
| 23 | 
            +
                }
         | 
| 24 | 
            +
                
         | 
| 25 | 
            +
                # Get recent votes
         | 
| 26 | 
            +
                recent_votes = Vote.query.order_by(Vote.vote_date.desc()).limit(10).all()
         | 
| 27 | 
            +
                
         | 
| 28 | 
            +
                # Get recent users
         | 
| 29 | 
            +
                recent_users = User.query.order_by(User.join_date.desc()).limit(10).all()
         | 
| 30 | 
            +
                
         | 
| 31 | 
            +
                # Get daily votes for the past 30 days
         | 
| 32 | 
            +
                thirty_days_ago = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
         | 
| 33 | 
            +
                
         | 
| 34 | 
            +
                daily_votes = db.session.query(
         | 
| 35 | 
            +
                    func.date(Vote.vote_date).label('date'),
         | 
| 36 | 
            +
                    func.count().label('count')
         | 
| 37 | 
            +
                ).filter(Vote.vote_date >= thirty_days_ago).group_by(
         | 
| 38 | 
            +
                    func.date(Vote.vote_date)
         | 
| 39 | 
            +
                ).order_by(func.date(Vote.vote_date)).all()
         | 
| 40 | 
            +
                
         | 
| 41 | 
            +
                # Generate a complete list of dates for the past 30 days
         | 
| 42 | 
            +
                date_list = []
         | 
| 43 | 
            +
                current_date = datetime.utcnow()
         | 
| 44 | 
            +
                for i in range(30, -1, -1):
         | 
| 45 | 
            +
                    date_list.append((current_date - timedelta(days=i)).date())
         | 
| 46 | 
            +
                
         | 
| 47 | 
            +
                # Create a dictionary with actual vote counts
         | 
| 48 | 
            +
                vote_counts = {day.date: day.count for day in daily_votes}
         | 
| 49 | 
            +
                
         | 
| 50 | 
            +
                # Build complete datasets including days with zero votes
         | 
| 51 | 
            +
                formatted_dates = [date.strftime("%Y-%m-%d") for date in date_list]
         | 
| 52 | 
            +
                vote_counts_list = [vote_counts.get(date, 0) for date in date_list]
         | 
| 53 | 
            +
                
         | 
| 54 | 
            +
                daily_votes_data = {
         | 
| 55 | 
            +
                    "labels": formatted_dates,
         | 
| 56 | 
            +
                    "counts": vote_counts_list
         | 
| 57 | 
            +
                }
         | 
| 58 | 
            +
                
         | 
| 59 | 
            +
                # Get top models
         | 
| 60 | 
            +
                top_tts_models = Model.query.filter_by(
         | 
| 61 | 
            +
                    model_type=ModelType.TTS
         | 
| 62 | 
            +
                ).order_by(Model.current_elo.desc()).limit(5).all()
         | 
| 63 | 
            +
                
         | 
| 64 | 
            +
                top_conversational_models = Model.query.filter_by(
         | 
| 65 | 
            +
                    model_type=ModelType.CONVERSATIONAL
         | 
| 66 | 
            +
                ).order_by(Model.current_elo.desc()).limit(5).all()
         | 
| 67 | 
            +
                
         | 
| 68 | 
            +
                return render_template(
         | 
| 69 | 
            +
                    "admin/index.html",
         | 
| 70 | 
            +
                    stats=stats,
         | 
| 71 | 
            +
                    recent_votes=recent_votes,
         | 
| 72 | 
            +
                    recent_users=recent_users,
         | 
| 73 | 
            +
                    daily_votes_data=json.dumps(daily_votes_data),
         | 
| 74 | 
            +
                    top_tts_models=top_tts_models,
         | 
| 75 | 
            +
                    top_conversational_models=top_conversational_models
         | 
| 76 | 
            +
                )
         | 
| 77 | 
            +
             | 
| 78 | 
            +
            @admin.route("/models")
         | 
| 79 | 
            +
            @admin_required
         | 
| 80 | 
            +
            def models():
         | 
| 81 | 
            +
                """Manage models"""
         | 
| 82 | 
            +
                tts_models = Model.query.filter_by(model_type=ModelType.TTS).order_by(Model.name).all()
         | 
| 83 | 
            +
                conversational_models = Model.query.filter_by(model_type=ModelType.CONVERSATIONAL).order_by(Model.name).all()
         | 
| 84 | 
            +
                
         | 
| 85 | 
            +
                return render_template(
         | 
| 86 | 
            +
                    "admin/models.html",
         | 
| 87 | 
            +
                    tts_models=tts_models,
         | 
| 88 | 
            +
                    conversational_models=conversational_models
         | 
| 89 | 
            +
                )
         | 
| 90 | 
            +
             | 
| 91 | 
            +
             | 
| 92 | 
            +
            @admin.route("/model/<model_id>", methods=["GET", "POST"])
         | 
| 93 | 
            +
            @admin_required
         | 
| 94 | 
            +
            def edit_model(model_id):
         | 
| 95 | 
            +
                """Edit a model"""
         | 
| 96 | 
            +
                model = Model.query.get_or_404(model_id)
         | 
| 97 | 
            +
                
         | 
| 98 | 
            +
                if request.method == "POST":
         | 
| 99 | 
            +
                    model.name = request.form.get("name")
         | 
| 100 | 
            +
                    model.is_active = "is_active" in request.form
         | 
| 101 | 
            +
                    model.is_open = "is_open" in request.form
         | 
| 102 | 
            +
                    model.model_url = request.form.get("model_url")
         | 
| 103 | 
            +
                    
         | 
| 104 | 
            +
                    db.session.commit()
         | 
| 105 | 
            +
                    flash(f"Model '{model.name}' updated successfully", "success")
         | 
| 106 | 
            +
                    return redirect(url_for("admin.models"))
         | 
| 107 | 
            +
                
         | 
| 108 | 
            +
                return render_template("admin/edit_model.html", model=model)
         | 
| 109 | 
            +
             | 
| 110 | 
            +
            @admin.route("/users")
         | 
| 111 | 
            +
            @admin_required
         | 
| 112 | 
            +
            def users():
         | 
| 113 | 
            +
                """Manage users"""
         | 
| 114 | 
            +
                users = User.query.order_by(User.username).all()
         | 
| 115 | 
            +
                admin_users = os.getenv("ADMIN_USERS", "").split(",")
         | 
| 116 | 
            +
                admin_users = [username.strip() for username in admin_users]
         | 
| 117 | 
            +
                
         | 
| 118 | 
            +
                return render_template("admin/users.html", users=users, admin_users=admin_users)
         | 
| 119 | 
            +
             | 
| 120 | 
            +
            @admin.route("/user/<int:user_id>")
         | 
| 121 | 
            +
            @admin_required
         | 
| 122 | 
            +
            def user_detail(user_id):
         | 
| 123 | 
            +
                """View user details"""
         | 
| 124 | 
            +
                user = User.query.get_or_404(user_id)
         | 
| 125 | 
            +
                
         | 
| 126 | 
            +
                # Get user votes
         | 
| 127 | 
            +
                recent_votes = Vote.query.filter_by(user_id=user_id).order_by(Vote.vote_date.desc()).limit(20).all()
         | 
| 128 | 
            +
                
         | 
| 129 | 
            +
                # Get vote statistics
         | 
| 130 | 
            +
                tts_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.TTS).count()
         | 
| 131 | 
            +
                conversational_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.CONVERSATIONAL).count()
         | 
| 132 | 
            +
                
         | 
| 133 | 
            +
                # Get favorite models (most chosen)
         | 
| 134 | 
            +
                favorite_models = db.session.query(
         | 
| 135 | 
            +
                    Vote.model_chosen,
         | 
| 136 | 
            +
                    Model.name,
         | 
| 137 | 
            +
                    func.count().label('count')
         | 
| 138 | 
            +
                ).join(
         | 
| 139 | 
            +
                    Model, Vote.model_chosen == Model.id
         | 
| 140 | 
            +
                ).filter(
         | 
| 141 | 
            +
                    Vote.user_id == user_id
         | 
| 142 | 
            +
                ).group_by(
         | 
| 143 | 
            +
                    Vote.model_chosen, Model.name
         | 
| 144 | 
            +
                ).order_by(
         | 
| 145 | 
            +
                    desc('count')
         | 
| 146 | 
            +
                ).limit(5).all()
         | 
| 147 | 
            +
                
         | 
| 148 | 
            +
                return render_template(
         | 
| 149 | 
            +
                    "admin/user_detail.html",
         | 
| 150 | 
            +
                    user=user,
         | 
| 151 | 
            +
                    recent_votes=recent_votes,
         | 
| 152 | 
            +
                    tts_votes=tts_votes,
         | 
| 153 | 
            +
                    conversational_votes=conversational_votes,
         | 
| 154 | 
            +
                    favorite_models=favorite_models,
         | 
| 155 | 
            +
                    total_votes=tts_votes + conversational_votes
         | 
| 156 | 
            +
                )
         | 
| 157 | 
            +
             | 
| 158 | 
            +
            @admin.route("/votes")
         | 
| 159 | 
            +
            @admin_required
         | 
| 160 | 
            +
            def votes():
         | 
| 161 | 
            +
                """View recent votes"""
         | 
| 162 | 
            +
                page = request.args.get('page', 1, type=int)
         | 
| 163 | 
            +
                per_page = 50
         | 
| 164 | 
            +
                
         | 
| 165 | 
            +
                # Get votes with pagination
         | 
| 166 | 
            +
                votes_pagination = Vote.query.order_by(
         | 
| 167 | 
            +
                    Vote.vote_date.desc()
         | 
| 168 | 
            +
                ).paginate(page=page, per_page=per_page)
         | 
| 169 | 
            +
                
         | 
| 170 | 
            +
                return render_template(
         | 
| 171 | 
            +
                    "admin/votes.html",
         | 
| 172 | 
            +
                    votes=votes_pagination.items,
         | 
| 173 | 
            +
                    pagination=votes_pagination
         | 
| 174 | 
            +
                )
         | 
| 175 | 
            +
             | 
| 176 | 
            +
            @admin.route("/statistics")
         | 
| 177 | 
            +
            @admin_required
         | 
| 178 | 
            +
            def statistics():
         | 
| 179 | 
            +
                """View detailed statistics"""
         | 
| 180 | 
            +
                # Get daily votes for the past 30 days by model type
         | 
| 181 | 
            +
                thirty_days_ago = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
         | 
| 182 | 
            +
                
         | 
| 183 | 
            +
                tts_daily_votes = db.session.query(
         | 
| 184 | 
            +
                    func.date(Vote.vote_date).label('date'),
         | 
| 185 | 
            +
                    func.count().label('count')
         | 
| 186 | 
            +
                ).filter(
         | 
| 187 | 
            +
                    Vote.vote_date >= thirty_days_ago,
         | 
| 188 | 
            +
                    Vote.model_type == ModelType.TTS
         | 
| 189 | 
            +
                ).group_by(
         | 
| 190 | 
            +
                    func.date(Vote.vote_date)
         | 
| 191 | 
            +
                ).order_by(func.date(Vote.vote_date)).all()
         | 
| 192 | 
            +
                
         | 
| 193 | 
            +
                conv_daily_votes = db.session.query(
         | 
| 194 | 
            +
                    func.date(Vote.vote_date).label('date'),
         | 
| 195 | 
            +
                    func.count().label('count')
         | 
| 196 | 
            +
                ).filter(
         | 
| 197 | 
            +
                    Vote.vote_date >= thirty_days_ago,
         | 
| 198 | 
            +
                    Vote.model_type == ModelType.CONVERSATIONAL
         | 
| 199 | 
            +
                ).group_by(
         | 
| 200 | 
            +
                    func.date(Vote.vote_date)
         | 
| 201 | 
            +
                ).order_by(func.date(Vote.vote_date)).all()
         | 
| 202 | 
            +
                
         | 
| 203 | 
            +
                # Monthly new users
         | 
| 204 | 
            +
                monthly_users = db.session.query(
         | 
| 205 | 
            +
                    extract('year', User.join_date).label('year'),
         | 
| 206 | 
            +
                    extract('month', User.join_date).label('month'),
         | 
| 207 | 
            +
                    func.count().label('count')
         | 
| 208 | 
            +
                ).group_by(
         | 
| 209 | 
            +
                    'year', 'month'
         | 
| 210 | 
            +
                ).order_by('year', 'month').all()
         | 
| 211 | 
            +
                
         | 
| 212 | 
            +
                # Generate a complete list of dates for the past 30 days
         | 
| 213 | 
            +
                date_list = []
         | 
| 214 | 
            +
                current_date = datetime.utcnow()
         | 
| 215 | 
            +
                for i in range(30, -1, -1):
         | 
| 216 | 
            +
                    date_list.append((current_date - timedelta(days=i)).date())
         | 
| 217 | 
            +
                
         | 
| 218 | 
            +
                # Create dictionaries with actual vote counts
         | 
| 219 | 
            +
                tts_vote_counts = {day.date: day.count for day in tts_daily_votes}
         | 
| 220 | 
            +
                conv_vote_counts = {day.date: day.count for day in conv_daily_votes}
         | 
| 221 | 
            +
                
         | 
| 222 | 
            +
                # Format dates consistently for charts
         | 
| 223 | 
            +
                formatted_dates = [date.strftime("%Y-%m-%d") for date in date_list]
         | 
| 224 | 
            +
                
         | 
| 225 | 
            +
                # Build complete datasets including days with zero votes
         | 
| 226 | 
            +
                tts_counts = [tts_vote_counts.get(date, 0) for date in date_list]
         | 
| 227 | 
            +
                conv_counts = [conv_vote_counts.get(date, 0) for date in date_list]
         | 
| 228 | 
            +
                
         | 
| 229 | 
            +
                # Generate all month/year combinations for the past 12 months
         | 
| 230 | 
            +
                current_date = datetime.utcnow()
         | 
| 231 | 
            +
                month_list = []
         | 
| 232 | 
            +
                for i in range(11, -1, -1):
         | 
| 233 | 
            +
                    past_date = current_date - timedelta(days=i*30)  # Approximate
         | 
| 234 | 
            +
                    month_list.append((past_date.year, past_date.month))
         | 
| 235 | 
            +
                
         | 
| 236 | 
            +
                # Create a dictionary with actual user counts
         | 
| 237 | 
            +
                user_counts = {(record.year, record.month): record.count for record in monthly_users}
         | 
| 238 | 
            +
                
         | 
| 239 | 
            +
                # Build complete monthly datasets including months with zero new users
         | 
| 240 | 
            +
                monthly_labels = [f"{month}/{year}" for year, month in month_list]
         | 
| 241 | 
            +
                monthly_counts = [user_counts.get((year, month), 0) for year, month in month_list]
         | 
| 242 | 
            +
                
         | 
| 243 | 
            +
                # Model performance over time
         | 
| 244 | 
            +
                top_models = Model.query.order_by(Model.match_count.desc()).limit(5).all()
         | 
| 245 | 
            +
                
         | 
| 246 | 
            +
                # Get first and last timestamp to create a consistent timeline
         | 
| 247 | 
            +
                earliest = datetime.utcnow() - timedelta(days=30)  # Default to 30 days ago
         | 
| 248 | 
            +
                latest = datetime.utcnow()  # Default to now
         | 
| 249 | 
            +
                
         | 
| 250 | 
            +
                # Find actual earliest and latest timestamps across all models
         | 
| 251 | 
            +
                has_elo_history = False
         | 
| 252 | 
            +
                for model in top_models:
         | 
| 253 | 
            +
                    first = EloHistory.query.filter_by(model_id=model.id).order_by(EloHistory.timestamp).first()
         | 
| 254 | 
            +
                    last = EloHistory.query.filter_by(model_id=model.id).order_by(EloHistory.timestamp.desc()).first()
         | 
| 255 | 
            +
                    
         | 
| 256 | 
            +
                    if first and last:
         | 
| 257 | 
            +
                        has_elo_history = True
         | 
| 258 | 
            +
                        if first.timestamp < earliest:
         | 
| 259 | 
            +
                            earliest = first.timestamp
         | 
| 260 | 
            +
                        if last.timestamp > latest:
         | 
| 261 | 
            +
                            latest = last.timestamp
         | 
| 262 | 
            +
                
         | 
| 263 | 
            +
                # If no history was found, use a default range of the last 30 days
         | 
| 264 | 
            +
                if not has_elo_history:
         | 
| 265 | 
            +
                    earliest = datetime.utcnow() - timedelta(days=30)
         | 
| 266 | 
            +
                    latest = datetime.utcnow()
         | 
| 267 | 
            +
                
         | 
| 268 | 
            +
                # Make sure the date range is valid (earliest before latest)
         | 
| 269 | 
            +
                if earliest > latest:
         | 
| 270 | 
            +
                    earliest = latest - timedelta(days=30)
         | 
| 271 | 
            +
                
         | 
| 272 | 
            +
                # Generate a list of dates for the ELO history timeline
         | 
| 273 | 
            +
                # Using 1-day intervals for a smoother chart
         | 
| 274 | 
            +
                elo_dates = []
         | 
| 275 | 
            +
                current = earliest
         | 
| 276 | 
            +
                while current <= latest:
         | 
| 277 | 
            +
                    elo_dates.append(current.date())
         | 
| 278 | 
            +
                    current += timedelta(days=1)
         | 
| 279 | 
            +
                
         | 
| 280 | 
            +
                # Format dates consistently
         | 
| 281 | 
            +
                formatted_elo_dates = [date.strftime("%Y-%m-%d") for date in elo_dates]
         | 
| 282 | 
            +
                
         | 
| 283 | 
            +
                model_history = {}
         | 
| 284 | 
            +
                
         | 
| 285 | 
            +
                # Initialize empty data for all top models
         | 
| 286 | 
            +
                for model in top_models:
         | 
| 287 | 
            +
                    model_history[model.name] = {
         | 
| 288 | 
            +
                        "timestamps": formatted_elo_dates,
         | 
| 289 | 
            +
                        "scores": [None] * len(formatted_elo_dates)  # Initialize with None values
         | 
| 290 | 
            +
                    }
         | 
| 291 | 
            +
                    
         | 
| 292 | 
            +
                    history = EloHistory.query.filter_by(
         | 
| 293 | 
            +
                        model_id=model.id
         | 
| 294 | 
            +
                    ).order_by(EloHistory.timestamp).all()
         | 
| 295 | 
            +
                    
         | 
| 296 | 
            +
                    if history:
         | 
| 297 | 
            +
                        # Create a dictionary mapping dates to scores
         | 
| 298 | 
            +
                        history_dict = {}
         | 
| 299 | 
            +
                        for h in history:
         | 
| 300 | 
            +
                            date_key = h.timestamp.date().strftime("%Y-%m-%d")
         | 
| 301 | 
            +
                            history_dict[date_key] = h.elo_score
         | 
| 302 | 
            +
                        
         | 
| 303 | 
            +
                        # Fill in missing dates with the previous score
         | 
| 304 | 
            +
                        last_score = model.current_elo  # Default to current ELO if no history
         | 
| 305 | 
            +
                        scores = []
         | 
| 306 | 
            +
                        
         | 
| 307 | 
            +
                        for date in formatted_elo_dates:
         | 
| 308 | 
            +
                            if date in history_dict:
         | 
| 309 | 
            +
                                last_score = history_dict[date]
         | 
| 310 | 
            +
                            scores.append(last_score)
         | 
| 311 | 
            +
                        
         | 
| 312 | 
            +
                        model_history[model.name]["scores"] = scores
         | 
| 313 | 
            +
                    else:
         | 
| 314 | 
            +
                        # If no history, use the current Elo for all dates
         | 
| 315 | 
            +
                        model_history[model.name]["scores"] = [model.current_elo] * len(formatted_elo_dates)
         | 
| 316 | 
            +
                
         | 
| 317 | 
            +
                chart_data = {
         | 
| 318 | 
            +
                    "dailyVotes": {
         | 
| 319 | 
            +
                        "labels": formatted_dates,
         | 
| 320 | 
            +
                        "ttsCounts": tts_counts,
         | 
| 321 | 
            +
                        "convCounts": conv_counts
         | 
| 322 | 
            +
                    },
         | 
| 323 | 
            +
                    "monthlyUsers": {
         | 
| 324 | 
            +
                        "labels": monthly_labels,
         | 
| 325 | 
            +
                        "counts": monthly_counts
         | 
| 326 | 
            +
                    },
         | 
| 327 | 
            +
                    "modelHistory": model_history
         | 
| 328 | 
            +
                }
         | 
| 329 | 
            +
                
         | 
| 330 | 
            +
                return render_template(
         | 
| 331 | 
            +
                    "admin/statistics.html",
         | 
| 332 | 
            +
                    chart_data=json.dumps(chart_data)
         | 
| 333 | 
            +
                )
         | 
| 334 | 
            +
             | 
| 335 | 
            +
            @admin.route("/activity")
         | 
| 336 | 
            +
            @admin_required
         | 
| 337 | 
            +
            def activity():
         | 
| 338 | 
            +
                """View recent text generations"""
         | 
| 339 | 
            +
                # Check if we have any active sessions from app.py
         | 
| 340 | 
            +
                tts_session_count = 0
         | 
| 341 | 
            +
                conversational_session_count = 0
         | 
| 342 | 
            +
                
         | 
| 343 | 
            +
                # Access global variables from app.py through current_app
         | 
| 344 | 
            +
                if hasattr(current_app, 'tts_sessions'):
         | 
| 345 | 
            +
                    tts_session_count = len(current_app.tts_sessions)
         | 
| 346 | 
            +
                else:  # Try to access through app module
         | 
| 347 | 
            +
                    from app import tts_sessions
         | 
| 348 | 
            +
                    tts_session_count = len(tts_sessions)
         | 
| 349 | 
            +
                
         | 
| 350 | 
            +
                if hasattr(current_app, 'conversational_sessions'):
         | 
| 351 | 
            +
                    conversational_session_count = len(current_app.conversational_sessions)
         | 
| 352 | 
            +
                else:  # Try to access through app module
         | 
| 353 | 
            +
                    from app import conversational_sessions
         | 
| 354 | 
            +
                    conversational_session_count = len(conversational_sessions)
         | 
| 355 | 
            +
                
         | 
| 356 | 
            +
                # Get recent votes which represent completed generations
         | 
| 357 | 
            +
                recent_tts_votes = Vote.query.filter_by(
         | 
| 358 | 
            +
                    model_type=ModelType.TTS
         | 
| 359 | 
            +
                ).order_by(Vote.vote_date.desc()).limit(20).all()
         | 
| 360 | 
            +
                
         | 
| 361 | 
            +
                recent_conv_votes = Vote.query.filter_by(
         | 
| 362 | 
            +
                    model_type=ModelType.CONVERSATIONAL
         | 
| 363 | 
            +
                ).order_by(Vote.vote_date.desc()).limit(20).all()
         | 
| 364 | 
            +
                
         | 
| 365 | 
            +
                # Get votes per hour for the last 24 hours
         | 
| 366 | 
            +
                current_time = datetime.utcnow()
         | 
| 367 | 
            +
                last_24h = current_time.replace(minute=0, second=0, microsecond=0) - timedelta(hours=24)
         | 
| 368 | 
            +
                
         | 
| 369 | 
            +
                # Use SQLite-compatible date formatting
         | 
| 370 | 
            +
                hourly_votes = db.session.query(
         | 
| 371 | 
            +
                    func.strftime('%Y-%m-%d %H:00', Vote.vote_date).label('hour'),
         | 
| 372 | 
            +
                    func.count().label('count')
         | 
| 373 | 
            +
                ).filter(
         | 
| 374 | 
            +
                    Vote.vote_date >= last_24h
         | 
| 375 | 
            +
                ).group_by('hour').order_by('hour').all()
         | 
| 376 | 
            +
                
         | 
| 377 | 
            +
                # Generate all hours for the past 24 hours with correct hour formatting
         | 
| 378 | 
            +
                hour_list = []
         | 
| 379 | 
            +
                for i in range(24, -1, -1):
         | 
| 380 | 
            +
                    # Calculate the hour time and truncate to hour
         | 
| 381 | 
            +
                    hour_time = current_time - timedelta(hours=i)
         | 
| 382 | 
            +
                    hour_time = hour_time.replace(minute=0, second=0, microsecond=0)
         | 
| 383 | 
            +
                    hour_list.append(hour_time.strftime('%Y-%m-%d %H:00'))
         | 
| 384 | 
            +
                
         | 
| 385 | 
            +
                # Create a dictionary with actual vote counts
         | 
| 386 | 
            +
                vote_counts = {hour.hour: hour.count for hour in hourly_votes}
         | 
| 387 | 
            +
                
         | 
| 388 | 
            +
                # Build complete hourly datasets including hours with zero votes
         | 
| 389 | 
            +
                hourly_data = {
         | 
| 390 | 
            +
                    "labels": hour_list,
         | 
| 391 | 
            +
                    "counts": [vote_counts.get(hour, 0) for hour in hour_list]
         | 
| 392 | 
            +
                }
         | 
| 393 | 
            +
                
         | 
| 394 | 
            +
                return render_template(
         | 
| 395 | 
            +
                    "admin/activity.html",
         | 
| 396 | 
            +
                    tts_session_count=tts_session_count,
         | 
| 397 | 
            +
                    conversational_session_count=conversational_session_count,
         | 
| 398 | 
            +
                    recent_tts_votes=recent_tts_votes,
         | 
| 399 | 
            +
                    recent_conv_votes=recent_conv_votes,
         | 
| 400 | 
            +
                    hourly_data=json.dumps(hourly_data)
         | 
| 401 | 
            +
                ) 
         | 
    	
        app.py
    CHANGED
    
    | @@ -1,4 +1,1067 @@ | |
| 1 | 
            -
             | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 2 |  | 
| 3 | 
             
            if __name__ == "__main__":
         | 
| 4 | 
            -
                app. | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            from huggingface_hub import HfApi, hf_hub_download
         | 
| 3 | 
            +
            from apscheduler.schedulers.background import BackgroundScheduler
         | 
| 4 | 
            +
            from concurrent.futures import ThreadPoolExecutor
         | 
| 5 | 
            +
            from datetime import datetime
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            year = datetime.now().year
         | 
| 8 | 
            +
            month = datetime.now().month
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            # Check if running in a Huggin Face Space
         | 
| 11 | 
            +
            IS_SPACES = False
         | 
| 12 | 
            +
            if os.getenv("SPACE_REPO_NAME"):
         | 
| 13 | 
            +
                print("Running in a Hugging Face Space 🤗")
         | 
| 14 | 
            +
                IS_SPACES = True
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                # Setup database sync for HF Spaces
         | 
| 17 | 
            +
                if not os.path.exists("instance/tts_arena.db"):
         | 
| 18 | 
            +
                    os.makedirs("instance", exist_ok=True)
         | 
| 19 | 
            +
                    try:
         | 
| 20 | 
            +
                        print("Database not found, downloading from HF dataset...")
         | 
| 21 | 
            +
                        hf_hub_download(
         | 
| 22 | 
            +
                            repo_id="TTS-AGI/database-arena-v2",
         | 
| 23 | 
            +
                            filename="tts_arena.db",
         | 
| 24 | 
            +
                            repo_type="dataset",
         | 
| 25 | 
            +
                            local_dir="instance",
         | 
| 26 | 
            +
                            token=os.getenv("HF_TOKEN"),
         | 
| 27 | 
            +
                        )
         | 
| 28 | 
            +
                        print("Database downloaded successfully ✅")
         | 
| 29 | 
            +
                    except Exception as e:
         | 
| 30 | 
            +
                        print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
         | 
| 31 | 
            +
             | 
| 32 | 
            +
            from flask import (
         | 
| 33 | 
            +
                Flask,
         | 
| 34 | 
            +
                render_template,
         | 
| 35 | 
            +
                g,
         | 
| 36 | 
            +
                request,
         | 
| 37 | 
            +
                jsonify,
         | 
| 38 | 
            +
                send_file,
         | 
| 39 | 
            +
                redirect,
         | 
| 40 | 
            +
                url_for,
         | 
| 41 | 
            +
                session,
         | 
| 42 | 
            +
                abort,
         | 
| 43 | 
            +
            )
         | 
| 44 | 
            +
            from flask_login import LoginManager, current_user
         | 
| 45 | 
            +
            from models import *
         | 
| 46 | 
            +
            from auth import auth, init_oauth, is_admin
         | 
| 47 | 
            +
            from admin import admin
         | 
| 48 | 
            +
            import os
         | 
| 49 | 
            +
            from dotenv import load_dotenv
         | 
| 50 | 
            +
            from flask_limiter import Limiter
         | 
| 51 | 
            +
            from flask_limiter.util import get_remote_address
         | 
| 52 | 
            +
            import uuid
         | 
| 53 | 
            +
            import tempfile
         | 
| 54 | 
            +
            import shutil
         | 
| 55 | 
            +
            from tts import predict_tts
         | 
| 56 | 
            +
            import random
         | 
| 57 | 
            +
            import json
         | 
| 58 | 
            +
            from datetime import datetime, timedelta
         | 
| 59 | 
            +
            from flask_migrate import Migrate
         | 
| 60 | 
            +
            import requests
         | 
| 61 | 
            +
            import functools
         | 
| 62 | 
            +
            import time # Added for potential retries
         | 
| 63 | 
            +
             | 
| 64 | 
            +
             | 
| 65 | 
            +
            # Load environment variables
         | 
| 66 | 
            +
            if not IS_SPACES:
         | 
| 67 | 
            +
                load_dotenv()  # Only load .env if not running in a Hugging Face Space
         | 
| 68 | 
            +
             | 
| 69 | 
            +
            app = Flask(__name__)
         | 
| 70 | 
            +
            app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", os.urandom(24))
         | 
| 71 | 
            +
            app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv(
         | 
| 72 | 
            +
                "DATABASE_URI", "sqlite:///tts_arena.db"
         | 
| 73 | 
            +
            )
         | 
| 74 | 
            +
            app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
         | 
| 75 | 
            +
            app.config["SESSION_COOKIE_SECURE"] = True
         | 
| 76 | 
            +
            app.config["SESSION_COOKIE_SAMESITE"] = (
         | 
| 77 | 
            +
                "None" if IS_SPACES else "Lax"
         | 
| 78 | 
            +
            )  # HF Spaces uses iframes to load the app, so we need to set SAMESITE to None
         | 
| 79 | 
            +
            app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)  # Set to desired duration
         | 
| 80 | 
            +
             | 
| 81 | 
            +
            # Force HTTPS when running in HuggingFace Spaces
         | 
| 82 | 
            +
            if IS_SPACES:
         | 
| 83 | 
            +
                app.config["PREFERRED_URL_SCHEME"] = "https"
         | 
| 84 | 
            +
             | 
| 85 | 
            +
            # Cloudflare Turnstile settings
         | 
| 86 | 
            +
            app.config["TURNSTILE_ENABLED"] = (
         | 
| 87 | 
            +
                os.getenv("TURNSTILE_ENABLED", "False").lower() == "true"
         | 
| 88 | 
            +
            )
         | 
| 89 | 
            +
            app.config["TURNSTILE_SITE_KEY"] = os.getenv("TURNSTILE_SITE_KEY", "")
         | 
| 90 | 
            +
            app.config["TURNSTILE_SECRET_KEY"] = os.getenv("TURNSTILE_SECRET_KEY", "")
         | 
| 91 | 
            +
            app.config["TURNSTILE_VERIFY_URL"] = (
         | 
| 92 | 
            +
                "https://challenges.cloudflare.com/turnstile/v0/siteverify"
         | 
| 93 | 
            +
            )
         | 
| 94 | 
            +
             | 
| 95 | 
            +
            migrate = Migrate(app, db)
         | 
| 96 | 
            +
             | 
| 97 | 
            +
            # Initialize extensions
         | 
| 98 | 
            +
            db.init_app(app)
         | 
| 99 | 
            +
            login_manager = LoginManager()
         | 
| 100 | 
            +
            login_manager.init_app(app)
         | 
| 101 | 
            +
            login_manager.login_view = "auth.login"
         | 
| 102 | 
            +
             | 
| 103 | 
            +
            # Initialize OAuth
         | 
| 104 | 
            +
            init_oauth(app)
         | 
| 105 | 
            +
             | 
| 106 | 
            +
            # Configure rate limits
         | 
| 107 | 
            +
            limiter = Limiter(
         | 
| 108 | 
            +
                app=app,
         | 
| 109 | 
            +
                key_func=get_remote_address,
         | 
| 110 | 
            +
                default_limits=["200 per day", "50 per hour"],
         | 
| 111 | 
            +
                storage_uri="memory://",
         | 
| 112 | 
            +
            )
         | 
| 113 | 
            +
             | 
| 114 | 
            +
            # Create temp directory for audio files
         | 
| 115 | 
            +
            TEMP_AUDIO_DIR = os.path.join(tempfile.gettempdir(), "tts_arena_audio")
         | 
| 116 | 
            +
            os.makedirs(TEMP_AUDIO_DIR, exist_ok=True)
         | 
| 117 | 
            +
             | 
| 118 | 
            +
            # Store active TTS sessions
         | 
| 119 | 
            +
            app.tts_sessions = {}
         | 
| 120 | 
            +
            tts_sessions = app.tts_sessions
         | 
| 121 | 
            +
             | 
| 122 | 
            +
            # Store active conversational sessions
         | 
| 123 | 
            +
            app.conversational_sessions = {}
         | 
| 124 | 
            +
            conversational_sessions = app.conversational_sessions
         | 
| 125 | 
            +
             | 
| 126 | 
            +
            # Register blueprints
         | 
| 127 | 
            +
            app.register_blueprint(auth, url_prefix="/auth")
         | 
| 128 | 
            +
            app.register_blueprint(admin)
         | 
| 129 | 
            +
             | 
| 130 | 
            +
             | 
| 131 | 
            +
            @login_manager.user_loader
         | 
| 132 | 
            +
            def load_user(user_id):
         | 
| 133 | 
            +
                return User.query.get(int(user_id))
         | 
| 134 | 
            +
             | 
| 135 | 
            +
             | 
| 136 | 
            +
            @app.before_request
         | 
| 137 | 
            +
            def before_request():
         | 
| 138 | 
            +
                g.user = current_user
         | 
| 139 | 
            +
                g.is_admin = is_admin(current_user)
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                # Ensure HTTPS for HuggingFace Spaces environment
         | 
| 142 | 
            +
                if IS_SPACES and request.headers.get("X-Forwarded-Proto") == "http":
         | 
| 143 | 
            +
                    url = request.url.replace("http://", "https://", 1)
         | 
| 144 | 
            +
                    return redirect(url, code=301)
         | 
| 145 | 
            +
             | 
| 146 | 
            +
                # Check if Turnstile verification is required
         | 
| 147 | 
            +
                if app.config["TURNSTILE_ENABLED"]:
         | 
| 148 | 
            +
                    # Exclude verification routes
         | 
| 149 | 
            +
                    excluded_routes = ["verify_turnstile", "turnstile_page", "static"]
         | 
| 150 | 
            +
                    if request.endpoint not in excluded_routes:
         | 
| 151 | 
            +
                        # Check if user is verified
         | 
| 152 | 
            +
                        if not session.get("turnstile_verified"):
         | 
| 153 | 
            +
                            # Save original URL for redirect after verification
         | 
| 154 | 
            +
                            redirect_url = request.url
         | 
| 155 | 
            +
                            # Force HTTPS in HuggingFace Spaces
         | 
| 156 | 
            +
                            if IS_SPACES and redirect_url.startswith("http://"):
         | 
| 157 | 
            +
                                redirect_url = redirect_url.replace("http://", "https://", 1)
         | 
| 158 | 
            +
             | 
| 159 | 
            +
                            # If it's an API request, return a JSON response
         | 
| 160 | 
            +
                            if request.path.startswith("/api/"):
         | 
| 161 | 
            +
                                return jsonify({"error": "Turnstile verification required"}), 403
         | 
| 162 | 
            +
                            # For regular requests, redirect to verification page
         | 
| 163 | 
            +
                            return redirect(url_for("turnstile_page", redirect_url=redirect_url))
         | 
| 164 | 
            +
                        else:
         | 
| 165 | 
            +
                            # Check if verification has expired (default: 24 hours)
         | 
| 166 | 
            +
                            verification_timeout = (
         | 
| 167 | 
            +
                                int(os.getenv("TURNSTILE_TIMEOUT_HOURS", "24")) * 3600
         | 
| 168 | 
            +
                            )  # Convert hours to seconds
         | 
| 169 | 
            +
                            verified_at = session.get("turnstile_verified_at", 0)
         | 
| 170 | 
            +
                            current_time = datetime.utcnow().timestamp()
         | 
| 171 | 
            +
             | 
| 172 | 
            +
                            if current_time - verified_at > verification_timeout:
         | 
| 173 | 
            +
                                # Verification expired, clear status and redirect to verification page
         | 
| 174 | 
            +
                                session.pop("turnstile_verified", None)
         | 
| 175 | 
            +
                                session.pop("turnstile_verified_at", None)
         | 
| 176 | 
            +
             | 
| 177 | 
            +
                                redirect_url = request.url
         | 
| 178 | 
            +
                                # Force HTTPS in HuggingFace Spaces
         | 
| 179 | 
            +
                                if IS_SPACES and redirect_url.startswith("http://"):
         | 
| 180 | 
            +
                                    redirect_url = redirect_url.replace("http://", "https://", 1)
         | 
| 181 | 
            +
             | 
| 182 | 
            +
                                if request.path.startswith("/api/"):
         | 
| 183 | 
            +
                                    return jsonify({"error": "Turnstile verification expired"}), 403
         | 
| 184 | 
            +
                                return redirect(
         | 
| 185 | 
            +
                                    url_for("turnstile_page", redirect_url=redirect_url)
         | 
| 186 | 
            +
                                )
         | 
| 187 | 
            +
             | 
| 188 | 
            +
             | 
| 189 | 
            +
            @app.route("/turnstile", methods=["GET"])
         | 
| 190 | 
            +
            def turnstile_page():
         | 
| 191 | 
            +
                """Display Cloudflare Turnstile verification page"""
         | 
| 192 | 
            +
                redirect_url = request.args.get("redirect_url", url_for("arena", _external=True))
         | 
| 193 | 
            +
             | 
| 194 | 
            +
                # Force HTTPS in HuggingFace Spaces
         | 
| 195 | 
            +
                if IS_SPACES and redirect_url.startswith("http://"):
         | 
| 196 | 
            +
                    redirect_url = redirect_url.replace("http://", "https://", 1)
         | 
| 197 | 
            +
             | 
| 198 | 
            +
                return render_template(
         | 
| 199 | 
            +
                    "turnstile.html",
         | 
| 200 | 
            +
                    turnstile_site_key=app.config["TURNSTILE_SITE_KEY"],
         | 
| 201 | 
            +
                    redirect_url=redirect_url,
         | 
| 202 | 
            +
                )
         | 
| 203 | 
            +
             | 
| 204 | 
            +
             | 
| 205 | 
            +
            @app.route("/verify-turnstile", methods=["POST"])
         | 
| 206 | 
            +
            def verify_turnstile():
         | 
| 207 | 
            +
                """Verify Cloudflare Turnstile token"""
         | 
| 208 | 
            +
                token = request.form.get("cf-turnstile-response")
         | 
| 209 | 
            +
                redirect_url = request.form.get("redirect_url", url_for("arena", _external=True))
         | 
| 210 | 
            +
             | 
| 211 | 
            +
                # Force HTTPS in HuggingFace Spaces
         | 
| 212 | 
            +
                if IS_SPACES and redirect_url.startswith("http://"):
         | 
| 213 | 
            +
                    redirect_url = redirect_url.replace("http://", "https://", 1)
         | 
| 214 | 
            +
             | 
| 215 | 
            +
                if not token:
         | 
| 216 | 
            +
                    # If AJAX request, return JSON error
         | 
| 217 | 
            +
                    if request.headers.get("X-Requested-With") == "XMLHttpRequest":
         | 
| 218 | 
            +
                        return (
         | 
| 219 | 
            +
                            jsonify({"success": False, "error": "Missing verification token"}),
         | 
| 220 | 
            +
                            400,
         | 
| 221 | 
            +
                        )
         | 
| 222 | 
            +
                    # Otherwise redirect back to turnstile page
         | 
| 223 | 
            +
                    return redirect(url_for("turnstile_page", redirect_url=redirect_url))
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                # Verify token with Cloudflare
         | 
| 226 | 
            +
                data = {
         | 
| 227 | 
            +
                    "secret": app.config["TURNSTILE_SECRET_KEY"],
         | 
| 228 | 
            +
                    "response": token,
         | 
| 229 | 
            +
                    "remoteip": request.remote_addr,
         | 
| 230 | 
            +
                }
         | 
| 231 | 
            +
             | 
| 232 | 
            +
                try:
         | 
| 233 | 
            +
                    response = requests.post(app.config["TURNSTILE_VERIFY_URL"], data=data)
         | 
| 234 | 
            +
                    result = response.json()
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                    if result.get("success"):
         | 
| 237 | 
            +
                        # Set verification status in session
         | 
| 238 | 
            +
                        session["turnstile_verified"] = True
         | 
| 239 | 
            +
                        session["turnstile_verified_at"] = datetime.utcnow().timestamp()
         | 
| 240 | 
            +
             | 
| 241 | 
            +
                        # Determine response type based on request
         | 
| 242 | 
            +
                        is_xhr = request.headers.get("X-Requested-With") == "XMLHttpRequest"
         | 
| 243 | 
            +
                        accepts_json = "application/json" in request.headers.get("Accept", "")
         | 
| 244 | 
            +
             | 
| 245 | 
            +
                        # If AJAX or JSON request, return success JSON
         | 
| 246 | 
            +
                        if is_xhr or accepts_json:
         | 
| 247 | 
            +
                            return jsonify({"success": True, "redirect": redirect_url})
         | 
| 248 | 
            +
             | 
| 249 | 
            +
                        # For regular form submissions, redirect to the target URL
         | 
| 250 | 
            +
                        return redirect(redirect_url)
         | 
| 251 | 
            +
                    else:
         | 
| 252 | 
            +
                        # Verification failed
         | 
| 253 | 
            +
                        app.logger.warning(f"Turnstile verification failed: {result}")
         | 
| 254 | 
            +
             | 
| 255 | 
            +
                        # If AJAX request, return JSON error
         | 
| 256 | 
            +
                        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
         | 
| 257 | 
            +
                            return jsonify({"success": False, "error": "Verification failed"}), 403
         | 
| 258 | 
            +
             | 
| 259 | 
            +
                        # Otherwise redirect back to turnstile page
         | 
| 260 | 
            +
                        return redirect(url_for("turnstile_page", redirect_url=redirect_url))
         | 
| 261 | 
            +
             | 
| 262 | 
            +
                except Exception as e:
         | 
| 263 | 
            +
                    app.logger.error(f"Turnstile verification error: {str(e)}")
         | 
| 264 | 
            +
             | 
| 265 | 
            +
                    # If AJAX request, return JSON error
         | 
| 266 | 
            +
                    if request.headers.get("X-Requested-With") == "XMLHttpRequest":
         | 
| 267 | 
            +
                        return (
         | 
| 268 | 
            +
                            jsonify(
         | 
| 269 | 
            +
                                {"success": False, "error": "Server error during verification"}
         | 
| 270 | 
            +
                            ),
         | 
| 271 | 
            +
                            500,
         | 
| 272 | 
            +
                        )
         | 
| 273 | 
            +
             | 
| 274 | 
            +
                    # Otherwise redirect back to turnstile page
         | 
| 275 | 
            +
                    return redirect(url_for("turnstile_page", redirect_url=redirect_url))
         | 
| 276 | 
            +
             | 
| 277 | 
            +
             | 
| 278 | 
            +
            @app.route("/")
         | 
| 279 | 
            +
            def arena():
         | 
| 280 | 
            +
                return render_template("arena.html")
         | 
| 281 | 
            +
             | 
| 282 | 
            +
             | 
| 283 | 
            +
            @app.route("/leaderboard")
         | 
| 284 | 
            +
            def leaderboard():
         | 
| 285 | 
            +
                tts_leaderboard = get_leaderboard_data(ModelType.TTS)
         | 
| 286 | 
            +
                conversational_leaderboard = get_leaderboard_data(ModelType.CONVERSATIONAL)
         | 
| 287 | 
            +
                top_voters = get_top_voters(10)  # Get top 10 voters
         | 
| 288 | 
            +
             | 
| 289 | 
            +
                # Initialize personal leaderboard data
         | 
| 290 | 
            +
                tts_personal_leaderboard = None
         | 
| 291 | 
            +
                conversational_personal_leaderboard = None
         | 
| 292 | 
            +
                user_leaderboard_visibility = None
         | 
| 293 | 
            +
             | 
| 294 | 
            +
                # If user is logged in, get their personal leaderboard and visibility setting
         | 
| 295 | 
            +
                if current_user.is_authenticated:
         | 
| 296 | 
            +
                    tts_personal_leaderboard = get_user_leaderboard(current_user.id, ModelType.TTS)
         | 
| 297 | 
            +
                    conversational_personal_leaderboard = get_user_leaderboard(
         | 
| 298 | 
            +
                        current_user.id, ModelType.CONVERSATIONAL
         | 
| 299 | 
            +
                    )
         | 
| 300 | 
            +
                    user_leaderboard_visibility = current_user.show_in_leaderboard
         | 
| 301 | 
            +
             | 
| 302 | 
            +
                # Get key dates for the timeline
         | 
| 303 | 
            +
                tts_key_dates = get_key_historical_dates(ModelType.TTS)
         | 
| 304 | 
            +
                conversational_key_dates = get_key_historical_dates(ModelType.CONVERSATIONAL)
         | 
| 305 | 
            +
             | 
| 306 | 
            +
                # Format dates for display in the dropdown
         | 
| 307 | 
            +
                formatted_tts_dates = [date.strftime("%B %Y") for date in tts_key_dates]
         | 
| 308 | 
            +
                formatted_conversational_dates = [
         | 
| 309 | 
            +
                    date.strftime("%B %Y") for date in conversational_key_dates
         | 
| 310 | 
            +
                ]
         | 
| 311 | 
            +
             | 
| 312 | 
            +
                return render_template(
         | 
| 313 | 
            +
                    "leaderboard.html",
         | 
| 314 | 
            +
                    tts_leaderboard=tts_leaderboard,
         | 
| 315 | 
            +
                    conversational_leaderboard=conversational_leaderboard,
         | 
| 316 | 
            +
                    tts_personal_leaderboard=tts_personal_leaderboard,
         | 
| 317 | 
            +
                    conversational_personal_leaderboard=conversational_personal_leaderboard,
         | 
| 318 | 
            +
                    tts_key_dates=tts_key_dates,
         | 
| 319 | 
            +
                    conversational_key_dates=conversational_key_dates,
         | 
| 320 | 
            +
                    formatted_tts_dates=formatted_tts_dates,
         | 
| 321 | 
            +
                    formatted_conversational_dates=formatted_conversational_dates,
         | 
| 322 | 
            +
                    top_voters=top_voters,
         | 
| 323 | 
            +
                    user_leaderboard_visibility=user_leaderboard_visibility
         | 
| 324 | 
            +
                )
         | 
| 325 | 
            +
             | 
| 326 | 
            +
             | 
| 327 | 
            +
            @app.route("/api/historical-leaderboard/<model_type>")
         | 
| 328 | 
            +
            def historical_leaderboard(model_type):
         | 
| 329 | 
            +
                """Get historical leaderboard data for a specific date"""
         | 
| 330 | 
            +
                if model_type not in [ModelType.TTS, ModelType.CONVERSATIONAL]:
         | 
| 331 | 
            +
                    return jsonify({"error": "Invalid model type"}), 400
         | 
| 332 | 
            +
             | 
| 333 | 
            +
                # Get date from query parameter
         | 
| 334 | 
            +
                date_str = request.args.get("date")
         | 
| 335 | 
            +
                if not date_str:
         | 
| 336 | 
            +
                    return jsonify({"error": "Date parameter is required"}), 400
         | 
| 337 | 
            +
             | 
| 338 | 
            +
                try:
         | 
| 339 | 
            +
                    # Parse date from URL parameter (format: YYYY-MM-DD)
         | 
| 340 | 
            +
                    target_date = datetime.strptime(date_str, "%Y-%m-%d")
         | 
| 341 | 
            +
             | 
| 342 | 
            +
                    # Get historical leaderboard data
         | 
| 343 | 
            +
                    leaderboard_data = get_historical_leaderboard_data(model_type, target_date)
         | 
| 344 | 
            +
             | 
| 345 | 
            +
                    return jsonify(
         | 
| 346 | 
            +
                        {"date": target_date.strftime("%B %d, %Y"), "leaderboard": leaderboard_data}
         | 
| 347 | 
            +
                    )
         | 
| 348 | 
            +
                except ValueError:
         | 
| 349 | 
            +
                    return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400
         | 
| 350 | 
            +
             | 
| 351 | 
            +
             | 
| 352 | 
            +
            @app.route("/about")
         | 
| 353 | 
            +
            def about():
         | 
| 354 | 
            +
                return render_template("about.html")
         | 
| 355 | 
            +
             | 
| 356 | 
            +
             | 
| 357 | 
            +
            @app.route("/api/tts/generate", methods=["POST"])
         | 
| 358 | 
            +
            @limiter.limit("10 per minute")
         | 
| 359 | 
            +
            def generate_tts():
         | 
| 360 | 
            +
                # If verification not setup, handle it first
         | 
| 361 | 
            +
                if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
         | 
| 362 | 
            +
                    return jsonify({"error": "Turnstile verification required"}), 403
         | 
| 363 | 
            +
             | 
| 364 | 
            +
                data = request.json
         | 
| 365 | 
            +
                text = data.get("text")
         | 
| 366 | 
            +
             | 
| 367 | 
            +
                if not text or len(text) > 1000:
         | 
| 368 | 
            +
                    return jsonify({"error": "Invalid or too long text"}), 400
         | 
| 369 | 
            +
             | 
| 370 | 
            +
                # Get two random TTS models
         | 
| 371 | 
            +
                available_models = Model.query.filter_by(
         | 
| 372 | 
            +
                    model_type=ModelType.TTS, is_active=True
         | 
| 373 | 
            +
                ).all()
         | 
| 374 | 
            +
                if len(available_models) < 2:
         | 
| 375 | 
            +
                    return jsonify({"error": "Not enough TTS models available"}), 500
         | 
| 376 | 
            +
             | 
| 377 | 
            +
                selected_models = random.sample(available_models, 2)
         | 
| 378 | 
            +
             | 
| 379 | 
            +
                try:
         | 
| 380 | 
            +
                    # Generate TTS for both models concurrently
         | 
| 381 | 
            +
                    audio_files = []
         | 
| 382 | 
            +
                    model_ids = []
         | 
| 383 | 
            +
             | 
| 384 | 
            +
                    # Function to process a single model
         | 
| 385 | 
            +
                    def process_model(model):
         | 
| 386 | 
            +
                        # Call TTS service
         | 
| 387 | 
            +
                        audio_path = predict_tts(text, model.id)
         | 
| 388 | 
            +
             | 
| 389 | 
            +
                        # Copy to temp dir with unique name
         | 
| 390 | 
            +
                        file_uuid = str(uuid.uuid4())
         | 
| 391 | 
            +
                        dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
         | 
| 392 | 
            +
                        shutil.copy(audio_path, dest_path)
         | 
| 393 | 
            +
             | 
| 394 | 
            +
                        return {"model_id": model.id, "audio_path": dest_path}
         | 
| 395 | 
            +
             | 
| 396 | 
            +
                    # Use ThreadPoolExecutor to process models concurrently
         | 
| 397 | 
            +
                    with ThreadPoolExecutor(max_workers=2) as executor:
         | 
| 398 | 
            +
                        results = list(executor.map(process_model, selected_models))
         | 
| 399 | 
            +
             | 
| 400 | 
            +
                    # Extract results
         | 
| 401 | 
            +
                    for result in results:
         | 
| 402 | 
            +
                        model_ids.append(result["model_id"])
         | 
| 403 | 
            +
                        audio_files.append(result["audio_path"])
         | 
| 404 | 
            +
             | 
| 405 | 
            +
                    # Create session
         | 
| 406 | 
            +
                    session_id = str(uuid.uuid4())
         | 
| 407 | 
            +
                    app.tts_sessions[session_id] = {
         | 
| 408 | 
            +
                        "model_a": model_ids[0],
         | 
| 409 | 
            +
                        "model_b": model_ids[1],
         | 
| 410 | 
            +
                        "audio_a": audio_files[0],
         | 
| 411 | 
            +
                        "audio_b": audio_files[1],
         | 
| 412 | 
            +
                        "text": text,
         | 
| 413 | 
            +
                        "created_at": datetime.utcnow(),
         | 
| 414 | 
            +
                        "expires_at": datetime.utcnow() + timedelta(minutes=30),
         | 
| 415 | 
            +
                        "voted": False,
         | 
| 416 | 
            +
                    }
         | 
| 417 | 
            +
             | 
| 418 | 
            +
                    # Return audio file paths and session
         | 
| 419 | 
            +
                    return jsonify(
         | 
| 420 | 
            +
                        {
         | 
| 421 | 
            +
                            "session_id": session_id,
         | 
| 422 | 
            +
                            "audio_a": f"/api/tts/audio/{session_id}/a",
         | 
| 423 | 
            +
                            "audio_b": f"/api/tts/audio/{session_id}/b",
         | 
| 424 | 
            +
                            "expires_in": 1800,  # 30 minutes in seconds
         | 
| 425 | 
            +
                        }
         | 
| 426 | 
            +
                    )
         | 
| 427 | 
            +
             | 
| 428 | 
            +
                except Exception as e:
         | 
| 429 | 
            +
                    app.logger.error(f"TTS generation error: {str(e)}")
         | 
| 430 | 
            +
                    return jsonify({"error": "Failed to generate TTS"}), 500
         | 
| 431 | 
            +
             | 
| 432 | 
            +
             | 
| 433 | 
            +
            @app.route("/api/tts/audio/<session_id>/<model_key>")
         | 
| 434 | 
            +
            def get_audio(session_id, model_key):
         | 
| 435 | 
            +
                # If verification not setup, handle it first
         | 
| 436 | 
            +
                if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
         | 
| 437 | 
            +
                    return jsonify({"error": "Turnstile verification required"}), 403
         | 
| 438 | 
            +
             | 
| 439 | 
            +
                if session_id not in app.tts_sessions:
         | 
| 440 | 
            +
                    return jsonify({"error": "Invalid or expired session"}), 404
         | 
| 441 | 
            +
             | 
| 442 | 
            +
                session_data = app.tts_sessions[session_id]
         | 
| 443 | 
            +
             | 
| 444 | 
            +
                # Check if session expired
         | 
| 445 | 
            +
                if datetime.utcnow() > session_data["expires_at"]:
         | 
| 446 | 
            +
                    cleanup_session(session_id)
         | 
| 447 | 
            +
                    return jsonify({"error": "Session expired"}), 410
         | 
| 448 | 
            +
             | 
| 449 | 
            +
                if model_key == "a":
         | 
| 450 | 
            +
                    audio_path = session_data["audio_a"]
         | 
| 451 | 
            +
                elif model_key == "b":
         | 
| 452 | 
            +
                    audio_path = session_data["audio_b"]
         | 
| 453 | 
            +
                else:
         | 
| 454 | 
            +
                    return jsonify({"error": "Invalid model key"}), 400
         | 
| 455 | 
            +
             | 
| 456 | 
            +
                # Check if file exists
         | 
| 457 | 
            +
                if not os.path.exists(audio_path):
         | 
| 458 | 
            +
                    return jsonify({"error": "Audio file not found"}), 404
         | 
| 459 | 
            +
             | 
| 460 | 
            +
                return send_file(audio_path, mimetype="audio/wav")
         | 
| 461 | 
            +
             | 
| 462 | 
            +
             | 
| 463 | 
            +
            @app.route("/api/tts/vote", methods=["POST"])
         | 
| 464 | 
            +
            @limiter.limit("30 per minute")
         | 
| 465 | 
            +
            def submit_vote():
         | 
| 466 | 
            +
                # If verification not setup, handle it first
         | 
| 467 | 
            +
                if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
         | 
| 468 | 
            +
                    return jsonify({"error": "Turnstile verification required"}), 403
         | 
| 469 | 
            +
             | 
| 470 | 
            +
                data = request.json
         | 
| 471 | 
            +
                session_id = data.get("session_id")
         | 
| 472 | 
            +
                chosen_model_key = data.get("chosen_model")  # "a" or "b"
         | 
| 473 | 
            +
             | 
| 474 | 
            +
                if not session_id or session_id not in app.tts_sessions:
         | 
| 475 | 
            +
                    return jsonify({"error": "Invalid or expired session"}), 404
         | 
| 476 | 
            +
             | 
| 477 | 
            +
                if not chosen_model_key or chosen_model_key not in ["a", "b"]:
         | 
| 478 | 
            +
                    return jsonify({"error": "Invalid chosen model"}), 400
         | 
| 479 | 
            +
             | 
| 480 | 
            +
                session_data = app.tts_sessions[session_id]
         | 
| 481 | 
            +
             | 
| 482 | 
            +
                # Check if session expired
         | 
| 483 | 
            +
                if datetime.utcnow() > session_data["expires_at"]:
         | 
| 484 | 
            +
                    cleanup_session(session_id)
         | 
| 485 | 
            +
                    return jsonify({"error": "Session expired"}), 410
         | 
| 486 | 
            +
             | 
| 487 | 
            +
                # Check if already voted
         | 
| 488 | 
            +
                if session_data["voted"]:
         | 
| 489 | 
            +
                    return jsonify({"error": "Vote already submitted for this session"}), 400
         | 
| 490 | 
            +
             | 
| 491 | 
            +
                # Get model IDs and audio paths
         | 
| 492 | 
            +
                chosen_id = (
         | 
| 493 | 
            +
                    session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
         | 
| 494 | 
            +
                )
         | 
| 495 | 
            +
                rejected_id = (
         | 
| 496 | 
            +
                    session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
         | 
| 497 | 
            +
                )
         | 
| 498 | 
            +
                chosen_audio_path = (
         | 
| 499 | 
            +
                    session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
         | 
| 500 | 
            +
                )
         | 
| 501 | 
            +
                rejected_audio_path = (
         | 
| 502 | 
            +
                    session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
         | 
| 503 | 
            +
                )
         | 
| 504 | 
            +
             | 
| 505 | 
            +
                # Record vote in database
         | 
| 506 | 
            +
                user_id = current_user.id if current_user.is_authenticated else None
         | 
| 507 | 
            +
                vote, error = record_vote(
         | 
| 508 | 
            +
                    user_id, session_data["text"], chosen_id, rejected_id, ModelType.TTS
         | 
| 509 | 
            +
                )
         | 
| 510 | 
            +
             | 
| 511 | 
            +
                if error:
         | 
| 512 | 
            +
                    return jsonify({"error": error}), 500
         | 
| 513 | 
            +
             | 
| 514 | 
            +
                # --- Save preference data ---
         | 
| 515 | 
            +
                try:
         | 
| 516 | 
            +
                    vote_uuid = str(uuid.uuid4())
         | 
| 517 | 
            +
                    vote_dir = os.path.join("./votes", vote_uuid)
         | 
| 518 | 
            +
                    os.makedirs(vote_dir, exist_ok=True)
         | 
| 519 | 
            +
             | 
| 520 | 
            +
                    # Copy audio files
         | 
| 521 | 
            +
                    shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
         | 
| 522 | 
            +
                    shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
         | 
| 523 | 
            +
             | 
| 524 | 
            +
                    # Create metadata
         | 
| 525 | 
            +
                    chosen_model_obj = Model.query.get(chosen_id)
         | 
| 526 | 
            +
                    rejected_model_obj = Model.query.get(rejected_id)
         | 
| 527 | 
            +
                    metadata = {
         | 
| 528 | 
            +
                        "text": session_data["text"],
         | 
| 529 | 
            +
                        "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
         | 
| 530 | 
            +
                        "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
         | 
| 531 | 
            +
                        "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
         | 
| 532 | 
            +
                        "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
         | 
| 533 | 
            +
                        "session_id": session_id,
         | 
| 534 | 
            +
                        "timestamp": datetime.utcnow().isoformat(),
         | 
| 535 | 
            +
                        "username": current_user.username if current_user.is_authenticated else None,
         | 
| 536 | 
            +
                        "model_type": "TTS"
         | 
| 537 | 
            +
                    }
         | 
| 538 | 
            +
                    with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
         | 
| 539 | 
            +
                        json.dump(metadata, f, indent=2)
         | 
| 540 | 
            +
             | 
| 541 | 
            +
                except Exception as e:
         | 
| 542 | 
            +
                    app.logger.error(f"Error saving preference data for vote {session_id}: {str(e)}")
         | 
| 543 | 
            +
                    # Continue even if saving preference data fails, vote is already recorded
         | 
| 544 | 
            +
             | 
| 545 | 
            +
                # Mark session as voted
         | 
| 546 | 
            +
                session_data["voted"] = True
         | 
| 547 | 
            +
             | 
| 548 | 
            +
                # Return updated models (use previously fetched objects)
         | 
| 549 | 
            +
                return jsonify(
         | 
| 550 | 
            +
                    {
         | 
| 551 | 
            +
                        "success": True,
         | 
| 552 | 
            +
                        "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
         | 
| 553 | 
            +
                        "rejected_model": {
         | 
| 554 | 
            +
                            "id": rejected_id,
         | 
| 555 | 
            +
                            "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
         | 
| 556 | 
            +
                        },
         | 
| 557 | 
            +
                        "names": {
         | 
| 558 | 
            +
                            "a": (
         | 
| 559 | 
            +
                                chosen_model_obj.name if chosen_model_key == "a" else rejected_model_obj.name
         | 
| 560 | 
            +
                                if chosen_model_obj and rejected_model_obj else "Unknown"
         | 
| 561 | 
            +
                            ),
         | 
| 562 | 
            +
                            "b": (
         | 
| 563 | 
            +
                                rejected_model_obj.name if chosen_model_key == "a" else chosen_model_obj.name
         | 
| 564 | 
            +
                                if chosen_model_obj and rejected_model_obj else "Unknown"
         | 
| 565 | 
            +
                            ),
         | 
| 566 | 
            +
                        },
         | 
| 567 | 
            +
                    }
         | 
| 568 | 
            +
                )
         | 
| 569 | 
            +
             | 
| 570 | 
            +
             | 
| 571 | 
            +
            def cleanup_session(session_id):
         | 
| 572 | 
            +
                """Remove session and its audio files"""
         | 
| 573 | 
            +
                if session_id in app.tts_sessions:
         | 
| 574 | 
            +
                    session = app.tts_sessions[session_id]
         | 
| 575 | 
            +
             | 
| 576 | 
            +
                    # Remove audio files
         | 
| 577 | 
            +
                    for audio_file in [session["audio_a"], session["audio_b"]]:
         | 
| 578 | 
            +
                        if os.path.exists(audio_file):
         | 
| 579 | 
            +
                            try:
         | 
| 580 | 
            +
                                os.remove(audio_file)
         | 
| 581 | 
            +
                            except Exception as e:
         | 
| 582 | 
            +
                                app.logger.error(f"Error removing audio file: {str(e)}")
         | 
| 583 | 
            +
             | 
| 584 | 
            +
                    # Remove session
         | 
| 585 | 
            +
                    del app.tts_sessions[session_id]
         | 
| 586 | 
            +
             | 
| 587 | 
            +
             | 
| 588 | 
            +
            @app.route("/api/conversational/generate", methods=["POST"])
         | 
| 589 | 
            +
            @limiter.limit("5 per minute")
         | 
| 590 | 
            +
            def generate_podcast():
         | 
| 591 | 
            +
                # If verification not setup, handle it first
         | 
| 592 | 
            +
                if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
         | 
| 593 | 
            +
                    return jsonify({"error": "Turnstile verification required"}), 403
         | 
| 594 | 
            +
             | 
| 595 | 
            +
                data = request.json
         | 
| 596 | 
            +
                script = data.get("script")
         | 
| 597 | 
            +
             | 
| 598 | 
            +
                if not script or not isinstance(script, list) or len(script) < 2:
         | 
| 599 | 
            +
                    return jsonify({"error": "Invalid script format or too short"}), 400
         | 
| 600 | 
            +
             | 
| 601 | 
            +
                # Validate script format
         | 
| 602 | 
            +
                for line in script:
         | 
| 603 | 
            +
                    if not isinstance(line, dict) or "text" not in line or "speaker_id" not in line:
         | 
| 604 | 
            +
                        return (
         | 
| 605 | 
            +
                            jsonify(
         | 
| 606 | 
            +
                                {
         | 
| 607 | 
            +
                                    "error": "Invalid script line format. Each line must have text and speaker_id"
         | 
| 608 | 
            +
                                }
         | 
| 609 | 
            +
                            ),
         | 
| 610 | 
            +
                            400,
         | 
| 611 | 
            +
                        )
         | 
| 612 | 
            +
                    if (
         | 
| 613 | 
            +
                        not line["text"]
         | 
| 614 | 
            +
                        or not isinstance(line["speaker_id"], int)
         | 
| 615 | 
            +
                        or line["speaker_id"] not in [0, 1]
         | 
| 616 | 
            +
                    ):
         | 
| 617 | 
            +
                        return (
         | 
| 618 | 
            +
                            jsonify({"error": "Invalid script content. Speaker ID must be 0 or 1"}),
         | 
| 619 | 
            +
                            400,
         | 
| 620 | 
            +
                        )
         | 
| 621 | 
            +
             | 
| 622 | 
            +
                # Get two conversational models (currently only CSM and PlayDialog)
         | 
| 623 | 
            +
                available_models = Model.query.filter_by(
         | 
| 624 | 
            +
                    model_type=ModelType.CONVERSATIONAL, is_active=True
         | 
| 625 | 
            +
                ).all()
         | 
| 626 | 
            +
             | 
| 627 | 
            +
                if len(available_models) < 2:
         | 
| 628 | 
            +
                    return jsonify({"error": "Not enough conversational models available"}), 500
         | 
| 629 | 
            +
             | 
| 630 | 
            +
                selected_models = random.sample(available_models, 2)
         | 
| 631 | 
            +
             | 
| 632 | 
            +
                try:
         | 
| 633 | 
            +
                    # Generate audio for both models concurrently
         | 
| 634 | 
            +
                    audio_files = []
         | 
| 635 | 
            +
                    model_ids = []
         | 
| 636 | 
            +
             | 
| 637 | 
            +
                    # Function to process a single model
         | 
| 638 | 
            +
                    def process_model(model):
         | 
| 639 | 
            +
                        # Call conversational TTS service
         | 
| 640 | 
            +
                        audio_content = predict_tts(script, model.id)
         | 
| 641 | 
            +
             | 
| 642 | 
            +
                        # Save to temp file with unique name
         | 
| 643 | 
            +
                        file_uuid = str(uuid.uuid4())
         | 
| 644 | 
            +
                        dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
         | 
| 645 | 
            +
             | 
| 646 | 
            +
                        with open(dest_path, "wb") as f:
         | 
| 647 | 
            +
                            f.write(audio_content)
         | 
| 648 | 
            +
             | 
| 649 | 
            +
                        return {"model_id": model.id, "audio_path": dest_path}
         | 
| 650 | 
            +
             | 
| 651 | 
            +
                    # Use ThreadPoolExecutor to process models concurrently
         | 
| 652 | 
            +
                    with ThreadPoolExecutor(max_workers=2) as executor:
         | 
| 653 | 
            +
                        results = list(executor.map(process_model, selected_models))
         | 
| 654 | 
            +
             | 
| 655 | 
            +
                    # Extract results
         | 
| 656 | 
            +
                    for result in results:
         | 
| 657 | 
            +
                        model_ids.append(result["model_id"])
         | 
| 658 | 
            +
                        audio_files.append(result["audio_path"])
         | 
| 659 | 
            +
             | 
| 660 | 
            +
                    # Create session
         | 
| 661 | 
            +
                    session_id = str(uuid.uuid4())
         | 
| 662 | 
            +
                    script_text = " ".join([line["text"] for line in script])
         | 
| 663 | 
            +
                    app.conversational_sessions[session_id] = {
         | 
| 664 | 
            +
                        "model_a": model_ids[0],
         | 
| 665 | 
            +
                        "model_b": model_ids[1],
         | 
| 666 | 
            +
                        "audio_a": audio_files[0],
         | 
| 667 | 
            +
                        "audio_b": audio_files[1],
         | 
| 668 | 
            +
                        "text": script_text[:1000],  # Limit text length
         | 
| 669 | 
            +
                        "created_at": datetime.utcnow(),
         | 
| 670 | 
            +
                        "expires_at": datetime.utcnow() + timedelta(minutes=30),
         | 
| 671 | 
            +
                        "voted": False,
         | 
| 672 | 
            +
                        "script": script,
         | 
| 673 | 
            +
                    }
         | 
| 674 | 
            +
             | 
| 675 | 
            +
                    # Return audio file paths and session
         | 
| 676 | 
            +
                    return jsonify(
         | 
| 677 | 
            +
                        {
         | 
| 678 | 
            +
                            "session_id": session_id,
         | 
| 679 | 
            +
                            "audio_a": f"/api/conversational/audio/{session_id}/a",
         | 
| 680 | 
            +
                            "audio_b": f"/api/conversational/audio/{session_id}/b",
         | 
| 681 | 
            +
                            "expires_in": 1800,  # 30 minutes in seconds
         | 
| 682 | 
            +
                        }
         | 
| 683 | 
            +
                    )
         | 
| 684 | 
            +
             | 
| 685 | 
            +
                except Exception as e:
         | 
| 686 | 
            +
                    app.logger.error(f"Conversational generation error: {str(e)}")
         | 
| 687 | 
            +
                    return jsonify({"error": f"Failed to generate podcast: {str(e)}"}), 500
         | 
| 688 | 
            +
             | 
| 689 | 
            +
             | 
| 690 | 
            +
            @app.route("/api/conversational/audio/<session_id>/<model_key>")
         | 
| 691 | 
            +
            def get_podcast_audio(session_id, model_key):
         | 
| 692 | 
            +
                # If verification not setup, handle it first
         | 
| 693 | 
            +
                if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
         | 
| 694 | 
            +
                    return jsonify({"error": "Turnstile verification required"}), 403
         | 
| 695 | 
            +
             | 
| 696 | 
            +
                if session_id not in app.conversational_sessions:
         | 
| 697 | 
            +
                    return jsonify({"error": "Invalid or expired session"}), 404
         | 
| 698 | 
            +
             | 
| 699 | 
            +
                session_data = app.conversational_sessions[session_id]
         | 
| 700 | 
            +
             | 
| 701 | 
            +
                # Check if session expired
         | 
| 702 | 
            +
                if datetime.utcnow() > session_data["expires_at"]:
         | 
| 703 | 
            +
                    cleanup_conversational_session(session_id)
         | 
| 704 | 
            +
                    return jsonify({"error": "Session expired"}), 410
         | 
| 705 | 
            +
             | 
| 706 | 
            +
                if model_key == "a":
         | 
| 707 | 
            +
                    audio_path = session_data["audio_a"]
         | 
| 708 | 
            +
                elif model_key == "b":
         | 
| 709 | 
            +
                    audio_path = session_data["audio_b"]
         | 
| 710 | 
            +
                else:
         | 
| 711 | 
            +
                    return jsonify({"error": "Invalid model key"}), 400
         | 
| 712 | 
            +
             | 
| 713 | 
            +
                # Check if file exists
         | 
| 714 | 
            +
                if not os.path.exists(audio_path):
         | 
| 715 | 
            +
                    return jsonify({"error": "Audio file not found"}), 404
         | 
| 716 | 
            +
             | 
| 717 | 
            +
                return send_file(audio_path, mimetype="audio/wav")
         | 
| 718 | 
            +
             | 
| 719 | 
            +
             | 
| 720 | 
            +
            @app.route("/api/conversational/vote", methods=["POST"])
         | 
| 721 | 
            +
            @limiter.limit("30 per minute")
         | 
| 722 | 
            +
            def submit_podcast_vote():
         | 
| 723 | 
            +
                # If verification not setup, handle it first
         | 
| 724 | 
            +
                if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
         | 
| 725 | 
            +
                    return jsonify({"error": "Turnstile verification required"}), 403
         | 
| 726 | 
            +
             | 
| 727 | 
            +
                data = request.json
         | 
| 728 | 
            +
                session_id = data.get("session_id")
         | 
| 729 | 
            +
                chosen_model_key = data.get("chosen_model")  # "a" or "b"
         | 
| 730 | 
            +
             | 
| 731 | 
            +
                if not session_id or session_id not in app.conversational_sessions:
         | 
| 732 | 
            +
                    return jsonify({"error": "Invalid or expired session"}), 404
         | 
| 733 | 
            +
             | 
| 734 | 
            +
                if not chosen_model_key or chosen_model_key not in ["a", "b"]:
         | 
| 735 | 
            +
                    return jsonify({"error": "Invalid chosen model"}), 400
         | 
| 736 | 
            +
             | 
| 737 | 
            +
                session_data = app.conversational_sessions[session_id]
         | 
| 738 | 
            +
             | 
| 739 | 
            +
                # Check if session expired
         | 
| 740 | 
            +
                if datetime.utcnow() > session_data["expires_at"]:
         | 
| 741 | 
            +
                    cleanup_conversational_session(session_id)
         | 
| 742 | 
            +
                    return jsonify({"error": "Session expired"}), 410
         | 
| 743 | 
            +
             | 
| 744 | 
            +
                # Check if already voted
         | 
| 745 | 
            +
                if session_data["voted"]:
         | 
| 746 | 
            +
                    return jsonify({"error": "Vote already submitted for this session"}), 400
         | 
| 747 | 
            +
             | 
| 748 | 
            +
                # Get model IDs and audio paths
         | 
| 749 | 
            +
                chosen_id = (
         | 
| 750 | 
            +
                    session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
         | 
| 751 | 
            +
                )
         | 
| 752 | 
            +
                rejected_id = (
         | 
| 753 | 
            +
                    session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
         | 
| 754 | 
            +
                )
         | 
| 755 | 
            +
                chosen_audio_path = (
         | 
| 756 | 
            +
                    session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
         | 
| 757 | 
            +
                )
         | 
| 758 | 
            +
                rejected_audio_path = (
         | 
| 759 | 
            +
                    session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
         | 
| 760 | 
            +
                )
         | 
| 761 | 
            +
             | 
| 762 | 
            +
                # Record vote in database
         | 
| 763 | 
            +
                user_id = current_user.id if current_user.is_authenticated else None
         | 
| 764 | 
            +
                vote, error = record_vote(
         | 
| 765 | 
            +
                    user_id, session_data["text"], chosen_id, rejected_id, ModelType.CONVERSATIONAL
         | 
| 766 | 
            +
                )
         | 
| 767 | 
            +
             | 
| 768 | 
            +
                if error:
         | 
| 769 | 
            +
                    return jsonify({"error": error}), 500
         | 
| 770 | 
            +
             | 
| 771 | 
            +
                # --- Save preference data ---\
         | 
| 772 | 
            +
                try:
         | 
| 773 | 
            +
                    vote_uuid = str(uuid.uuid4())
         | 
| 774 | 
            +
                    vote_dir = os.path.join("./votes", vote_uuid)
         | 
| 775 | 
            +
                    os.makedirs(vote_dir, exist_ok=True)
         | 
| 776 | 
            +
             | 
| 777 | 
            +
                    # Copy audio files
         | 
| 778 | 
            +
                    shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
         | 
| 779 | 
            +
                    shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
         | 
| 780 | 
            +
             | 
| 781 | 
            +
                    # Create metadata
         | 
| 782 | 
            +
                    chosen_model_obj = Model.query.get(chosen_id)
         | 
| 783 | 
            +
                    rejected_model_obj = Model.query.get(rejected_id)
         | 
| 784 | 
            +
                    metadata = {
         | 
| 785 | 
            +
                        "script": session_data["script"], # Save the full script
         | 
| 786 | 
            +
                        "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
         | 
| 787 | 
            +
                        "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
         | 
| 788 | 
            +
                        "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
         | 
| 789 | 
            +
                        "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
         | 
| 790 | 
            +
                        "session_id": session_id,
         | 
| 791 | 
            +
                        "timestamp": datetime.utcnow().isoformat(),
         | 
| 792 | 
            +
                        "username": current_user.username if current_user.is_authenticated else None,
         | 
| 793 | 
            +
                        "model_type": "CONVERSATIONAL"
         | 
| 794 | 
            +
                    }
         | 
| 795 | 
            +
                    with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
         | 
| 796 | 
            +
                        json.dump(metadata, f, indent=2)
         | 
| 797 | 
            +
             | 
| 798 | 
            +
                except Exception as e:
         | 
| 799 | 
            +
                    app.logger.error(f"Error saving preference data for conversational vote {session_id}: {str(e)}")
         | 
| 800 | 
            +
                    # Continue even if saving preference data fails, vote is already recorded
         | 
| 801 | 
            +
             | 
| 802 | 
            +
                # Mark session as voted
         | 
| 803 | 
            +
                session_data["voted"] = True
         | 
| 804 | 
            +
             | 
| 805 | 
            +
                # Return updated models (use previously fetched objects)
         | 
| 806 | 
            +
                return jsonify(
         | 
| 807 | 
            +
                    {
         | 
| 808 | 
            +
                        "success": True,
         | 
| 809 | 
            +
                        "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
         | 
| 810 | 
            +
                        "rejected_model": {
         | 
| 811 | 
            +
                            "id": rejected_id,
         | 
| 812 | 
            +
                            "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
         | 
| 813 | 
            +
                        },
         | 
| 814 | 
            +
                        "names": {
         | 
| 815 | 
            +
                            "a": Model.query.get(session_data["model_a"]).name,
         | 
| 816 | 
            +
                            "b": Model.query.get(session_data["model_b"]).name,
         | 
| 817 | 
            +
                        },
         | 
| 818 | 
            +
                    }
         | 
| 819 | 
            +
                )
         | 
| 820 | 
            +
             | 
| 821 | 
            +
             | 
| 822 | 
            +
            def cleanup_conversational_session(session_id):
         | 
| 823 | 
            +
                """Remove conversational session and its audio files"""
         | 
| 824 | 
            +
                if session_id in app.conversational_sessions:
         | 
| 825 | 
            +
                    session = app.conversational_sessions[session_id]
         | 
| 826 | 
            +
             | 
| 827 | 
            +
                    # Remove audio files
         | 
| 828 | 
            +
                    for audio_file in [session["audio_a"], session["audio_b"]]:
         | 
| 829 | 
            +
                        if os.path.exists(audio_file):
         | 
| 830 | 
            +
                            try:
         | 
| 831 | 
            +
                                os.remove(audio_file)
         | 
| 832 | 
            +
                            except Exception as e:
         | 
| 833 | 
            +
                                app.logger.error(
         | 
| 834 | 
            +
                                    f"Error removing conversational audio file: {str(e)}"
         | 
| 835 | 
            +
                                )
         | 
| 836 | 
            +
             | 
| 837 | 
            +
                    # Remove session
         | 
| 838 | 
            +
                    del app.conversational_sessions[session_id]
         | 
| 839 | 
            +
             | 
| 840 | 
            +
             | 
| 841 | 
            +
            # Schedule periodic cleanup
         | 
| 842 | 
            +
            def setup_cleanup():
         | 
| 843 | 
            +
                def cleanup_expired_sessions():
         | 
| 844 | 
            +
                    with app.app_context(): # Ensure app context for logging
         | 
| 845 | 
            +
                        current_time = datetime.utcnow()
         | 
| 846 | 
            +
                        # Cleanup TTS sessions
         | 
| 847 | 
            +
                        expired_tts_sessions = [
         | 
| 848 | 
            +
                            sid
         | 
| 849 | 
            +
                            for sid, session_data in app.tts_sessions.items()
         | 
| 850 | 
            +
                            if current_time > session_data["expires_at"]
         | 
| 851 | 
            +
                        ]
         | 
| 852 | 
            +
                        for sid in expired_tts_sessions:
         | 
| 853 | 
            +
                            cleanup_session(sid)
         | 
| 854 | 
            +
             | 
| 855 | 
            +
                        # Cleanup conversational sessions
         | 
| 856 | 
            +
                        expired_conv_sessions = [
         | 
| 857 | 
            +
                            sid
         | 
| 858 | 
            +
                            for sid, session_data in app.conversational_sessions.items()
         | 
| 859 | 
            +
                            if current_time > session_data["expires_at"]
         | 
| 860 | 
            +
                        ]
         | 
| 861 | 
            +
                        for sid in expired_conv_sessions:
         | 
| 862 | 
            +
                            cleanup_conversational_session(sid)
         | 
| 863 | 
            +
                        app.logger.info(f"Cleaned up {len(expired_tts_sessions)} TTS and {len(expired_conv_sessions)} conversational sessions.")
         | 
| 864 | 
            +
             | 
| 865 | 
            +
             | 
| 866 | 
            +
                # Run cleanup every 15 minutes
         | 
| 867 | 
            +
                scheduler = BackgroundScheduler()
         | 
| 868 | 
            +
                scheduler.add_job(cleanup_expired_sessions, "interval", minutes=15)
         | 
| 869 | 
            +
                scheduler.start()
         | 
| 870 | 
            +
                print("Cleanup scheduler started") # Use print for startup messages
         | 
| 871 | 
            +
             | 
| 872 | 
            +
             | 
| 873 | 
            +
            # Schedule periodic tasks (database sync and preference upload)
         | 
| 874 | 
            +
            def setup_periodic_tasks():
         | 
| 875 | 
            +
                """Setup periodic database synchronization and preference data upload for Spaces"""
         | 
| 876 | 
            +
                if not IS_SPACES:
         | 
| 877 | 
            +
                    return
         | 
| 878 | 
            +
             | 
| 879 | 
            +
                db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "instance/") # Get relative path
         | 
| 880 | 
            +
                preferences_repo_id = "TTS-AGI/arena-v2-preferences"
         | 
| 881 | 
            +
                database_repo_id = "TTS-AGI/database-arena-v2"
         | 
| 882 | 
            +
                votes_dir = "./votes"
         | 
| 883 | 
            +
             | 
| 884 | 
            +
                def sync_database():
         | 
| 885 | 
            +
                    """Uploads the database to HF dataset"""
         | 
| 886 | 
            +
                    with app.app_context(): # Ensure app context for logging
         | 
| 887 | 
            +
                        try:
         | 
| 888 | 
            +
                            if not os.path.exists(db_path):
         | 
| 889 | 
            +
                                app.logger.warning(f"Database file not found at {db_path}, skipping sync.")
         | 
| 890 | 
            +
                                return
         | 
| 891 | 
            +
             | 
| 892 | 
            +
                            api = HfApi(token=os.getenv("HF_TOKEN"))
         | 
| 893 | 
            +
                            api.upload_file(
         | 
| 894 | 
            +
                                path_or_fileobj=db_path,
         | 
| 895 | 
            +
                                path_in_repo="tts_arena.db",
         | 
| 896 | 
            +
                                repo_id=database_repo_id,
         | 
| 897 | 
            +
                                repo_type="dataset",
         | 
| 898 | 
            +
                            )
         | 
| 899 | 
            +
                            app.logger.info(f"Database uploaded to {database_repo_id} at {datetime.utcnow()}")
         | 
| 900 | 
            +
                        except Exception as e:
         | 
| 901 | 
            +
                            app.logger.error(f"Error uploading database to {database_repo_id}: {str(e)}")
         | 
| 902 | 
            +
             | 
| 903 | 
            +
                def sync_preferences_data():
         | 
| 904 | 
            +
                    """Zips and uploads preference data folders to HF dataset"""
         | 
| 905 | 
            +
                    with app.app_context(): # Ensure app context for logging
         | 
| 906 | 
            +
                        if not os.path.isdir(votes_dir):
         | 
| 907 | 
            +
                            # app.logger.info(f"Votes directory '{votes_dir}' not found, skipping preference sync.")
         | 
| 908 | 
            +
                            return # Don't log every 5 mins if dir doesn't exist yet
         | 
| 909 | 
            +
             | 
| 910 | 
            +
                        try:
         | 
| 911 | 
            +
                            api = HfApi(token=os.getenv("HF_TOKEN"))
         | 
| 912 | 
            +
                            vote_uuids = [d for d in os.listdir(votes_dir) if os.path.isdir(os.path.join(votes_dir, d))]
         | 
| 913 | 
            +
             | 
| 914 | 
            +
                            if not vote_uuids:
         | 
| 915 | 
            +
                                # app.logger.info("No new preference data to upload.")
         | 
| 916 | 
            +
                                return # Don't log every 5 mins if no new data
         | 
| 917 | 
            +
             | 
| 918 | 
            +
                            uploaded_count = 0
         | 
| 919 | 
            +
                            for vote_uuid in vote_uuids:
         | 
| 920 | 
            +
                                dir_path = os.path.join(votes_dir, vote_uuid)
         | 
| 921 | 
            +
                                zip_base_path = os.path.join(votes_dir, vote_uuid) # Name zip file same as folder
         | 
| 922 | 
            +
                                zip_path = f"{zip_base_path}.zip"
         | 
| 923 | 
            +
             | 
| 924 | 
            +
                                try:
         | 
| 925 | 
            +
                                    # Create zip archive
         | 
| 926 | 
            +
                                    shutil.make_archive(zip_base_path, 'zip', dir_path)
         | 
| 927 | 
            +
                                    app.logger.info(f"Created zip archive: {zip_path}")
         | 
| 928 | 
            +
             | 
| 929 | 
            +
                                    # Upload zip file
         | 
| 930 | 
            +
                                    api.upload_file(
         | 
| 931 | 
            +
                                        path_or_fileobj=zip_path,
         | 
| 932 | 
            +
                                        path_in_repo=f"votes/{year}/{month}/{vote_uuid}.zip",
         | 
| 933 | 
            +
                                        repo_id=preferences_repo_id,
         | 
| 934 | 
            +
                                        repo_type="dataset",
         | 
| 935 | 
            +
                                        commit_message=f"Add preference data {vote_uuid}"
         | 
| 936 | 
            +
                                    )
         | 
| 937 | 
            +
                                    app.logger.info(f"Successfully uploaded {zip_path} to {preferences_repo_id}")
         | 
| 938 | 
            +
                                    uploaded_count += 1
         | 
| 939 | 
            +
             | 
| 940 | 
            +
                                    # Cleanup local files after successful upload
         | 
| 941 | 
            +
                                    try:
         | 
| 942 | 
            +
                                        os.remove(zip_path)
         | 
| 943 | 
            +
                                        shutil.rmtree(dir_path)
         | 
| 944 | 
            +
                                        app.logger.info(f"Cleaned up local files: {zip_path} and {dir_path}")
         | 
| 945 | 
            +
                                    except OSError as e:
         | 
| 946 | 
            +
                                        app.logger.error(f"Error cleaning up files for {vote_uuid}: {str(e)}")
         | 
| 947 | 
            +
             | 
| 948 | 
            +
                                except Exception as upload_err:
         | 
| 949 | 
            +
                                    app.logger.error(f"Error processing or uploading preference data for {vote_uuid}: {str(upload_err)}")
         | 
| 950 | 
            +
                                    # Optionally remove zip if it exists but upload failed
         | 
| 951 | 
            +
                                    if os.path.exists(zip_path):
         | 
| 952 | 
            +
                                         try:
         | 
| 953 | 
            +
                                             os.remove(zip_path)
         | 
| 954 | 
            +
                                         except OSError as e:
         | 
| 955 | 
            +
                                             app.logger.error(f"Error removing zip file after failed upload {zip_path}: {str(e)}")
         | 
| 956 | 
            +
                                    # Keep the original folder for the next attempt
         | 
| 957 | 
            +
             | 
| 958 | 
            +
                            if uploaded_count > 0:
         | 
| 959 | 
            +
                                app.logger.info(f"Finished preference data sync. Uploaded {uploaded_count} new entries.")
         | 
| 960 | 
            +
             | 
| 961 | 
            +
                        except Exception as e:
         | 
| 962 | 
            +
                            app.logger.error(f"General error during preference data sync: {str(e)}")
         | 
| 963 | 
            +
             | 
| 964 | 
            +
             | 
| 965 | 
            +
                # Schedule periodic tasks
         | 
| 966 | 
            +
                scheduler = BackgroundScheduler()
         | 
| 967 | 
            +
                # Sync database less frequently if needed, e.g., every 15 minutes
         | 
| 968 | 
            +
                scheduler.add_job(sync_database, "interval", minutes=15, id="sync_db_job")
         | 
| 969 | 
            +
                # Sync preferences more frequently
         | 
| 970 | 
            +
                scheduler.add_job(sync_preferences_data, "interval", minutes=5, id="sync_pref_job")
         | 
| 971 | 
            +
                scheduler.start()
         | 
| 972 | 
            +
                print("Periodic tasks scheduler started (DB sync and Preferences upload)") # Use print for startup
         | 
| 973 | 
            +
             | 
| 974 | 
            +
             | 
| 975 | 
            +
            @app.cli.command("init-db")
         | 
| 976 | 
            +
            def init_db():
         | 
| 977 | 
            +
                """Initialize the database."""
         | 
| 978 | 
            +
                with app.app_context():
         | 
| 979 | 
            +
                    db.create_all()
         | 
| 980 | 
            +
                    print("Database initialized!")
         | 
| 981 | 
            +
             | 
| 982 | 
            +
             | 
| 983 | 
            +
            @app.route("/api/toggle-leaderboard-visibility", methods=["POST"])
         | 
| 984 | 
            +
            def toggle_leaderboard_visibility():
         | 
| 985 | 
            +
                """Toggle whether the current user appears in the top voters leaderboard"""
         | 
| 986 | 
            +
                if not current_user.is_authenticated:
         | 
| 987 | 
            +
                    return jsonify({"error": "You must be logged in to change this setting"}), 401
         | 
| 988 | 
            +
                
         | 
| 989 | 
            +
                new_status = toggle_user_leaderboard_visibility(current_user.id)
         | 
| 990 | 
            +
                if new_status is None:
         | 
| 991 | 
            +
                    return jsonify({"error": "User not found"}), 404
         | 
| 992 | 
            +
                    
         | 
| 993 | 
            +
                return jsonify({
         | 
| 994 | 
            +
                    "success": True, 
         | 
| 995 | 
            +
                    "visible": new_status,
         | 
| 996 | 
            +
                    "message": "You are now visible in the voters leaderboard" if new_status else "You are now hidden from the voters leaderboard"
         | 
| 997 | 
            +
                })
         | 
| 998 | 
            +
             | 
| 999 |  | 
| 1000 | 
             
            if __name__ == "__main__":
         | 
| 1001 | 
            +
                with app.app_context():
         | 
| 1002 | 
            +
                    # Ensure ./instance and ./votes directories exist
         | 
| 1003 | 
            +
                    os.makedirs("instance", exist_ok=True)
         | 
| 1004 | 
            +
                    os.makedirs("./votes", exist_ok=True) # Create votes directory if it doesn't exist
         | 
| 1005 | 
            +
             | 
| 1006 | 
            +
                    # Download database if it doesn't exist (only on initial space start)
         | 
| 1007 | 
            +
                    if IS_SPACES and not os.path.exists(app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")):
         | 
| 1008 | 
            +
                         try:
         | 
| 1009 | 
            +
                            print("Database not found, downloading from HF dataset...")
         | 
| 1010 | 
            +
                            hf_hub_download(
         | 
| 1011 | 
            +
                                repo_id="TTS-AGI/database-arena-v2",
         | 
| 1012 | 
            +
                                filename="tts_arena.db",
         | 
| 1013 | 
            +
                                repo_type="dataset",
         | 
| 1014 | 
            +
                                local_dir="instance", # download to instance/
         | 
| 1015 | 
            +
                                token=os.getenv("HF_TOKEN"),
         | 
| 1016 | 
            +
                            )
         | 
| 1017 | 
            +
                            print("Database downloaded successfully ✅")
         | 
| 1018 | 
            +
                         except Exception as e:
         | 
| 1019 | 
            +
                             print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
         | 
| 1020 | 
            +
             | 
| 1021 | 
            +
             | 
| 1022 | 
            +
                    db.create_all()  # Create tables if they don't exist
         | 
| 1023 | 
            +
                    insert_initial_models()
         | 
| 1024 | 
            +
                    # Setup background tasks
         | 
| 1025 | 
            +
                    setup_cleanup()
         | 
| 1026 | 
            +
                    setup_periodic_tasks() # Renamed function call
         | 
| 1027 | 
            +
             | 
| 1028 | 
            +
                # Configure Flask to recognize HTTPS when behind a reverse proxy
         | 
| 1029 | 
            +
                from werkzeug.middleware.proxy_fix import ProxyFix
         | 
| 1030 | 
            +
             | 
| 1031 | 
            +
                # Apply ProxyFix middleware to handle reverse proxy headers
         | 
| 1032 | 
            +
                # This ensures Flask generates correct URLs with https scheme
         | 
| 1033 | 
            +
                # X-Forwarded-Proto header will be used to detect the original protocol
         | 
| 1034 | 
            +
                app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
         | 
| 1035 | 
            +
             | 
| 1036 | 
            +
                # Force Flask to prefer HTTPS for generated URLs
         | 
| 1037 | 
            +
                app.config["PREFERRED_URL_SCHEME"] = "https"
         | 
| 1038 | 
            +
             | 
| 1039 | 
            +
                from waitress import serve
         | 
| 1040 | 
            +
             | 
| 1041 | 
            +
                # Configuration for 2 vCPUs:
         | 
| 1042 | 
            +
                # - threads: typically 4-8 threads per CPU core is a good balance
         | 
| 1043 | 
            +
                # - connection_limit: maximum concurrent connections
         | 
| 1044 | 
            +
                # - channel_timeout: prevent hanging connections
         | 
| 1045 | 
            +
                threads = 12  # 6 threads per vCPU is a good balance for mixed IO/CPU workloads
         | 
| 1046 | 
            +
             | 
| 1047 | 
            +
                if IS_SPACES:
         | 
| 1048 | 
            +
                    serve(
         | 
| 1049 | 
            +
                        app,
         | 
| 1050 | 
            +
                        host="0.0.0.0",
         | 
| 1051 | 
            +
                        port=int(os.environ.get("PORT", 7860)),
         | 
| 1052 | 
            +
                        threads=threads,
         | 
| 1053 | 
            +
                        connection_limit=100,
         | 
| 1054 | 
            +
                        channel_timeout=30,
         | 
| 1055 | 
            +
                        url_scheme='https'
         | 
| 1056 | 
            +
                    )
         | 
| 1057 | 
            +
                else:
         | 
| 1058 | 
            +
                    print(f"Starting Waitress server with {threads} threads")
         | 
| 1059 | 
            +
                    serve(
         | 
| 1060 | 
            +
                        app,
         | 
| 1061 | 
            +
                        host="0.0.0.0",
         | 
| 1062 | 
            +
                        port=5000,
         | 
| 1063 | 
            +
                        threads=threads,
         | 
| 1064 | 
            +
                        connection_limit=100,
         | 
| 1065 | 
            +
                        channel_timeout=30,
         | 
| 1066 | 
            +
                        url_scheme='https' # Keep https for local dev if using proxy/tunnel
         | 
| 1067 | 
            +
                    )
         | 
    	
        auth.py
    ADDED
    
    | @@ -0,0 +1,104 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            from flask import Blueprint, redirect, url_for, session, request, current_app, flash
         | 
| 2 | 
            +
            from flask_login import login_user, logout_user, current_user, login_required
         | 
| 3 | 
            +
            from authlib.integrations.flask_client import OAuth
         | 
| 4 | 
            +
            import os
         | 
| 5 | 
            +
            from models import db, User
         | 
| 6 | 
            +
            import requests
         | 
| 7 | 
            +
            from functools import wraps
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            auth = Blueprint("auth", __name__)
         | 
| 10 | 
            +
            oauth = OAuth()
         | 
| 11 | 
            +
             | 
| 12 | 
            +
             | 
| 13 | 
            +
            def init_oauth(app):
         | 
| 14 | 
            +
                oauth.init_app(app)
         | 
| 15 | 
            +
                oauth.register(
         | 
| 16 | 
            +
                    name="huggingface",
         | 
| 17 | 
            +
                    client_id=os.getenv("OAUTH_CLIENT_ID"),
         | 
| 18 | 
            +
                    client_secret=os.getenv("OAUTH_CLIENT_SECRET"),
         | 
| 19 | 
            +
                    access_token_url="https://huggingface.co/oauth/token",
         | 
| 20 | 
            +
                    access_token_params=None,
         | 
| 21 | 
            +
                    authorize_url="https://huggingface.co/oauth/authorize",
         | 
| 22 | 
            +
                    authorize_params=None,
         | 
| 23 | 
            +
                    api_base_url="https://huggingface.co/api/",
         | 
| 24 | 
            +
                    client_kwargs={},
         | 
| 25 | 
            +
                )
         | 
| 26 | 
            +
             | 
| 27 | 
            +
             | 
| 28 | 
            +
            def is_admin(user):
         | 
| 29 | 
            +
                """Check if a user is in the ADMIN_USERS environment variable"""
         | 
| 30 | 
            +
                if not user or not user.is_authenticated:
         | 
| 31 | 
            +
                    return False
         | 
| 32 | 
            +
                
         | 
| 33 | 
            +
                admin_users = os.getenv("ADMIN_USERS", "").split(",")
         | 
| 34 | 
            +
                return user.username in [username.strip() for username in admin_users]
         | 
| 35 | 
            +
             | 
| 36 | 
            +
             | 
| 37 | 
            +
            def admin_required(f):
         | 
| 38 | 
            +
                """Decorator to require admin access for a route"""
         | 
| 39 | 
            +
                @wraps(f)
         | 
| 40 | 
            +
                def decorated_function(*args, **kwargs):
         | 
| 41 | 
            +
                    if not current_user.is_authenticated:
         | 
| 42 | 
            +
                        flash("Please log in to access this page", "error")
         | 
| 43 | 
            +
                        return redirect(url_for("auth.login", next=request.url))
         | 
| 44 | 
            +
                    
         | 
| 45 | 
            +
                    if not is_admin(current_user):
         | 
| 46 | 
            +
                        flash("You do not have permission to access this page", "error")
         | 
| 47 | 
            +
                        return redirect(url_for("arena"))
         | 
| 48 | 
            +
                        
         | 
| 49 | 
            +
                    return f(*args, **kwargs)
         | 
| 50 | 
            +
                return decorated_function
         | 
| 51 | 
            +
             | 
| 52 | 
            +
             | 
| 53 | 
            +
            @auth.route("/login")
         | 
| 54 | 
            +
            def login():
         | 
| 55 | 
            +
                # Store the next URL to redirect after login
         | 
| 56 | 
            +
                next_url = request.args.get("next") or url_for("arena")
         | 
| 57 | 
            +
                session["next_url"] = next_url
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                redirect_uri = url_for("auth.authorize", _external=True, _scheme="https")
         | 
| 60 | 
            +
                return oauth.huggingface.authorize_redirect(redirect_uri)
         | 
| 61 | 
            +
             | 
| 62 | 
            +
             | 
| 63 | 
            +
            @auth.route("/authorize")
         | 
| 64 | 
            +
            def authorize():
         | 
| 65 | 
            +
                try:
         | 
| 66 | 
            +
                    # Get token without OpenID verification
         | 
| 67 | 
            +
                    token = oauth.huggingface.authorize_access_token()
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                    # Fetch user info manually from HF API
         | 
| 70 | 
            +
                    headers = {"Authorization": f'Bearer {token["access_token"]}'}
         | 
| 71 | 
            +
                    resp = requests.get("https://huggingface.co/api/whoami-v2", headers=headers)
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                    if not resp.ok:
         | 
| 74 | 
            +
                        flash("Failed to fetch user information from Hugging Face", "error")
         | 
| 75 | 
            +
                        return redirect(url_for("arena"))
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                    user_info = resp.json()
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                    # Check if user exists, otherwise create
         | 
| 80 | 
            +
                    user = User.query.filter_by(hf_id=user_info["id"]).first()
         | 
| 81 | 
            +
                    if not user:
         | 
| 82 | 
            +
                        user = User(username=user_info["name"], hf_id=user_info["id"])
         | 
| 83 | 
            +
                        db.session.add(user)
         | 
| 84 | 
            +
                        db.session.commit()
         | 
| 85 | 
            +
             | 
| 86 | 
            +
                    # Log in the user
         | 
| 87 | 
            +
                    login_user(user, remember=True)
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                    # Redirect to the original page or default
         | 
| 90 | 
            +
                    next_url = session.pop("next_url", url_for("arena"))
         | 
| 91 | 
            +
                    return redirect(next_url)
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                except Exception as e:
         | 
| 94 | 
            +
                    current_app.logger.error(f"OAuth error: {str(e)}")
         | 
| 95 | 
            +
                    flash(f"Authentication error: {str(e)}", "error")
         | 
| 96 | 
            +
                    return redirect(url_for("arena"))
         | 
| 97 | 
            +
             | 
| 98 | 
            +
             | 
| 99 | 
            +
            @auth.route("/logout")
         | 
| 100 | 
            +
            @login_required
         | 
| 101 | 
            +
            def logout():
         | 
| 102 | 
            +
                logout_user()
         | 
| 103 | 
            +
                flash("You have been logged out", "info")
         | 
| 104 | 
            +
                return redirect(url_for("arena"))
         | 
    	
        models.py
    ADDED
    
    | @@ -0,0 +1,575 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            from flask_sqlalchemy import SQLAlchemy
         | 
| 2 | 
            +
            from flask_login import UserMixin
         | 
| 3 | 
            +
            from datetime import datetime
         | 
| 4 | 
            +
            import math
         | 
| 5 | 
            +
            from sqlalchemy import func
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            db = SQLAlchemy()
         | 
| 8 | 
            +
             | 
| 9 | 
            +
             | 
| 10 | 
            +
            class User(db.Model, UserMixin):
         | 
| 11 | 
            +
                id = db.Column(db.Integer, primary_key=True)
         | 
| 12 | 
            +
                username = db.Column(db.String(100), unique=True, nullable=False)
         | 
| 13 | 
            +
                hf_id = db.Column(db.String(100), unique=True, nullable=False)
         | 
| 14 | 
            +
                join_date = db.Column(db.DateTime, default=datetime.utcnow)
         | 
| 15 | 
            +
                votes = db.relationship("Vote", backref="user", lazy=True)
         | 
| 16 | 
            +
                show_in_leaderboard = db.Column(db.Boolean, default=True)
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                def __repr__(self):
         | 
| 19 | 
            +
                    return f"<User {self.username}>"
         | 
| 20 | 
            +
             | 
| 21 | 
            +
             | 
| 22 | 
            +
            class ModelType:
         | 
| 23 | 
            +
                TTS = "tts"
         | 
| 24 | 
            +
                CONVERSATIONAL = "conversational"
         | 
| 25 | 
            +
             | 
| 26 | 
            +
             | 
| 27 | 
            +
            class Model(db.Model):
         | 
| 28 | 
            +
                id = db.Column(db.String(100), primary_key=True)
         | 
| 29 | 
            +
                name = db.Column(db.String(100), nullable=False)
         | 
| 30 | 
            +
                model_type = db.Column(db.String(20), nullable=False)  # 'tts' or 'conversational'
         | 
| 31 | 
            +
                # Fix ambiguous foreign keys by specifying which foreign key to use
         | 
| 32 | 
            +
                votes = db.relationship(
         | 
| 33 | 
            +
                    "Vote",
         | 
| 34 | 
            +
                    primaryjoin="or_(Model.id==Vote.model_chosen, Model.id==Vote.model_rejected)",
         | 
| 35 | 
            +
                    viewonly=True,
         | 
| 36 | 
            +
                )
         | 
| 37 | 
            +
                current_elo = db.Column(db.Float, default=1500.0)
         | 
| 38 | 
            +
                win_count = db.Column(db.Integer, default=0)
         | 
| 39 | 
            +
                match_count = db.Column(db.Integer, default=0)
         | 
| 40 | 
            +
                is_open = db.Column(db.Boolean, default=False)
         | 
| 41 | 
            +
                is_active = db.Column(
         | 
| 42 | 
            +
                    db.Boolean, default=True
         | 
| 43 | 
            +
                )  # Whether the model is active and can be voted on
         | 
| 44 | 
            +
                model_url = db.Column(db.String(255), nullable=True)
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                @property
         | 
| 47 | 
            +
                def win_rate(self):
         | 
| 48 | 
            +
                    if self.match_count == 0:
         | 
| 49 | 
            +
                        return 0
         | 
| 50 | 
            +
                    return (self.win_count / self.match_count) * 100
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                def __repr__(self):
         | 
| 53 | 
            +
                    return f"<Model {self.name} ({self.model_type})>"
         | 
| 54 | 
            +
             | 
| 55 | 
            +
             | 
| 56 | 
            +
            class Vote(db.Model):
         | 
| 57 | 
            +
                id = db.Column(db.Integer, primary_key=True)
         | 
| 58 | 
            +
                user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
         | 
| 59 | 
            +
                text = db.Column(db.String(1000), nullable=False)
         | 
| 60 | 
            +
                vote_date = db.Column(db.DateTime, default=datetime.utcnow)
         | 
| 61 | 
            +
                model_chosen = db.Column(db.String(100), db.ForeignKey("model.id"), nullable=False)
         | 
| 62 | 
            +
                model_rejected = db.Column(
         | 
| 63 | 
            +
                    db.String(100), db.ForeignKey("model.id"), nullable=False
         | 
| 64 | 
            +
                )
         | 
| 65 | 
            +
                model_type = db.Column(db.String(20), nullable=False)  # 'tts' or 'conversational'
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                chosen = db.relationship(
         | 
| 68 | 
            +
                    "Model",
         | 
| 69 | 
            +
                    foreign_keys=[model_chosen],
         | 
| 70 | 
            +
                    backref=db.backref("chosen_votes", lazy=True),
         | 
| 71 | 
            +
                )
         | 
| 72 | 
            +
                rejected = db.relationship(
         | 
| 73 | 
            +
                    "Model",
         | 
| 74 | 
            +
                    foreign_keys=[model_rejected],
         | 
| 75 | 
            +
                    backref=db.backref("rejected_votes", lazy=True),
         | 
| 76 | 
            +
                )
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                def __repr__(self):
         | 
| 79 | 
            +
                    return f"<Vote {self.id}: {self.model_chosen} over {self.model_rejected} ({self.model_type})>"
         | 
| 80 | 
            +
             | 
| 81 | 
            +
             | 
| 82 | 
            +
            class EloHistory(db.Model):
         | 
| 83 | 
            +
                id = db.Column(db.Integer, primary_key=True)
         | 
| 84 | 
            +
                model_id = db.Column(db.String(100), db.ForeignKey("model.id"), nullable=False)
         | 
| 85 | 
            +
                timestamp = db.Column(db.DateTime, default=datetime.utcnow)
         | 
| 86 | 
            +
                elo_score = db.Column(db.Float, nullable=False)
         | 
| 87 | 
            +
                vote_id = db.Column(db.Integer, db.ForeignKey("vote.id"), nullable=True)
         | 
| 88 | 
            +
                model_type = db.Column(db.String(20), nullable=False)  # 'tts' or 'conversational'
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                model = db.relationship("Model", backref=db.backref("elo_history", lazy=True))
         | 
| 91 | 
            +
                vote = db.relationship("Vote", backref=db.backref("elo_changes", lazy=True))
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                def __repr__(self):
         | 
| 94 | 
            +
                    return f"<EloHistory {self.model_id}: {self.elo_score} at {self.timestamp} ({self.model_type})>"
         | 
| 95 | 
            +
             | 
| 96 | 
            +
             | 
| 97 | 
            +
            def calculate_elo_change(winner_elo, loser_elo, k_factor=32):
         | 
| 98 | 
            +
                """Calculate Elo rating changes for a match."""
         | 
| 99 | 
            +
                expected_winner = 1 / (1 + math.pow(10, (loser_elo - winner_elo) / 400))
         | 
| 100 | 
            +
                expected_loser = 1 / (1 + math.pow(10, (winner_elo - loser_elo) / 400))
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                winner_new_elo = winner_elo + k_factor * (1 - expected_winner)
         | 
| 103 | 
            +
                loser_new_elo = loser_elo + k_factor * (0 - expected_loser)
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                return winner_new_elo, loser_new_elo
         | 
| 106 | 
            +
             | 
| 107 | 
            +
             | 
| 108 | 
            +
            def record_vote(user_id, text, chosen_model_id, rejected_model_id, model_type):
         | 
| 109 | 
            +
                """Record a vote and update Elo ratings."""
         | 
| 110 | 
            +
                # Create the vote
         | 
| 111 | 
            +
                vote = Vote(
         | 
| 112 | 
            +
                    user_id=user_id,  # Can be None for anonymous votes
         | 
| 113 | 
            +
                    text=text,
         | 
| 114 | 
            +
                    model_chosen=chosen_model_id,
         | 
| 115 | 
            +
                    model_rejected=rejected_model_id,
         | 
| 116 | 
            +
                    model_type=model_type,
         | 
| 117 | 
            +
                )
         | 
| 118 | 
            +
                db.session.add(vote)
         | 
| 119 | 
            +
                db.session.flush()  # Get the vote ID without committing
         | 
| 120 | 
            +
             | 
| 121 | 
            +
                # Get the models
         | 
| 122 | 
            +
                chosen_model = Model.query.filter_by(
         | 
| 123 | 
            +
                    id=chosen_model_id, model_type=model_type
         | 
| 124 | 
            +
                ).first()
         | 
| 125 | 
            +
                rejected_model = Model.query.filter_by(
         | 
| 126 | 
            +
                    id=rejected_model_id, model_type=model_type
         | 
| 127 | 
            +
                ).first()
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                if not chosen_model or not rejected_model:
         | 
| 130 | 
            +
                    db.session.rollback()
         | 
| 131 | 
            +
                    return None, "One or both models not found for the specified model type"
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                # Calculate new Elo ratings
         | 
| 134 | 
            +
                new_chosen_elo, new_rejected_elo = calculate_elo_change(
         | 
| 135 | 
            +
                    chosen_model.current_elo, rejected_model.current_elo
         | 
| 136 | 
            +
                )
         | 
| 137 | 
            +
             | 
| 138 | 
            +
                # Update model stats
         | 
| 139 | 
            +
                chosen_model.current_elo = new_chosen_elo
         | 
| 140 | 
            +
                chosen_model.win_count += 1
         | 
| 141 | 
            +
                chosen_model.match_count += 1
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                rejected_model.current_elo = new_rejected_elo
         | 
| 144 | 
            +
                rejected_model.match_count += 1
         | 
| 145 | 
            +
             | 
| 146 | 
            +
                # Record Elo history
         | 
| 147 | 
            +
                chosen_history = EloHistory(
         | 
| 148 | 
            +
                    model_id=chosen_model_id,
         | 
| 149 | 
            +
                    elo_score=new_chosen_elo,
         | 
| 150 | 
            +
                    vote_id=vote.id,
         | 
| 151 | 
            +
                    model_type=model_type,
         | 
| 152 | 
            +
                )
         | 
| 153 | 
            +
             | 
| 154 | 
            +
                rejected_history = EloHistory(
         | 
| 155 | 
            +
                    model_id=rejected_model_id,
         | 
| 156 | 
            +
                    elo_score=new_rejected_elo,
         | 
| 157 | 
            +
                    vote_id=vote.id,
         | 
| 158 | 
            +
                    model_type=model_type,
         | 
| 159 | 
            +
                )
         | 
| 160 | 
            +
             | 
| 161 | 
            +
                db.session.add_all([chosen_history, rejected_history])
         | 
| 162 | 
            +
                db.session.commit()
         | 
| 163 | 
            +
             | 
| 164 | 
            +
                return vote, None
         | 
| 165 | 
            +
             | 
| 166 | 
            +
             | 
| 167 | 
            +
            def get_leaderboard_data(model_type):
         | 
| 168 | 
            +
                """
         | 
| 169 | 
            +
                Get leaderboard data for the specified model type.
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                Args:
         | 
| 172 | 
            +
                    model_type (str): The model type ('tts' or 'conversational')
         | 
| 173 | 
            +
             | 
| 174 | 
            +
                Returns:
         | 
| 175 | 
            +
                    list: List of dictionaries containing model data for the leaderboard
         | 
| 176 | 
            +
                """
         | 
| 177 | 
            +
                query = Model.query.filter_by(model_type=model_type)
         | 
| 178 | 
            +
             | 
| 179 | 
            +
                # Get models ordered by ELO score
         | 
| 180 | 
            +
                models = query.order_by(Model.current_elo.desc()).all()
         | 
| 181 | 
            +
             | 
| 182 | 
            +
                result = []
         | 
| 183 | 
            +
                for rank, model in enumerate(models, 1):
         | 
| 184 | 
            +
                    # Determine tier based on rank
         | 
| 185 | 
            +
                    if rank <= 2:
         | 
| 186 | 
            +
                        tier = "tier-s"
         | 
| 187 | 
            +
                    elif rank <= 4:
         | 
| 188 | 
            +
                        tier = "tier-a"
         | 
| 189 | 
            +
                    elif rank <= 7:
         | 
| 190 | 
            +
                        tier = "tier-b"
         | 
| 191 | 
            +
                    else:
         | 
| 192 | 
            +
                        tier = ""
         | 
| 193 | 
            +
             | 
| 194 | 
            +
                    result.append(
         | 
| 195 | 
            +
                        {
         | 
| 196 | 
            +
                            "rank": rank,
         | 
| 197 | 
            +
                            "id": model.id,
         | 
| 198 | 
            +
                            "name": model.name,
         | 
| 199 | 
            +
                            "model_url": model.model_url,
         | 
| 200 | 
            +
                            "win_rate": f"{model.win_rate:.0f}%",
         | 
| 201 | 
            +
                            "total_votes": model.match_count,
         | 
| 202 | 
            +
                            "elo": int(model.current_elo),
         | 
| 203 | 
            +
                            "tier": tier,
         | 
| 204 | 
            +
                            "is_open": model.is_open,
         | 
| 205 | 
            +
                        }
         | 
| 206 | 
            +
                    )
         | 
| 207 | 
            +
             | 
| 208 | 
            +
                return result
         | 
| 209 | 
            +
             | 
| 210 | 
            +
             | 
| 211 | 
            +
            def get_user_leaderboard(user_id, model_type):
         | 
| 212 | 
            +
                """
         | 
| 213 | 
            +
                Get personalized leaderboard data for a specific user.
         | 
| 214 | 
            +
             | 
| 215 | 
            +
                Args:
         | 
| 216 | 
            +
                    user_id (int): The user ID
         | 
| 217 | 
            +
                    model_type (str): The model type ('tts' or 'conversational')
         | 
| 218 | 
            +
             | 
| 219 | 
            +
                Returns:
         | 
| 220 | 
            +
                    list: List of dictionaries containing model data for the user's personal leaderboard
         | 
| 221 | 
            +
                """
         | 
| 222 | 
            +
                # Get all models of the specified type
         | 
| 223 | 
            +
                models = Model.query.filter_by(model_type=model_type).all()
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                # Get user's votes
         | 
| 226 | 
            +
                user_votes = Vote.query.filter_by(user_id=user_id, model_type=model_type).all()
         | 
| 227 | 
            +
             | 
| 228 | 
            +
                # Calculate win counts and match counts for each model based on user's votes
         | 
| 229 | 
            +
                model_stats = {model.id: {"wins": 0, "matches": 0} for model in models}
         | 
| 230 | 
            +
             | 
| 231 | 
            +
                for vote in user_votes:
         | 
| 232 | 
            +
                    model_stats[vote.model_chosen]["wins"] += 1
         | 
| 233 | 
            +
                    model_stats[vote.model_chosen]["matches"] += 1
         | 
| 234 | 
            +
                    model_stats[vote.model_rejected]["matches"] += 1
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                # Calculate win rates and prepare result
         | 
| 237 | 
            +
                result = []
         | 
| 238 | 
            +
                for model in models:
         | 
| 239 | 
            +
                    stats = model_stats[model.id]
         | 
| 240 | 
            +
                    win_rate = (
         | 
| 241 | 
            +
                        (stats["wins"] / stats["matches"] * 100) if stats["matches"] > 0 else 0
         | 
| 242 | 
            +
                    )
         | 
| 243 | 
            +
             | 
| 244 | 
            +
                    # Only include models the user has voted on
         | 
| 245 | 
            +
                    if stats["matches"] > 0:
         | 
| 246 | 
            +
                        result.append(
         | 
| 247 | 
            +
                            {
         | 
| 248 | 
            +
                                "id": model.id,
         | 
| 249 | 
            +
                                "name": model.name,
         | 
| 250 | 
            +
                                "model_url": model.model_url,
         | 
| 251 | 
            +
                                "win_rate": f"{win_rate:.0f}%",
         | 
| 252 | 
            +
                                "total_votes": stats["matches"],
         | 
| 253 | 
            +
                                "wins": stats["wins"],
         | 
| 254 | 
            +
                                "is_open": model.is_open,
         | 
| 255 | 
            +
                            }
         | 
| 256 | 
            +
                        )
         | 
| 257 | 
            +
             | 
| 258 | 
            +
                # Sort by win rate descending
         | 
| 259 | 
            +
                result.sort(key=lambda x: float(x["win_rate"].rstrip("%")), reverse=True)
         | 
| 260 | 
            +
             | 
| 261 | 
            +
                # Add rank
         | 
| 262 | 
            +
                for i, item in enumerate(result, 1):
         | 
| 263 | 
            +
                    item["rank"] = i
         | 
| 264 | 
            +
             | 
| 265 | 
            +
                return result
         | 
| 266 | 
            +
             | 
| 267 | 
            +
             | 
| 268 | 
            +
            def get_historical_leaderboard_data(model_type, target_date=None):
         | 
| 269 | 
            +
                """
         | 
| 270 | 
            +
                Get leaderboard data at a specific date in history.
         | 
| 271 | 
            +
             | 
| 272 | 
            +
                Args:
         | 
| 273 | 
            +
                    model_type (str): The model type ('tts' or 'conversational')
         | 
| 274 | 
            +
                    target_date (datetime): The target date for historical data, defaults to current time
         | 
| 275 | 
            +
             | 
| 276 | 
            +
                Returns:
         | 
| 277 | 
            +
                    list: List of dictionaries containing model data for the historical leaderboard
         | 
| 278 | 
            +
                """
         | 
| 279 | 
            +
                if not target_date:
         | 
| 280 | 
            +
                    target_date = datetime.utcnow()
         | 
| 281 | 
            +
             | 
| 282 | 
            +
                # Get all models of the specified type
         | 
| 283 | 
            +
                models = Model.query.filter_by(model_type=model_type).all()
         | 
| 284 | 
            +
             | 
| 285 | 
            +
                # Create a result list for the models
         | 
| 286 | 
            +
                result = []
         | 
| 287 | 
            +
             | 
| 288 | 
            +
                for model in models:
         | 
| 289 | 
            +
                    # Get the most recent EloHistory entry for each model before the target date
         | 
| 290 | 
            +
                    elo_entry = (
         | 
| 291 | 
            +
                        EloHistory.query.filter(
         | 
| 292 | 
            +
                            EloHistory.model_id == model.id,
         | 
| 293 | 
            +
                            EloHistory.model_type == model_type,
         | 
| 294 | 
            +
                            EloHistory.timestamp <= target_date,
         | 
| 295 | 
            +
                        )
         | 
| 296 | 
            +
                        .order_by(EloHistory.timestamp.desc())
         | 
| 297 | 
            +
                        .first()
         | 
| 298 | 
            +
                    )
         | 
| 299 | 
            +
             | 
| 300 | 
            +
                    # Skip models that have no history before the target date
         | 
| 301 | 
            +
                    if not elo_entry:
         | 
| 302 | 
            +
                        continue
         | 
| 303 | 
            +
             | 
| 304 | 
            +
                    # Count wins and matches up to the target date
         | 
| 305 | 
            +
                    match_count = Vote.query.filter(
         | 
| 306 | 
            +
                        db.or_(Vote.model_chosen == model.id, Vote.model_rejected == model.id),
         | 
| 307 | 
            +
                        Vote.model_type == model_type,
         | 
| 308 | 
            +
                        Vote.vote_date <= target_date,
         | 
| 309 | 
            +
                    ).count()
         | 
| 310 | 
            +
             | 
| 311 | 
            +
                    win_count = Vote.query.filter(
         | 
| 312 | 
            +
                        Vote.model_chosen == model.id,
         | 
| 313 | 
            +
                        Vote.model_type == model_type,
         | 
| 314 | 
            +
                        Vote.vote_date <= target_date,
         | 
| 315 | 
            +
                    ).count()
         | 
| 316 | 
            +
             | 
| 317 | 
            +
                    # Calculate win rate
         | 
| 318 | 
            +
                    win_rate = (win_count / match_count * 100) if match_count > 0 else 0
         | 
| 319 | 
            +
             | 
| 320 | 
            +
                    # Add to result
         | 
| 321 | 
            +
                    result.append(
         | 
| 322 | 
            +
                        {
         | 
| 323 | 
            +
                            "id": model.id,
         | 
| 324 | 
            +
                            "name": model.name,
         | 
| 325 | 
            +
                            "model_url": model.model_url,
         | 
| 326 | 
            +
                            "win_rate": f"{win_rate:.0f}%",
         | 
| 327 | 
            +
                            "total_votes": match_count,
         | 
| 328 | 
            +
                            "elo": int(elo_entry.elo_score),
         | 
| 329 | 
            +
                            "is_open": model.is_open,
         | 
| 330 | 
            +
                        }
         | 
| 331 | 
            +
                    )
         | 
| 332 | 
            +
             | 
| 333 | 
            +
                # Sort by ELO score descending
         | 
| 334 | 
            +
                result.sort(key=lambda x: x["elo"], reverse=True)
         | 
| 335 | 
            +
             | 
| 336 | 
            +
                # Add rank and tier
         | 
| 337 | 
            +
                for i, item in enumerate(result, 1):
         | 
| 338 | 
            +
                    item["rank"] = i
         | 
| 339 | 
            +
                    # Determine tier based on rank
         | 
| 340 | 
            +
                    if i <= 2:
         | 
| 341 | 
            +
                        item["tier"] = "tier-s"
         | 
| 342 | 
            +
                    elif i <= 4:
         | 
| 343 | 
            +
                        item["tier"] = "tier-a"
         | 
| 344 | 
            +
                    elif i <= 7:
         | 
| 345 | 
            +
                        item["tier"] = "tier-b"
         | 
| 346 | 
            +
                    else:
         | 
| 347 | 
            +
                        item["tier"] = ""
         | 
| 348 | 
            +
             | 
| 349 | 
            +
                return result
         | 
| 350 | 
            +
             | 
| 351 | 
            +
             | 
| 352 | 
            +
            def get_key_historical_dates(model_type):
         | 
| 353 | 
            +
                """
         | 
| 354 | 
            +
                Get a list of key dates in the leaderboard history.
         | 
| 355 | 
            +
             | 
| 356 | 
            +
                Args:
         | 
| 357 | 
            +
                    model_type (str): The model type ('tts' or 'conversational')
         | 
| 358 | 
            +
             | 
| 359 | 
            +
                Returns:
         | 
| 360 | 
            +
                    list: List of datetime objects representing key dates
         | 
| 361 | 
            +
                """
         | 
| 362 | 
            +
                # Get first and most recent vote dates
         | 
| 363 | 
            +
                first_vote = (
         | 
| 364 | 
            +
                    Vote.query.filter_by(model_type=model_type)
         | 
| 365 | 
            +
                    .order_by(Vote.vote_date.asc())
         | 
| 366 | 
            +
                    .first()
         | 
| 367 | 
            +
                )
         | 
| 368 | 
            +
                last_vote = (
         | 
| 369 | 
            +
                    Vote.query.filter_by(model_type=model_type)
         | 
| 370 | 
            +
                    .order_by(Vote.vote_date.desc())
         | 
| 371 | 
            +
                    .first()
         | 
| 372 | 
            +
                )
         | 
| 373 | 
            +
             | 
| 374 | 
            +
                if not first_vote or not last_vote:
         | 
| 375 | 
            +
                    return []
         | 
| 376 | 
            +
             | 
| 377 | 
            +
                # Generate a list of key dates - first day of each month between the first and last vote
         | 
| 378 | 
            +
                dates = []
         | 
| 379 | 
            +
                current_date = first_vote.vote_date.replace(day=1)
         | 
| 380 | 
            +
                end_date = last_vote.vote_date
         | 
| 381 | 
            +
             | 
| 382 | 
            +
                while current_date <= end_date:
         | 
| 383 | 
            +
                    dates.append(current_date)
         | 
| 384 | 
            +
                    # Move to next month
         | 
| 385 | 
            +
                    if current_date.month == 12:
         | 
| 386 | 
            +
                        current_date = current_date.replace(year=current_date.year + 1, month=1)
         | 
| 387 | 
            +
                    else:
         | 
| 388 | 
            +
                        current_date = current_date.replace(month=current_date.month + 1)
         | 
| 389 | 
            +
             | 
| 390 | 
            +
                # Add latest date
         | 
| 391 | 
            +
                if dates and dates[-1].month != end_date.month or dates[-1].year != end_date.year:
         | 
| 392 | 
            +
                    dates.append(end_date)
         | 
| 393 | 
            +
             | 
| 394 | 
            +
                return dates
         | 
| 395 | 
            +
             | 
| 396 | 
            +
             | 
| 397 | 
            +
            def insert_initial_models():
         | 
| 398 | 
            +
                """Insert initial models into the database."""
         | 
| 399 | 
            +
                tts_models = [
         | 
| 400 | 
            +
                    Model(
         | 
| 401 | 
            +
                        id="eleven-multilingual-v2",
         | 
| 402 | 
            +
                        name="Eleven Multilingual v2",
         | 
| 403 | 
            +
                        model_type=ModelType.TTS,
         | 
| 404 | 
            +
                        is_open=False,
         | 
| 405 | 
            +
                        model_url="https://elevenlabs.io/",
         | 
| 406 | 
            +
                    ),
         | 
| 407 | 
            +
                    Model(
         | 
| 408 | 
            +
                        id="eleven-turbo-v2.5",
         | 
| 409 | 
            +
                        name="Eleven Turbo v2.5",
         | 
| 410 | 
            +
                        model_type=ModelType.TTS,
         | 
| 411 | 
            +
                        is_open=False,
         | 
| 412 | 
            +
                        model_url="https://elevenlabs.io/",
         | 
| 413 | 
            +
                    ),
         | 
| 414 | 
            +
                    Model(
         | 
| 415 | 
            +
                        id="eleven-flash-v2.5",
         | 
| 416 | 
            +
                        name="Eleven Flash v2.5",
         | 
| 417 | 
            +
                        model_type=ModelType.TTS,
         | 
| 418 | 
            +
                        is_open=False,
         | 
| 419 | 
            +
                        model_url="https://elevenlabs.io/",
         | 
| 420 | 
            +
                    ),
         | 
| 421 | 
            +
                    Model(
         | 
| 422 | 
            +
                        id="cartesia-sonic-2",
         | 
| 423 | 
            +
                        name="Cartesia Sonic 2",
         | 
| 424 | 
            +
                        model_type=ModelType.TTS,
         | 
| 425 | 
            +
                        is_open=False,
         | 
| 426 | 
            +
                        model_url="https://cartesia.ai/",
         | 
| 427 | 
            +
                    ),
         | 
| 428 | 
            +
                    Model(
         | 
| 429 | 
            +
                        id="spark-tts",
         | 
| 430 | 
            +
                        name="Spark TTS",
         | 
| 431 | 
            +
                        model_type=ModelType.TTS,
         | 
| 432 | 
            +
                        is_open=False,
         | 
| 433 | 
            +
                        model_url="https://github.com/SparkAudio/Spark-TTS",
         | 
| 434 | 
            +
                    ),
         | 
| 435 | 
            +
                    Model(
         | 
| 436 | 
            +
                        id="playht-2.0",
         | 
| 437 | 
            +
                        name="PlayHT 2.0",
         | 
| 438 | 
            +
                        model_type=ModelType.TTS,
         | 
| 439 | 
            +
                        is_open=False,
         | 
| 440 | 
            +
                        model_url="https://play.ht/",
         | 
| 441 | 
            +
                    ),
         | 
| 442 | 
            +
                    Model(
         | 
| 443 | 
            +
                        id="styletts2",
         | 
| 444 | 
            +
                        name="StyleTTS 2",
         | 
| 445 | 
            +
                        model_type=ModelType.TTS,
         | 
| 446 | 
            +
                        is_open=True,
         | 
| 447 | 
            +
                        model_url="https://github.com/yl4579/StyleTTS2",
         | 
| 448 | 
            +
                    ),
         | 
| 449 | 
            +
                    Model(
         | 
| 450 | 
            +
                        id="kokoro-v1",
         | 
| 451 | 
            +
                        name="Kokoro v1.0",
         | 
| 452 | 
            +
                        model_type=ModelType.TTS,
         | 
| 453 | 
            +
                        is_open=True,
         | 
| 454 | 
            +
                        model_url="https://huggingface.co/hexgrad/Kokoro-82M",
         | 
| 455 | 
            +
                    ),
         | 
| 456 | 
            +
                    Model(
         | 
| 457 | 
            +
                        id="cosyvoice-2.0",
         | 
| 458 | 
            +
                        name="CosyVoice 2.0",
         | 
| 459 | 
            +
                        model_type=ModelType.TTS,
         | 
| 460 | 
            +
                        is_open=True,
         | 
| 461 | 
            +
                        model_url="https://github.com/FunAudioLLM/CosyVoice",
         | 
| 462 | 
            +
                    ),
         | 
| 463 | 
            +
                    Model(
         | 
| 464 | 
            +
                        id="papla-p1",
         | 
| 465 | 
            +
                        name="Papla P1",
         | 
| 466 | 
            +
                        model_type=ModelType.TTS,
         | 
| 467 | 
            +
                        is_open=False,
         | 
| 468 | 
            +
                        model_url="https://papla.media/",
         | 
| 469 | 
            +
                    ),
         | 
| 470 | 
            +
                    Model(
         | 
| 471 | 
            +
                        id="hume-octave",
         | 
| 472 | 
            +
                        name="Hume Octave",
         | 
| 473 | 
            +
                        model_type=ModelType.TTS,
         | 
| 474 | 
            +
                        is_open=False,
         | 
| 475 | 
            +
                        model_url="https://hume.ai/",
         | 
| 476 | 
            +
                    ),
         | 
| 477 | 
            +
                    Model(
         | 
| 478 | 
            +
                        id="megatts3",
         | 
| 479 | 
            +
                        name="MegaTTS 3",
         | 
| 480 | 
            +
                        model_type=ModelType.TTS,
         | 
| 481 | 
            +
                        is_open=True,
         | 
| 482 | 
            +
                        model_url="https://github.com/bytedance/MegaTTS3",
         | 
| 483 | 
            +
                    ),
         | 
| 484 | 
            +
                ]
         | 
| 485 | 
            +
                conversational_models = [
         | 
| 486 | 
            +
                    Model(
         | 
| 487 | 
            +
                        id="csm-1b",
         | 
| 488 | 
            +
                        name="CSM 1B",
         | 
| 489 | 
            +
                        model_type=ModelType.CONVERSATIONAL,
         | 
| 490 | 
            +
                        is_open=True,
         | 
| 491 | 
            +
                        model_url="https://huggingface.co/sesame/csm-1b",
         | 
| 492 | 
            +
                    ),
         | 
| 493 | 
            +
                    Model(
         | 
| 494 | 
            +
                        id="playdialog-1.0",
         | 
| 495 | 
            +
                        name="PlayDialog 1.0",
         | 
| 496 | 
            +
                        model_type=ModelType.CONVERSATIONAL,
         | 
| 497 | 
            +
                        is_open=False,
         | 
| 498 | 
            +
                        model_url="https://play.ht/",
         | 
| 499 | 
            +
                    ),
         | 
| 500 | 
            +
                    Model(
         | 
| 501 | 
            +
                        id="dia-1.6b",
         | 
| 502 | 
            +
                        name="Dia 1.6B",
         | 
| 503 | 
            +
                        model_type=ModelType.CONVERSATIONAL,
         | 
| 504 | 
            +
                        is_open=True,
         | 
| 505 | 
            +
                        model_url="https://huggingface.co/nari-labs/Dia-1.6B",
         | 
| 506 | 
            +
                    ),
         | 
| 507 | 
            +
                ]
         | 
| 508 | 
            +
             | 
| 509 | 
            +
                all_models = tts_models + conversational_models
         | 
| 510 | 
            +
             | 
| 511 | 
            +
                for model in all_models:
         | 
| 512 | 
            +
                    existing = Model.query.filter_by(
         | 
| 513 | 
            +
                        id=model.id, model_type=model.model_type
         | 
| 514 | 
            +
                    ).first()
         | 
| 515 | 
            +
                    if not existing:
         | 
| 516 | 
            +
                        db.session.add(model)
         | 
| 517 | 
            +
                    else:
         | 
| 518 | 
            +
                        # Update model attributes if they've changed, but preserve other data
         | 
| 519 | 
            +
                        existing.name = model.name
         | 
| 520 | 
            +
                        existing.is_open = model.is_open
         | 
| 521 | 
            +
                        if model.is_active is not None:
         | 
| 522 | 
            +
                            existing.is_active = model.is_active
         | 
| 523 | 
            +
             | 
| 524 | 
            +
                db.session.commit()
         | 
| 525 | 
            +
             | 
| 526 | 
            +
             | 
| 527 | 
            +
            def get_top_voters(limit=10):
         | 
| 528 | 
            +
                """
         | 
| 529 | 
            +
                Get the top voters by number of votes.
         | 
| 530 | 
            +
                
         | 
| 531 | 
            +
                Args:
         | 
| 532 | 
            +
                    limit (int): Number of users to return
         | 
| 533 | 
            +
                    
         | 
| 534 | 
            +
                Returns:
         | 
| 535 | 
            +
                    list: List of dictionaries containing user data and vote counts
         | 
| 536 | 
            +
                """
         | 
| 537 | 
            +
                # Query users who have opted in to the leaderboard and have at least one vote
         | 
| 538 | 
            +
                top_users = db.session.query(
         | 
| 539 | 
            +
                    User, func.count(Vote.id).label('vote_count')
         | 
| 540 | 
            +
                ).join(Vote).filter(
         | 
| 541 | 
            +
                    User.show_in_leaderboard == True
         | 
| 542 | 
            +
                ).group_by(User.id).order_by(
         | 
| 543 | 
            +
                    func.count(Vote.id).desc()
         | 
| 544 | 
            +
                ).limit(limit).all()
         | 
| 545 | 
            +
                
         | 
| 546 | 
            +
                result = []
         | 
| 547 | 
            +
                for i, (user, vote_count) in enumerate(top_users, 1):
         | 
| 548 | 
            +
                    result.append({
         | 
| 549 | 
            +
                        "rank": i,
         | 
| 550 | 
            +
                        "username": user.username,
         | 
| 551 | 
            +
                        "vote_count": vote_count,
         | 
| 552 | 
            +
                        "join_date": user.join_date.strftime("%b %d, %Y")
         | 
| 553 | 
            +
                    })
         | 
| 554 | 
            +
                
         | 
| 555 | 
            +
                return result
         | 
| 556 | 
            +
             | 
| 557 | 
            +
             | 
| 558 | 
            +
            def toggle_user_leaderboard_visibility(user_id):
         | 
| 559 | 
            +
                """
         | 
| 560 | 
            +
                Toggle whether a user appears in the voters leaderboard
         | 
| 561 | 
            +
                
         | 
| 562 | 
            +
                Args:
         | 
| 563 | 
            +
                    user_id (int): The user ID
         | 
| 564 | 
            +
                    
         | 
| 565 | 
            +
                Returns:
         | 
| 566 | 
            +
                    bool: New visibility state
         | 
| 567 | 
            +
                """
         | 
| 568 | 
            +
                user = User.query.get(user_id)
         | 
| 569 | 
            +
                if not user:
         | 
| 570 | 
            +
                    return None
         | 
| 571 | 
            +
                    
         | 
| 572 | 
            +
                user.show_in_leaderboard = not user.show_in_leaderboard
         | 
| 573 | 
            +
                db.session.commit()
         | 
| 574 | 
            +
                
         | 
| 575 | 
            +
                return user.show_in_leaderboard
         | 
    	
        requirements.txt
    CHANGED
    
    | @@ -1,8 +1,14 @@ | |
| 1 | 
            -
             | 
| 2 | 
            -
             | 
| 3 | 
            -
             | 
| 4 | 
            -
             | 
| 5 | 
            -
             | 
| 6 | 
            -
             | 
| 7 | 
            -
             | 
| 8 | 
            -
             | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            flask
         | 
| 2 | 
            +
            flask-login
         | 
| 3 | 
            +
            flask-sqlalchemy
         | 
| 4 | 
            +
            python-dotenv
         | 
| 5 | 
            +
            requests
         | 
| 6 | 
            +
            authlib
         | 
| 7 | 
            +
            werkzeug
         | 
| 8 | 
            +
            flask-limiter
         | 
| 9 | 
            +
            apscheduler
         | 
| 10 | 
            +
            flask-migrate
         | 
| 11 | 
            +
            gunicorn
         | 
| 12 | 
            +
            waitress
         | 
| 13 | 
            +
            fal-client
         | 
| 14 | 
            +
            git+https://github.com/playht/pyht
         | 
    	
        static/closed.svg
    ADDED
    
    |  | 
    	
        static/css/waveplayer.css
    ADDED
    
    | @@ -0,0 +1,134 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            /* WavePlayer Component Styles */
         | 
| 2 | 
            +
            .waveplayer {
         | 
| 3 | 
            +
              position: relative;
         | 
| 4 | 
            +
              width: 100%;
         | 
| 5 | 
            +
              background-color: var(--light-gray, #f5f5f5);
         | 
| 6 | 
            +
              border-radius: 8px;
         | 
| 7 | 
            +
              padding: 16px;
         | 
| 8 | 
            +
              margin-bottom: 16px;
         | 
| 9 | 
            +
              box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
         | 
| 10 | 
            +
              overflow: hidden;
         | 
| 11 | 
            +
            }
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            /* Hide native audio elements */
         | 
| 14 | 
            +
            .waveplayer audio {
         | 
| 15 | 
            +
              display: none !important;
         | 
| 16 | 
            +
            }
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            .waveplayer-controls {
         | 
| 19 | 
            +
              display: flex;
         | 
| 20 | 
            +
              align-items: center;
         | 
| 21 | 
            +
              margin-bottom: 12px;
         | 
| 22 | 
            +
              gap: 12px;
         | 
| 23 | 
            +
            }
         | 
| 24 | 
            +
             | 
| 25 | 
            +
            .waveplayer-play-btn {
         | 
| 26 | 
            +
              width: 40px;
         | 
| 27 | 
            +
              height: 40px;
         | 
| 28 | 
            +
              border-radius: 50%;
         | 
| 29 | 
            +
              background-color: var(--primary-color, #5046e5);
         | 
| 30 | 
            +
              color: white;
         | 
| 31 | 
            +
              border: none;
         | 
| 32 | 
            +
              display: flex;
         | 
| 33 | 
            +
              align-items: center;
         | 
| 34 | 
            +
              justify-content: center;
         | 
| 35 | 
            +
              cursor: pointer;
         | 
| 36 | 
            +
              transition: background-color 0.2s, transform 0.1s;
         | 
| 37 | 
            +
              flex-shrink: 0;
         | 
| 38 | 
            +
            }
         | 
| 39 | 
            +
             | 
| 40 | 
            +
            .waveplayer-play-btn:hover {
         | 
| 41 | 
            +
              background-color: var(--primary-hover, #4038c7);
         | 
| 42 | 
            +
            }
         | 
| 43 | 
            +
             | 
| 44 | 
            +
            .waveplayer-play-btn:active {
         | 
| 45 | 
            +
              transform: scale(0.95);
         | 
| 46 | 
            +
            }
         | 
| 47 | 
            +
             | 
| 48 | 
            +
            .waveplayer-play-btn svg {
         | 
| 49 | 
            +
              width: 20px;
         | 
| 50 | 
            +
              height: 20px;
         | 
| 51 | 
            +
            }
         | 
| 52 | 
            +
             | 
| 53 | 
            +
            .waveplayer-time {
         | 
| 54 | 
            +
              font-size: 14px;
         | 
| 55 | 
            +
              color: var(--text-color, #333);
         | 
| 56 | 
            +
              font-weight: 500;
         | 
| 57 | 
            +
              flex-shrink: 0;
         | 
| 58 | 
            +
            }
         | 
| 59 | 
            +
             | 
| 60 | 
            +
            .waveplayer-waveform {
         | 
| 61 | 
            +
              position: relative;
         | 
| 62 | 
            +
              width: 100%;
         | 
| 63 | 
            +
              height: 80px;
         | 
| 64 | 
            +
              cursor: pointer;
         | 
| 65 | 
            +
            }
         | 
| 66 | 
            +
             | 
| 67 | 
            +
            .waveplayer-loading {
         | 
| 68 | 
            +
              position: absolute;
         | 
| 69 | 
            +
              top: 0;
         | 
| 70 | 
            +
              left: 0;
         | 
| 71 | 
            +
              width: 100%;
         | 
| 72 | 
            +
              height: 100%;
         | 
| 73 | 
            +
              background-color: rgba(255, 255, 255, 0.7);
         | 
| 74 | 
            +
              display: flex;
         | 
| 75 | 
            +
              flex-direction: column;
         | 
| 76 | 
            +
              align-items: center;
         | 
| 77 | 
            +
              justify-content: center;
         | 
| 78 | 
            +
              -webkit-backdrop-filter: blur(2px);
         | 
| 79 | 
            +
              backdrop-filter: blur(2px);
         | 
| 80 | 
            +
              z-index: 10;
         | 
| 81 | 
            +
            }
         | 
| 82 | 
            +
             | 
| 83 | 
            +
            .waveplayer-spinner {
         | 
| 84 | 
            +
              width: 24px;
         | 
| 85 | 
            +
              height: 24px;
         | 
| 86 | 
            +
              border: 3px solid rgba(80, 70, 229, 0.3);
         | 
| 87 | 
            +
              border-radius: 50%;
         | 
| 88 | 
            +
              border-top-color: var(--primary-color, #5046e5);
         | 
| 89 | 
            +
              animation: spin 1s linear infinite;
         | 
| 90 | 
            +
              margin-bottom: 8px;
         | 
| 91 | 
            +
            }
         | 
| 92 | 
            +
             | 
| 93 | 
            +
            @keyframes spin {
         | 
| 94 | 
            +
              to {
         | 
| 95 | 
            +
                transform: rotate(360deg);
         | 
| 96 | 
            +
              }
         | 
| 97 | 
            +
            }
         | 
| 98 | 
            +
             | 
| 99 | 
            +
            /* Dark mode styles */
         | 
| 100 | 
            +
            @media (prefers-color-scheme: dark) {
         | 
| 101 | 
            +
              .waveplayer {
         | 
| 102 | 
            +
                background-color: var(--light-gray, #1e1e24);
         | 
| 103 | 
            +
                box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
         | 
| 104 | 
            +
              }
         | 
| 105 | 
            +
              
         | 
| 106 | 
            +
              .waveplayer-time {
         | 
| 107 | 
            +
                color: var(--text-color, #e0e0e0);
         | 
| 108 | 
            +
              }
         | 
| 109 | 
            +
              
         | 
| 110 | 
            +
              .waveplayer-loading {
         | 
| 111 | 
            +
                background-color: rgba(30, 30, 36, 0.7);
         | 
| 112 | 
            +
              }
         | 
| 113 | 
            +
            } 
         | 
| 114 | 
            +
             | 
| 115 | 
            +
            /* Mobile optimizations */
         | 
| 116 | 
            +
            @media (max-width: 768px) {
         | 
| 117 | 
            +
              .waveplayer {
         | 
| 118 | 
            +
                padding: 12px;
         | 
| 119 | 
            +
              }
         | 
| 120 | 
            +
              
         | 
| 121 | 
            +
              .waveplayer-play-btn {
         | 
| 122 | 
            +
                width: 44px;
         | 
| 123 | 
            +
                height: 44px;
         | 
| 124 | 
            +
              }
         | 
| 125 | 
            +
              
         | 
| 126 | 
            +
              .waveplayer-waveform {
         | 
| 127 | 
            +
                height: 70px;
         | 
| 128 | 
            +
                touch-action: none; /* Prevents scroll/zoom on touch */
         | 
| 129 | 
            +
              }
         | 
| 130 | 
            +
              
         | 
| 131 | 
            +
              .waveplayer-time {
         | 
| 132 | 
            +
                font-size: 12px;
         | 
| 133 | 
            +
              }
         | 
| 134 | 
            +
            } 
         | 
    	
        static/huggingface.svg
    ADDED
    
    |  | 
    	
        static/js/waveplayer.js
    ADDED
    
    | @@ -0,0 +1,310 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            class WavePlayer {
         | 
| 2 | 
            +
              constructor(container, options = {}) {
         | 
| 3 | 
            +
                this.container = container;
         | 
| 4 | 
            +
                this.options = {
         | 
| 5 | 
            +
                  waveColor: '#d1d6e0',
         | 
| 6 | 
            +
                  progressColor: '#5046e5',
         | 
| 7 | 
            +
                  cursorColor: '#5046e5',
         | 
| 8 | 
            +
                  cursorWidth: 2,
         | 
| 9 | 
            +
                  height: 80,
         | 
| 10 | 
            +
                  responsive: true,
         | 
| 11 | 
            +
                  barWidth: 2,
         | 
| 12 | 
            +
                  barGap: 1,
         | 
| 13 | 
            +
                  hideScrollbar: true,
         | 
| 14 | 
            +
                  ...options
         | 
| 15 | 
            +
                };
         | 
| 16 | 
            +
                
         | 
| 17 | 
            +
                this.isPlaying = false;
         | 
| 18 | 
            +
                this.wavesurfer = null;
         | 
| 19 | 
            +
                this.loadingIndicator = null;
         | 
| 20 | 
            +
                this.playButton = null;
         | 
| 21 | 
            +
                
         | 
| 22 | 
            +
                this.init();
         | 
| 23 | 
            +
              }
         | 
| 24 | 
            +
              
         | 
| 25 | 
            +
              init() {
         | 
| 26 | 
            +
                // Create player UI
         | 
| 27 | 
            +
                this.buildUI();
         | 
| 28 | 
            +
                
         | 
| 29 | 
            +
                // Initialize wavesurfer
         | 
| 30 | 
            +
                this.initWavesurfer();
         | 
| 31 | 
            +
                
         | 
| 32 | 
            +
                // Setup event listeners
         | 
| 33 | 
            +
                this.setupEvents();
         | 
| 34 | 
            +
              }
         | 
| 35 | 
            +
              
         | 
| 36 | 
            +
              buildUI() {
         | 
| 37 | 
            +
                // Clear container
         | 
| 38 | 
            +
                this.container.innerHTML = '';
         | 
| 39 | 
            +
                this.container.classList.add('waveplayer');
         | 
| 40 | 
            +
                
         | 
| 41 | 
            +
                // Add style to hide native audio elements that might be rendered by wavesurfer
         | 
| 42 | 
            +
                const style = document.createElement('style');
         | 
| 43 | 
            +
                style.textContent = `
         | 
| 44 | 
            +
                  .waveplayer audio {
         | 
| 45 | 
            +
                    display: none !important;
         | 
| 46 | 
            +
                  }
         | 
| 47 | 
            +
                  
         | 
| 48 | 
            +
                  /* Mobile optimizations */
         | 
| 49 | 
            +
                  @media (max-width: 768px) {
         | 
| 50 | 
            +
                    .waveplayer-play-btn {
         | 
| 51 | 
            +
                      width: 44px;
         | 
| 52 | 
            +
                      height: 44px;
         | 
| 53 | 
            +
                      margin-right: 12px;
         | 
| 54 | 
            +
                    }
         | 
| 55 | 
            +
                    
         | 
| 56 | 
            +
                    .waveplayer-waveform {
         | 
| 57 | 
            +
                      height: 70px;
         | 
| 58 | 
            +
                      cursor: pointer;
         | 
| 59 | 
            +
                      touch-action: none; /* Prevents scroll/zoom on touch */
         | 
| 60 | 
            +
                    }
         | 
| 61 | 
            +
                  }
         | 
| 62 | 
            +
                `;
         | 
| 63 | 
            +
                this.container.appendChild(style);
         | 
| 64 | 
            +
                
         | 
| 65 | 
            +
                // Create elements
         | 
| 66 | 
            +
                const waveformContainer = document.createElement('div');
         | 
| 67 | 
            +
                waveformContainer.className = 'waveplayer-waveform';
         | 
| 68 | 
            +
                
         | 
| 69 | 
            +
                const controlsContainer = document.createElement('div');
         | 
| 70 | 
            +
                controlsContainer.className = 'waveplayer-controls';
         | 
| 71 | 
            +
                
         | 
| 72 | 
            +
                // Play button
         | 
| 73 | 
            +
                this.playButton = document.createElement('button');
         | 
| 74 | 
            +
                this.playButton.className = 'waveplayer-play-btn';
         | 
| 75 | 
            +
                this.playButton.innerHTML = `
         | 
| 76 | 
            +
                  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="play-icon">
         | 
| 77 | 
            +
                    <polygon points="5 3 19 12 5 21 5 3"></polygon>
         | 
| 78 | 
            +
                  </svg>
         | 
| 79 | 
            +
                  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pause-icon" style="display: none;">
         | 
| 80 | 
            +
                    <rect x="6" y="4" width="4" height="16"></rect>
         | 
| 81 | 
            +
                    <rect x="14" y="4" width="4" height="16"></rect>
         | 
| 82 | 
            +
                  </svg>
         | 
| 83 | 
            +
                `;
         | 
| 84 | 
            +
                
         | 
| 85 | 
            +
                // Time display
         | 
| 86 | 
            +
                this.timeDisplay = document.createElement('div');
         | 
| 87 | 
            +
                this.timeDisplay.className = 'waveplayer-time';
         | 
| 88 | 
            +
                this.timeDisplay.textContent = '0:00 / 0:00';
         | 
| 89 | 
            +
                
         | 
| 90 | 
            +
                // Loading indicator
         | 
| 91 | 
            +
                this.loadingIndicator = document.createElement('div');
         | 
| 92 | 
            +
                this.loadingIndicator.className = 'waveplayer-loading';
         | 
| 93 | 
            +
                this.loadingIndicator.innerHTML = `
         | 
| 94 | 
            +
                  <div class="waveplayer-spinner"></div>
         | 
| 95 | 
            +
                  <span>Loading...</span>
         | 
| 96 | 
            +
                `;
         | 
| 97 | 
            +
                
         | 
| 98 | 
            +
                // Set up MutationObserver to detect when loading reaches 100%
         | 
| 99 | 
            +
                const loadingTextElement = this.loadingIndicator.querySelector('span');
         | 
| 100 | 
            +
                if (loadingTextElement) {
         | 
| 101 | 
            +
                  const observer = new MutationObserver((mutations) => {
         | 
| 102 | 
            +
                    mutations.forEach((mutation) => {
         | 
| 103 | 
            +
                      if (mutation.type === 'characterData' || mutation.type === 'childList') {
         | 
| 104 | 
            +
                        const text = loadingTextElement.textContent;
         | 
| 105 | 
            +
                        if (text && text.includes('100%')) {
         | 
| 106 | 
            +
                          // If we see "100%", hide the loading indicator after a short delay
         | 
| 107 | 
            +
                          setTimeout(() => this.hideLoading(), 300);
         | 
| 108 | 
            +
                        }
         | 
| 109 | 
            +
                      }
         | 
| 110 | 
            +
                    });
         | 
| 111 | 
            +
                  });
         | 
| 112 | 
            +
                  
         | 
| 113 | 
            +
                  observer.observe(loadingTextElement, { 
         | 
| 114 | 
            +
                    characterData: true, 
         | 
| 115 | 
            +
                    childList: true,
         | 
| 116 | 
            +
                    subtree: true
         | 
| 117 | 
            +
                  });
         | 
| 118 | 
            +
                }
         | 
| 119 | 
            +
                
         | 
| 120 | 
            +
                // Append elements
         | 
| 121 | 
            +
                controlsContainer.appendChild(this.playButton);
         | 
| 122 | 
            +
                controlsContainer.appendChild(this.timeDisplay);
         | 
| 123 | 
            +
                
         | 
| 124 | 
            +
                this.container.appendChild(controlsContainer);
         | 
| 125 | 
            +
                this.container.appendChild(waveformContainer);
         | 
| 126 | 
            +
                this.container.appendChild(this.loadingIndicator);
         | 
| 127 | 
            +
                
         | 
| 128 | 
            +
                // Store reference to waveform container
         | 
| 129 | 
            +
                this.waveformContainer = waveformContainer;
         | 
| 130 | 
            +
              }
         | 
| 131 | 
            +
              
         | 
| 132 | 
            +
              initWavesurfer() {
         | 
| 133 | 
            +
                // Initialize WaveSurfer
         | 
| 134 | 
            +
                this.wavesurfer = WaveSurfer.create({
         | 
| 135 | 
            +
                  container: this.waveformContainer,
         | 
| 136 | 
            +
                  ...this.options,
         | 
| 137 | 
            +
                  // Add mobile touch support
         | 
| 138 | 
            +
                  interact: true,
         | 
| 139 | 
            +
                  dragToSeek: true
         | 
| 140 | 
            +
                });
         | 
| 141 | 
            +
                
         | 
| 142 | 
            +
                // Force reset any loading indicators
         | 
| 143 | 
            +
                if (this.loadingIndicator) {
         | 
| 144 | 
            +
                  this.loadingIndicator.style.display = 'none';
         | 
| 145 | 
            +
                }
         | 
| 146 | 
            +
              }
         | 
| 147 | 
            +
              
         | 
| 148 | 
            +
              setupEvents() {
         | 
| 149 | 
            +
                // Play/pause button
         | 
| 150 | 
            +
                this.playButton.addEventListener('click', () => {
         | 
| 151 | 
            +
                  this.togglePlayPause();
         | 
| 152 | 
            +
                });
         | 
| 153 | 
            +
                
         | 
| 154 | 
            +
                // Add touch support for mobile
         | 
| 155 | 
            +
                this.playButton.addEventListener('touchstart', (e) => {
         | 
| 156 | 
            +
                  e.preventDefault();
         | 
| 157 | 
            +
                  this.togglePlayPause();
         | 
| 158 | 
            +
                });
         | 
| 159 | 
            +
                
         | 
| 160 | 
            +
                // Add touch support for waveform container
         | 
| 161 | 
            +
                this.waveformContainer.addEventListener('touchstart', (e) => {
         | 
| 162 | 
            +
                  // This helps ensure the touch events propagate correctly to wavesurfer
         | 
| 163 | 
            +
                  e.stopPropagation();
         | 
| 164 | 
            +
                });
         | 
| 165 | 
            +
                
         | 
| 166 | 
            +
                // Wavesurfer events
         | 
| 167 | 
            +
                this.wavesurfer.on('ready', () => {
         | 
| 168 | 
            +
                  // Clear loading timeout
         | 
| 169 | 
            +
                  if (this.loadingTimeout) {
         | 
| 170 | 
            +
                    clearTimeout(this.loadingTimeout);
         | 
| 171 | 
            +
                  }
         | 
| 172 | 
            +
                  
         | 
| 173 | 
            +
                  // Explicitly ensure loading indicator is hidden
         | 
| 174 | 
            +
                  this.hideLoading();
         | 
| 175 | 
            +
                  this.updateTimeDisplay();
         | 
| 176 | 
            +
                  
         | 
| 177 | 
            +
                  // Force loading message to be reset
         | 
| 178 | 
            +
                  if (this.loadingIndicator && this.loadingIndicator.querySelector('span')) {
         | 
| 179 | 
            +
                    this.loadingIndicator.querySelector('span').textContent = 'Loading...';
         | 
| 180 | 
            +
                  }
         | 
| 181 | 
            +
                  
         | 
| 182 | 
            +
                  console.log('WavePlayer ready event fired');
         | 
| 183 | 
            +
                });
         | 
| 184 | 
            +
                
         | 
| 185 | 
            +
                // Add specific handler for decode event (fired when audio is decoded)
         | 
| 186 | 
            +
                this.wavesurfer.on('decode', () => {
         | 
| 187 | 
            +
                  // Also hide loading indicator after decode
         | 
| 188 | 
            +
                  this.hideLoading();
         | 
| 189 | 
            +
                  console.log('WavePlayer decode event fired');
         | 
| 190 | 
            +
                });
         | 
| 191 | 
            +
                
         | 
| 192 | 
            +
                // Add specific handler for loading complete
         | 
| 193 | 
            +
                this.wavesurfer.on('loading', (percent) => {
         | 
| 194 | 
            +
                  this.showLoading(percent);
         | 
| 195 | 
            +
                  
         | 
| 196 | 
            +
                  // If loading reaches 100%, make sure to hide the loader after a small delay
         | 
| 197 | 
            +
                  if (percent === 100) {
         | 
| 198 | 
            +
                    setTimeout(() => {
         | 
| 199 | 
            +
                      this.hideLoading();
         | 
| 200 | 
            +
                      console.log('WavePlayer loading 100% - force hiding loader');
         | 
| 201 | 
            +
                    }, 500);
         | 
| 202 | 
            +
                  }
         | 
| 203 | 
            +
                });
         | 
| 204 | 
            +
                
         | 
| 205 | 
            +
                this.wavesurfer.on('play', () => {
         | 
| 206 | 
            +
                  this.isPlaying = true;
         | 
| 207 | 
            +
                  this.updatePlayButton();
         | 
| 208 | 
            +
                });
         | 
| 209 | 
            +
                
         | 
| 210 | 
            +
                this.wavesurfer.on('pause', () => {
         | 
| 211 | 
            +
                  this.isPlaying = false;
         | 
| 212 | 
            +
                  this.updatePlayButton();
         | 
| 213 | 
            +
                });
         | 
| 214 | 
            +
                
         | 
| 215 | 
            +
                this.wavesurfer.on('finish', () => {
         | 
| 216 | 
            +
                  this.isPlaying = false;
         | 
| 217 | 
            +
                  this.updatePlayButton();
         | 
| 218 | 
            +
                });
         | 
| 219 | 
            +
                
         | 
| 220 | 
            +
                this.wavesurfer.on('audioprocess', () => {
         | 
| 221 | 
            +
                  this.updateTimeDisplay();
         | 
| 222 | 
            +
                });
         | 
| 223 | 
            +
                
         | 
| 224 | 
            +
                this.wavesurfer.on('seek', () => {
         | 
| 225 | 
            +
                  this.updateTimeDisplay();
         | 
| 226 | 
            +
                });
         | 
| 227 | 
            +
                
         | 
| 228 | 
            +
                this.wavesurfer.on('error', (err) => {
         | 
| 229 | 
            +
                  console.error('WaveSurfer error:', err);
         | 
| 230 | 
            +
                  this.hideLoading();
         | 
| 231 | 
            +
                });
         | 
| 232 | 
            +
              }
         | 
| 233 | 
            +
              
         | 
| 234 | 
            +
              loadAudio(url) {
         | 
| 235 | 
            +
                this.showLoading();
         | 
| 236 | 
            +
                this.wavesurfer.load(url);
         | 
| 237 | 
            +
                
         | 
| 238 | 
            +
                // Safety timeout to ensure loading indicator gets hidden
         | 
| 239 | 
            +
                // even if the 'ready' event doesn't fire properly
         | 
| 240 | 
            +
                this.loadingTimeout = setTimeout(() => {
         | 
| 241 | 
            +
                  this.hideLoading();
         | 
| 242 | 
            +
                }, 10000); // 10 seconds max loading time
         | 
| 243 | 
            +
              }
         | 
| 244 | 
            +
              
         | 
| 245 | 
            +
              play() {
         | 
| 246 | 
            +
                this.wavesurfer.play();
         | 
| 247 | 
            +
              }
         | 
| 248 | 
            +
              
         | 
| 249 | 
            +
              pause() {
         | 
| 250 | 
            +
                this.wavesurfer.pause();
         | 
| 251 | 
            +
              }
         | 
| 252 | 
            +
              
         | 
| 253 | 
            +
              togglePlayPause() {
         | 
| 254 | 
            +
                this.wavesurfer.playPause();
         | 
| 255 | 
            +
              }
         | 
| 256 | 
            +
              
         | 
| 257 | 
            +
              stop() {
         | 
| 258 | 
            +
                this.wavesurfer.stop();
         | 
| 259 | 
            +
              }
         | 
| 260 | 
            +
              
         | 
| 261 | 
            +
              updatePlayButton() {
         | 
| 262 | 
            +
                const playIcon = this.playButton.querySelector('.play-icon');
         | 
| 263 | 
            +
                const pauseIcon = this.playButton.querySelector('.pause-icon');
         | 
| 264 | 
            +
                
         | 
| 265 | 
            +
                if (this.isPlaying) {
         | 
| 266 | 
            +
                  playIcon.style.display = 'none';
         | 
| 267 | 
            +
                  pauseIcon.style.display = 'block';
         | 
| 268 | 
            +
                } else {
         | 
| 269 | 
            +
                  playIcon.style.display = 'block';
         | 
| 270 | 
            +
                  pauseIcon.style.display = 'none';
         | 
| 271 | 
            +
                }
         | 
| 272 | 
            +
              }
         | 
| 273 | 
            +
              
         | 
| 274 | 
            +
              showLoading(percent) {
         | 
| 275 | 
            +
                this.loadingIndicator.style.display = 'flex';
         | 
| 276 | 
            +
                if (percent !== undefined) {
         | 
| 277 | 
            +
                  this.loadingIndicator.querySelector('span').textContent = `Loading: ${Math.round(percent)}%`;
         | 
| 278 | 
            +
                }
         | 
| 279 | 
            +
              }
         | 
| 280 | 
            +
              
         | 
| 281 | 
            +
              hideLoading() {
         | 
| 282 | 
            +
                if (this.loadingIndicator) {
         | 
| 283 | 
            +
                  this.loadingIndicator.style.display = 'none';
         | 
| 284 | 
            +
                  
         | 
| 285 | 
            +
                  // Reset loading text
         | 
| 286 | 
            +
                  const loadingText = this.loadingIndicator.querySelector('span');
         | 
| 287 | 
            +
                  if (loadingText) {
         | 
| 288 | 
            +
                    loadingText.textContent = 'Loading...';
         | 
| 289 | 
            +
                  }
         | 
| 290 | 
            +
                }
         | 
| 291 | 
            +
              }
         | 
| 292 | 
            +
              
         | 
| 293 | 
            +
              formatTime(seconds) {
         | 
| 294 | 
            +
                const minutes = Math.floor(seconds / 60);
         | 
| 295 | 
            +
                const secondsRemainder = Math.round(seconds) % 60;
         | 
| 296 | 
            +
                const paddedSeconds = secondsRemainder.toString().padStart(2, '0');
         | 
| 297 | 
            +
                return `${minutes}:${paddedSeconds}`;
         | 
| 298 | 
            +
              }
         | 
| 299 | 
            +
              
         | 
| 300 | 
            +
              updateTimeDisplay() {
         | 
| 301 | 
            +
                if (!this.wavesurfer.isReady) return;
         | 
| 302 | 
            +
                
         | 
| 303 | 
            +
                const currentTime = this.formatTime(this.wavesurfer.getCurrentTime());
         | 
| 304 | 
            +
                const duration = this.formatTime(this.wavesurfer.getDuration());
         | 
| 305 | 
            +
                this.timeDisplay.textContent = `${currentTime} / ${duration}`;
         | 
| 306 | 
            +
              }
         | 
| 307 | 
            +
            }
         | 
| 308 | 
            +
             | 
| 309 | 
            +
            // Allow global access
         | 
| 310 | 
            +
            window.WavePlayer = WavePlayer; 
         | 
    	
        static/open.svg
    ADDED
    
    |  | 
    	
        static/twitter.svg
    ADDED
    
    |  | 
    	
        templates/about.html
    ADDED
    
    | @@ -0,0 +1,415 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block title %}About - TTS Arena{% endblock %}
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            {% block current_page %}About{% endblock %}
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            {% block extra_head %}
         | 
| 8 | 
            +
            <style>
         | 
| 9 | 
            +
                .about-container {
         | 
| 10 | 
            +
                    max-width: 800px;
         | 
| 11 | 
            +
                    margin: 0 auto;
         | 
| 12 | 
            +
                }
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                .about-section {
         | 
| 15 | 
            +
                    background: white;
         | 
| 16 | 
            +
                    border-radius: var(--radius);
         | 
| 17 | 
            +
                    padding: 24px;
         | 
| 18 | 
            +
                    margin-bottom: 24px;
         | 
| 19 | 
            +
                    box-shadow: var(--shadow);
         | 
| 20 | 
            +
                }
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                .about-section h2 {
         | 
| 23 | 
            +
                    color: var(--primary-color);
         | 
| 24 | 
            +
                    margin-bottom: 16px;
         | 
| 25 | 
            +
                    font-size: 24px;
         | 
| 26 | 
            +
                }
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                .about-section p {
         | 
| 29 | 
            +
                    margin-bottom: 16px;
         | 
| 30 | 
            +
                    line-height: 1.6;
         | 
| 31 | 
            +
                    color: #444;
         | 
| 32 | 
            +
                }
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                .about-section p:last-child {
         | 
| 35 | 
            +
                    margin-bottom: 0;
         | 
| 36 | 
            +
                }
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                .feature-list {
         | 
| 39 | 
            +
                    list-style: none;
         | 
| 40 | 
            +
                    padding: 0;
         | 
| 41 | 
            +
                }
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                .feature-list li {
         | 
| 44 | 
            +
                    margin-bottom: 12px;
         | 
| 45 | 
            +
                    padding-left: 28px;
         | 
| 46 | 
            +
                    position: relative;
         | 
| 47 | 
            +
                }
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                .feature-list li::before {
         | 
| 50 | 
            +
                    content: "•";
         | 
| 51 | 
            +
                    color: var(--primary-color);
         | 
| 52 | 
            +
                    font-size: 24px;
         | 
| 53 | 
            +
                    position: absolute;
         | 
| 54 | 
            +
                    left: 8px;
         | 
| 55 | 
            +
                    top: -4px;
         | 
| 56 | 
            +
                }
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                .credits-list {
         | 
| 59 | 
            +
                    display: grid;
         | 
| 60 | 
            +
                    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
         | 
| 61 | 
            +
                    gap: 24px;
         | 
| 62 | 
            +
                    margin-top: 16px;
         | 
| 63 | 
            +
                }
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                .credit-item {
         | 
| 66 | 
            +
                    display: flex;
         | 
| 67 | 
            +
                    align-items: center;
         | 
| 68 | 
            +
                    justify-content: space-between;
         | 
| 69 | 
            +
                    padding-bottom: 8px;
         | 
| 70 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 71 | 
            +
                }
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                .credit-item a {
         | 
| 74 | 
            +
                    color: var(--primary-color);
         | 
| 75 | 
            +
                    text-decoration: none;
         | 
| 76 | 
            +
                }
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                .credit-item a:hover {
         | 
| 79 | 
            +
                    text-decoration: underline;
         | 
| 80 | 
            +
                }
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                .social-links {
         | 
| 83 | 
            +
                    display: flex;
         | 
| 84 | 
            +
                    gap: 12px;
         | 
| 85 | 
            +
                }
         | 
| 86 | 
            +
                
         | 
| 87 | 
            +
                .social-icon {
         | 
| 88 | 
            +
                    width: 20px;
         | 
| 89 | 
            +
                    height: 20px;
         | 
| 90 | 
            +
                }
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                .citation-box {
         | 
| 93 | 
            +
                    background-color: var(--light-gray);
         | 
| 94 | 
            +
                    border-radius: var(--radius);
         | 
| 95 | 
            +
                    padding: 16px;
         | 
| 96 | 
            +
                    margin-top: 16px;
         | 
| 97 | 
            +
                    position: relative;
         | 
| 98 | 
            +
                    font-family: monospace;
         | 
| 99 | 
            +
                    white-space: pre-wrap;
         | 
| 100 | 
            +
                    word-break: break-word;
         | 
| 101 | 
            +
                    font-size: 14px;
         | 
| 102 | 
            +
                    line-height: 1.5;
         | 
| 103 | 
            +
                }
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                .copy-citation {
         | 
| 106 | 
            +
                    position: absolute;
         | 
| 107 | 
            +
                    top: 8px;
         | 
| 108 | 
            +
                    right: 8px;
         | 
| 109 | 
            +
                    background-color: white;
         | 
| 110 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 111 | 
            +
                    border-radius: var(--radius);
         | 
| 112 | 
            +
                    width: 36px;
         | 
| 113 | 
            +
                    height: 36px;
         | 
| 114 | 
            +
                    display: flex;
         | 
| 115 | 
            +
                    align-items: center;
         | 
| 116 | 
            +
                    justify-content: center;
         | 
| 117 | 
            +
                    cursor: pointer;
         | 
| 118 | 
            +
                    transition: background-color 0.2s;
         | 
| 119 | 
            +
                }
         | 
| 120 | 
            +
             | 
| 121 | 
            +
                .copy-citation:hover {
         | 
| 122 | 
            +
                    background-color: var(--light-gray);
         | 
| 123 | 
            +
                }
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                .copy-citation svg {
         | 
| 126 | 
            +
                    color: var(--text-color);
         | 
| 127 | 
            +
                }
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                .faq-item {
         | 
| 130 | 
            +
                    margin-bottom: 20px;
         | 
| 131 | 
            +
                }
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                .faq-question {
         | 
| 134 | 
            +
                    font-weight: 600;
         | 
| 135 | 
            +
                    margin-bottom: 8px;
         | 
| 136 | 
            +
                    color: var(--primary-color);
         | 
| 137 | 
            +
                }
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                .faq-answer {
         | 
| 140 | 
            +
                    line-height: 1.6;
         | 
| 141 | 
            +
                }
         | 
| 142 | 
            +
                /* Dark mode styles */
         | 
| 143 | 
            +
                @media (prefers-color-scheme: dark) {
         | 
| 144 | 
            +
                    .about-section {
         | 
| 145 | 
            +
                        background-color: var(--light-gray);
         | 
| 146 | 
            +
                        border-color: var(--border-color);
         | 
| 147 | 
            +
                    }
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                    .about-section p {
         | 
| 150 | 
            +
                        color: var(--text-color);
         | 
| 151 | 
            +
                    }
         | 
| 152 | 
            +
             | 
| 153 | 
            +
                    .citation-box {
         | 
| 154 | 
            +
                        background-color: var(--secondary-color);
         | 
| 155 | 
            +
                        border-color: var(--border-color);
         | 
| 156 | 
            +
                    }
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                    .copy-citation {
         | 
| 159 | 
            +
                        background-color: var(--light-gray);
         | 
| 160 | 
            +
                        border-color: var(--border-color);
         | 
| 161 | 
            +
                    }
         | 
| 162 | 
            +
             | 
| 163 | 
            +
                    .copy-citation:hover {
         | 
| 164 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 165 | 
            +
                    }
         | 
| 166 | 
            +
             | 
| 167 | 
            +
                    .copy-citation svg {
         | 
| 168 | 
            +
                        color: var(--text-color);
         | 
| 169 | 
            +
                    }
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                    .faq-question {
         | 
| 172 | 
            +
                        color: var(--primary-color);
         | 
| 173 | 
            +
                    }
         | 
| 174 | 
            +
             | 
| 175 | 
            +
                    .social-icon.icon-x {
         | 
| 176 | 
            +
                        filter: invert(1);
         | 
| 177 | 
            +
                    }
         | 
| 178 | 
            +
                }
         | 
| 179 | 
            +
             | 
| 180 | 
            +
            </style>
         | 
| 181 | 
            +
            {% endblock %}
         | 
| 182 | 
            +
             | 
| 183 | 
            +
            {% block content %}
         | 
| 184 | 
            +
            <div class="about-container">
         | 
| 185 | 
            +
                <div class="about-section">
         | 
| 186 | 
            +
                    <h2>Welcome to TTS Arena 2.0</h2>
         | 
| 187 | 
            +
                    <p>
         | 
| 188 | 
            +
                        TTS Arena evaluates leading speech synthesis models in an interactive, community-driven platform. 
         | 
| 189 | 
            +
                        Inspired by LMsys's <a href="https://chat.lmsys.org/" target="_blank" rel="noopener">Chatbot Arena</a>, we've created 
         | 
| 190 | 
            +
                        a space where anyone can compare and rank text-to-speech technologies through direct, side-by-side evaluation.
         | 
| 191 | 
            +
                    </p>
         | 
| 192 | 
            +
                    <p>
         | 
| 193 | 
            +
                        Our second version now supports conversational models for podcast-like content generation, expanding the arena's scope to reflect the diverse applications of modern speech synthesis.
         | 
| 194 | 
            +
                    </p>
         | 
| 195 | 
            +
                </div>
         | 
| 196 | 
            +
             | 
| 197 | 
            +
                <div class="about-section">
         | 
| 198 | 
            +
                    <h2>Motivation</h2>
         | 
| 199 | 
            +
                    <p>
         | 
| 200 | 
            +
                        The field of speech synthesis has long lacked reliable methods to measure model quality. Traditional 
         | 
| 201 | 
            +
                        metrics like WER (word error rate) often fail to capture the nuances of natural speech, while subjective 
         | 
| 202 | 
            +
                        measures such as MOS (mean opinion score) typically involve small-scale experiments with limited participants.
         | 
| 203 | 
            +
                    </p>
         | 
| 204 | 
            +
                    <p>
         | 
| 205 | 
            +
                        TTS Arena addresses these limitations by inviting the entire community to participate in the evaluation 
         | 
| 206 | 
            +
                        process, making both the opportunity to rank models and the resulting insights accessible to everyone.
         | 
| 207 | 
            +
                    </p>
         | 
| 208 | 
            +
                </div>
         | 
| 209 | 
            +
             | 
| 210 | 
            +
                <div class="about-section">
         | 
| 211 | 
            +
                    <h2>How The Arena Works</h2>
         | 
| 212 | 
            +
                    <p>
         | 
| 213 | 
            +
                        The concept is straightforward: enter text that will be synthesized by two competing models. After 
         | 
| 214 | 
            +
                        listening to both samples, vote for the one that sounds more natural and engaging. To prevent bias, 
         | 
| 215 | 
            +
                        model names are revealed only after your vote is submitted.
         | 
| 216 | 
            +
                    </p>
         | 
| 217 | 
            +
                    <ul class="feature-list">
         | 
| 218 | 
            +
                        <li>Enter your own text or select a random sentence</li>
         | 
| 219 | 
            +
                        <li>Listen to two different TTS models synthesize the same content</li>
         | 
| 220 | 
            +
                        <li>Compare conversational models for podcast-like content</li>
         | 
| 221 | 
            +
                        <li>Vote for the model that sounds more natural, clear, and expressive</li>
         | 
| 222 | 
            +
                        <li>Track model rankings on our leaderboard</li>
         | 
| 223 | 
            +
                    </ul>
         | 
| 224 | 
            +
                </div>
         | 
| 225 | 
            +
             | 
| 226 | 
            +
                <div class="about-section">
         | 
| 227 | 
            +
                    <h2>Frequently Asked Questions</h2>
         | 
| 228 | 
            +
                    <div class="faq-item">
         | 
| 229 | 
            +
                        <div class="faq-question">What happened to the TTS Arena V1 leaderboard?</div>
         | 
| 230 | 
            +
                        <div class="faq-answer">
         | 
| 231 | 
            +
                            The TTS Arena V1 leaderboard is now deprecated. While you can no longer vote on it, the results and leaderboard are still available for reference at <a href="https://huggingface.co/spaces/TTS-AGI/TTS-Arena" target="_blank" rel="noopener">TTS Arena V1</a>. The leaderboard is static and will not change.
         | 
| 232 | 
            +
                        </div>
         | 
| 233 | 
            +
                    </div>
         | 
| 234 | 
            +
                    <div class="faq-item">
         | 
| 235 | 
            +
                        <div class="faq-question">How are models ranked in TTS Arena?</div>
         | 
| 236 | 
            +
                        <div class="faq-answer">
         | 
| 237 | 
            +
                            Models are ranked using an Elo rating system, similar to chess rankings. When you vote for a model, its rating increases while the other model's rating decreases. The amount of change depends on the current ratings of both models.
         | 
| 238 | 
            +
                        </div>
         | 
| 239 | 
            +
                    </div>
         | 
| 240 | 
            +
                    <div class="faq-item">
         | 
| 241 | 
            +
                        <div class="faq-question">Is the TTS Arena V2 leaderboard affected by votes from V1?</div>
         | 
| 242 | 
            +
                        <div class="faq-answer">
         | 
| 243 | 
            +
                            No, the TTS Arena V2 leaderboard is a completely fresh start. Votes from V1 do not affect the V2 leaderboard in any way. All models in V2 start with a clean slate.
         | 
| 244 | 
            +
                        </div>
         | 
| 245 | 
            +
                    </div>
         | 
| 246 | 
            +
                    <div class="faq-item">
         | 
| 247 | 
            +
                        <div class="faq-question">Can I suggest a model to be added to the arena?</div>
         | 
| 248 | 
            +
                        <div class="faq-answer">
         | 
| 249 | 
            +
                            Yes! We welcome suggestions for new models. Please reach out to us through the Hugging Face community or create an issue in our GitHub repository. If you are developing a new model and wish for it to be added anonymously for pre-release evaluation, please <a href="mailto:[email protected]" target="_blank" rel="noopener">reach out to us to discuss</a>.
         | 
| 250 | 
            +
                        </div>
         | 
| 251 | 
            +
                    </div>
         | 
| 252 | 
            +
                    <div class="faq-item">
         | 
| 253 | 
            +
                        <div class="faq-question">How can I contribute to the project?</div>
         | 
| 254 | 
            +
                        <div class="faq-answer">
         | 
| 255 | 
            +
                            You can contribute by voting on models, suggesting improvements, reporting bugs, or even contributing code. Check our GitHub repository for more information on how to get involved.
         | 
| 256 | 
            +
                        </div>
         | 
| 257 | 
            +
                    </div>
         | 
| 258 | 
            +
                    <div class="faq-item">
         | 
| 259 | 
            +
                        <div class="faq-question">What's new in TTS Arena 2.0?</div>
         | 
| 260 | 
            +
                        <div class="faq-answer">
         | 
| 261 | 
            +
                            TTS Arena 2.0 introduces support for conversational models (for podcast-like content), improved UI/UX, and a more robust backend infrastructure for handling more models and votes.
         | 
| 262 | 
            +
                        </div>
         | 
| 263 | 
            +
                    </div>
         | 
| 264 | 
            +
                    <div class="faq-item">
         | 
| 265 | 
            +
                        <div class="faq-question">Do I need to login to use TTS Arena?</div>
         | 
| 266 | 
            +
                        <div class="faq-answer">
         | 
| 267 | 
            +
                            Login is optional and not required to vote. If you choose to login (with Hugging Face), texts you enter will be associated with your account, and you'll have access to a personal leaderboard showing the models you favor the most.
         | 
| 268 | 
            +
                        </div>
         | 
| 269 | 
            +
                    </div>
         | 
| 270 | 
            +
                </div>
         | 
| 271 | 
            +
             | 
| 272 | 
            +
                <div class="about-section">
         | 
| 273 | 
            +
                    <h2>Citation</h2>
         | 
| 274 | 
            +
                    <p>
         | 
| 275 | 
            +
                        If you use TTS Arena in your research, please cite it as follows:
         | 
| 276 | 
            +
                    </p>
         | 
| 277 | 
            +
                    <div class="citation-box" id="citation-text">@misc{tts-arena-v2,
         | 
| 278 | 
            +
                    title        = {TTS Arena 2.0: Benchmarking Text-to-Speech Models in the Wild},
         | 
| 279 | 
            +
                    author       = {mrfakename and Srivastav, Vaibhav and Fourrier, Clémentine and Pouget, Lucain and Lacombe, Yoach and main and Gandhi, Sanchit and Passos, Apolinário and Cuenca, Pedro},
         | 
| 280 | 
            +
                    year         = 2025,
         | 
| 281 | 
            +
                    publisher    = {Hugging Face},
         | 
| 282 | 
            +
                    howpublished = "\url{https://huggingface.co/spaces/TTS-AGI/TTS-Arena-V2}"
         | 
| 283 | 
            +
            }<button class="copy-citation" onclick="copyToClipboard()" title="Copy citation"><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" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button></div>
         | 
| 284 | 
            +
                    <script>
         | 
| 285 | 
            +
                        function copyToClipboard() {
         | 
| 286 | 
            +
                            const text = document.getElementById('citation-text').innerText;
         | 
| 287 | 
            +
                            navigator.clipboard.writeText(text).then(() => {
         | 
| 288 | 
            +
                                const btn = document.querySelector('.copy-citation');
         | 
| 289 | 
            +
                                const originalContent = btn.innerHTML;
         | 
| 290 | 
            +
                                btn.innerHTML = '<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"><path d="M20 6 9 17l-5-5"/></svg>';
         | 
| 291 | 
            +
                                setTimeout(() => {
         | 
| 292 | 
            +
                                    btn.innerHTML = originalContent;
         | 
| 293 | 
            +
                                }, 2000);
         | 
| 294 | 
            +
                            });
         | 
| 295 | 
            +
                        }
         | 
| 296 | 
            +
                    </script>
         | 
| 297 | 
            +
                </div>
         | 
| 298 | 
            +
             | 
| 299 | 
            +
                <div class="about-section">
         | 
| 300 | 
            +
                    <h2>Credits</h2>
         | 
| 301 | 
            +
                    <p>
         | 
| 302 | 
            +
                        Thank you to the following individuals who helped make this project possible:
         | 
| 303 | 
            +
                    </p>
         | 
| 304 | 
            +
                    <div class="credits-list">
         | 
| 305 | 
            +
                        <div class="credit-item">
         | 
| 306 | 
            +
                            <span>Vaibhav (VB) Srivastav</span>
         | 
| 307 | 
            +
                            <div class="social-links">
         | 
| 308 | 
            +
                                <a href="https://twitter.com/reach_vb" target="_blank" rel="noopener" title="Twitter">
         | 
| 309 | 
            +
                                    <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
         | 
| 310 | 
            +
                                </a>
         | 
| 311 | 
            +
                                <a href="https://huggingface.co/reach-vb" target="_blank" rel="noopener" title="Hugging Face">
         | 
| 312 | 
            +
                                    <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
         | 
| 313 | 
            +
                                </a>
         | 
| 314 | 
            +
                            </div>
         | 
| 315 | 
            +
                        </div>
         | 
| 316 | 
            +
                        <div class="credit-item">
         | 
| 317 | 
            +
                            <span>Clémentine Fourrier</span>
         | 
| 318 | 
            +
                            <div class="social-links">
         | 
| 319 | 
            +
                                <a href="https://twitter.com/clefourrier" target="_blank" rel="noopener" title="Twitter">
         | 
| 320 | 
            +
                                    <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
         | 
| 321 | 
            +
                                </a>
         | 
| 322 | 
            +
                                <a href="https://huggingface.co/clefourrier" target="_blank" rel="noopener" title="Hugging Face">
         | 
| 323 | 
            +
                                    <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
         | 
| 324 | 
            +
                                </a>
         | 
| 325 | 
            +
                            </div>
         | 
| 326 | 
            +
                        </div>
         | 
| 327 | 
            +
                        <div class="credit-item">
         | 
| 328 | 
            +
                            <span>Lucain Pouget</span>
         | 
| 329 | 
            +
                            <div class="social-links">
         | 
| 330 | 
            +
                                <a href="https://twitter.com/Wauplin" target="_blank" rel="noopener" title="Twitter">
         | 
| 331 | 
            +
                                    <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
         | 
| 332 | 
            +
                                </a>
         | 
| 333 | 
            +
                                <a href="https://huggingface.co/Wauplin" target="_blank" rel="noopener" title="Hugging Face">
         | 
| 334 | 
            +
                                    <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
         | 
| 335 | 
            +
                                </a>
         | 
| 336 | 
            +
                            </div>
         | 
| 337 | 
            +
                        </div>
         | 
| 338 | 
            +
                        <div class="credit-item">
         | 
| 339 | 
            +
                            <span>Yoach Lacombe</span>
         | 
| 340 | 
            +
                            <div class="social-links">
         | 
| 341 | 
            +
                                <a href="https://twitter.com/yoachlacombe" target="_blank" rel="noopener" title="Twitter">
         | 
| 342 | 
            +
                                    <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
         | 
| 343 | 
            +
                                </a>
         | 
| 344 | 
            +
                                <a href="https://huggingface.co/ylacombe" target="_blank" rel="noopener" title="Hugging Face">
         | 
| 345 | 
            +
                                    <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
         | 
| 346 | 
            +
                                </a>
         | 
| 347 | 
            +
                            </div>
         | 
| 348 | 
            +
                        </div>
         | 
| 349 | 
            +
                        <div class="credit-item">
         | 
| 350 | 
            +
                            <span>Main Horse</span>
         | 
| 351 | 
            +
                            <div class="social-links">
         | 
| 352 | 
            +
                                <a href="https://twitter.com/main_horse" target="_blank" rel="noopener" title="Twitter">
         | 
| 353 | 
            +
                                    <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
         | 
| 354 | 
            +
                                </a>
         | 
| 355 | 
            +
                                <a href="https://huggingface.co/main-horse" target="_blank" rel="noopener" title="Hugging Face">
         | 
| 356 | 
            +
                                    <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
         | 
| 357 | 
            +
                                </a>
         | 
| 358 | 
            +
                            </div>
         | 
| 359 | 
            +
                        </div>
         | 
| 360 | 
            +
                        <div class="credit-item">
         | 
| 361 | 
            +
                            <span>Sanchit Gandhi</span>
         | 
| 362 | 
            +
                            <div class="social-links">
         | 
| 363 | 
            +
                                <a href="https://twitter.com/sanchitgandhi99" target="_blank" rel="noopener" title="Twitter">
         | 
| 364 | 
            +
                                    <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
         | 
| 365 | 
            +
                                </a>
         | 
| 366 | 
            +
                                <a href="https://huggingface.co/sanchit-gandhi" target="_blank" rel="noopener" title="Hugging Face">
         | 
| 367 | 
            +
                                    <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
         | 
| 368 | 
            +
                                </a>
         | 
| 369 | 
            +
                            </div>
         | 
| 370 | 
            +
                        </div>
         | 
| 371 | 
            +
                        <div class="credit-item">
         | 
| 372 | 
            +
                            <span>Apolinário Passos</span>
         | 
| 373 | 
            +
                            <div class="social-links">
         | 
| 374 | 
            +
                                <a href="https://twitter.com/multimodalart" target="_blank" rel="noopener" title="Twitter">
         | 
| 375 | 
            +
                                    <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
         | 
| 376 | 
            +
                                </a>
         | 
| 377 | 
            +
                                <a href="https://huggingface.co/multimodalart" target="_blank" rel="noopener" title="Hugging Face">
         | 
| 378 | 
            +
                                    <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
         | 
| 379 | 
            +
                                </a>
         | 
| 380 | 
            +
                            </div>
         | 
| 381 | 
            +
                        </div>
         | 
| 382 | 
            +
                        <div class="credit-item">
         | 
| 383 | 
            +
                            <span>Pedro Cuenca</span>
         | 
| 384 | 
            +
                            <div class="social-links">
         | 
| 385 | 
            +
                                <a href="https://twitter.com/pcuenq" target="_blank" rel="noopener" title="Twitter">
         | 
| 386 | 
            +
                                    <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
         | 
| 387 | 
            +
                                </a>
         | 
| 388 | 
            +
                                <a href="https://huggingface.co/pcuenq" target="_blank" rel="noopener" title="Hugging Face">
         | 
| 389 | 
            +
                                    <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
         | 
| 390 | 
            +
                                </a>
         | 
| 391 | 
            +
                            </div>
         | 
| 392 | 
            +
                        </div>
         | 
| 393 | 
            +
                    </div>
         | 
| 394 | 
            +
                </div>
         | 
| 395 | 
            +
             | 
| 396 | 
            +
                <div class="about-section">
         | 
| 397 | 
            +
                    <h2>Privacy Statement</h2>
         | 
| 398 | 
            +
                    <p>
         | 
| 399 | 
            +
                        We may store text you enter and generated audio. If you are logged in, we may associate your votes with your Hugging Face username. 
         | 
| 400 | 
            +
                        You agree that we may collect, share, and/or publish any data you input for research and/or 
         | 
| 401 | 
            +
                        commercial purposes.
         | 
| 402 | 
            +
                    </p>
         | 
| 403 | 
            +
                </div>
         | 
| 404 | 
            +
             | 
| 405 | 
            +
                <div class="about-section">
         | 
| 406 | 
            +
                    <h2>License</h2>
         | 
| 407 | 
            +
                    <p>
         | 
| 408 | 
            +
                        Generated audio clips cannot be redistributed and may be used for personal, non-commercial use only.
         | 
| 409 | 
            +
                        The code for the Arena is licensed under the Zlib license.
         | 
| 410 | 
            +
                        Random sentences are sourced from a filtered subset of the 
         | 
| 411 | 
            +
                        <a href="https://www.cs.columbia.edu/~hgs/audio/harvard.html" target="_blank" rel="noopener">Harvard Sentences</a>.
         | 
| 412 | 
            +
                    </p>
         | 
| 413 | 
            +
                </div>
         | 
| 414 | 
            +
            </div>
         | 
| 415 | 
            +
            {% endblock %}
         | 
    	
        templates/admin/activity.html
    ADDED
    
    | @@ -0,0 +1,139 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "admin/base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block admin_content %}
         | 
| 4 | 
            +
            <div class="admin-header">
         | 
| 5 | 
            +
                <div class="admin-title">Activity Monitoring</div>
         | 
| 6 | 
            +
            </div>
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            <div class="admin-stats">
         | 
| 9 | 
            +
                <div class="stat-card">
         | 
| 10 | 
            +
                    <div class="stat-title">Active TTS Sessions</div>
         | 
| 11 | 
            +
                    <div class="stat-value">{{ tts_session_count }}</div>
         | 
| 12 | 
            +
                </div>
         | 
| 13 | 
            +
                <div class="stat-card">
         | 
| 14 | 
            +
                    <div class="stat-title">Active Conversational Sessions</div>
         | 
| 15 | 
            +
                    <div class="stat-value">{{ conversational_session_count }}</div>
         | 
| 16 | 
            +
                </div>
         | 
| 17 | 
            +
            </div>
         | 
| 18 | 
            +
             | 
| 19 | 
            +
            <div class="admin-card">
         | 
| 20 | 
            +
                <div class="admin-card-header">
         | 
| 21 | 
            +
                    <div class="admin-card-title">Activity Past 24 Hours</div>
         | 
| 22 | 
            +
                </div>
         | 
| 23 | 
            +
                <canvas id="hourlyActivityChart" height="250"></canvas>
         | 
| 24 | 
            +
            </div>
         | 
| 25 | 
            +
             | 
| 26 | 
            +
            <div class="admin-card">
         | 
| 27 | 
            +
                <div class="admin-card-header">
         | 
| 28 | 
            +
                    <div class="admin-card-title">Recent TTS Votes</div>
         | 
| 29 | 
            +
                </div>
         | 
| 30 | 
            +
                <div class="table-responsive">
         | 
| 31 | 
            +
                    <table class="admin-table">
         | 
| 32 | 
            +
                        <thead>
         | 
| 33 | 
            +
                            <tr>
         | 
| 34 | 
            +
                                <th>Time</th>
         | 
| 35 | 
            +
                                <th>User</th>
         | 
| 36 | 
            +
                                <th>Chosen Model</th>
         | 
| 37 | 
            +
                                <th>Rejected Model</th>
         | 
| 38 | 
            +
                                <th>Text</th>
         | 
| 39 | 
            +
                            </tr>
         | 
| 40 | 
            +
                        </thead>
         | 
| 41 | 
            +
                        <tbody>
         | 
| 42 | 
            +
                            {% for vote in recent_tts_votes %}
         | 
| 43 | 
            +
                            <tr>
         | 
| 44 | 
            +
                                <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
         | 
| 45 | 
            +
                                <td>
         | 
| 46 | 
            +
                                    {% if vote.user %}
         | 
| 47 | 
            +
                                        <a href="{{ url_for('admin.user_detail', user_id=vote.user.id) }}">{{ vote.user.username }}</a>
         | 
| 48 | 
            +
                                    {% else %}
         | 
| 49 | 
            +
                                        Anonymous
         | 
| 50 | 
            +
                                    {% endif %}
         | 
| 51 | 
            +
                                </td>
         | 
| 52 | 
            +
                                <td>{{ vote.chosen.name }}</td>
         | 
| 53 | 
            +
                                <td>{{ vote.rejected.name }}</td>
         | 
| 54 | 
            +
                                <td>
         | 
| 55 | 
            +
                                    <div class="text-truncate" title="{{ vote.text }}">
         | 
| 56 | 
            +
                                        {{ vote.text }}
         | 
| 57 | 
            +
                                    </div>
         | 
| 58 | 
            +
                                </td>
         | 
| 59 | 
            +
                            </tr>
         | 
| 60 | 
            +
                            {% endfor %}
         | 
| 61 | 
            +
                        </tbody>
         | 
| 62 | 
            +
                    </table>
         | 
| 63 | 
            +
                </div>
         | 
| 64 | 
            +
            </div>
         | 
| 65 | 
            +
             | 
| 66 | 
            +
            <div class="admin-card">
         | 
| 67 | 
            +
                <div class="admin-card-header">
         | 
| 68 | 
            +
                    <div class="admin-card-title">Recent Conversational Votes</div>
         | 
| 69 | 
            +
                </div>
         | 
| 70 | 
            +
                <div class="table-responsive">
         | 
| 71 | 
            +
                    <table class="admin-table">
         | 
| 72 | 
            +
                        <thead>
         | 
| 73 | 
            +
                            <tr>
         | 
| 74 | 
            +
                                <th>Time</th>
         | 
| 75 | 
            +
                                <th>User</th>
         | 
| 76 | 
            +
                                <th>Chosen Model</th>
         | 
| 77 | 
            +
                                <th>Rejected Model</th>
         | 
| 78 | 
            +
                                <th>Text Preview</th>
         | 
| 79 | 
            +
                            </tr>
         | 
| 80 | 
            +
                        </thead>
         | 
| 81 | 
            +
                        <tbody>
         | 
| 82 | 
            +
                            {% for vote in recent_conv_votes %}
         | 
| 83 | 
            +
                            <tr>
         | 
| 84 | 
            +
                                <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
         | 
| 85 | 
            +
                                <td>
         | 
| 86 | 
            +
                                    {% if vote.user %}
         | 
| 87 | 
            +
                                        <a href="{{ url_for('admin.user_detail', user_id=vote.user.id) }}">{{ vote.user.username }}</a>
         | 
| 88 | 
            +
                                    {% else %}
         | 
| 89 | 
            +
                                        Anonymous
         | 
| 90 | 
            +
                                    {% endif %}
         | 
| 91 | 
            +
                                </td>
         | 
| 92 | 
            +
                                <td>{{ vote.chosen.name }}</td>
         | 
| 93 | 
            +
                                <td>{{ vote.rejected.name }}</td>
         | 
| 94 | 
            +
                                <td>
         | 
| 95 | 
            +
                                    <div class="text-truncate" title="{{ vote.text }}">
         | 
| 96 | 
            +
                                        {{ vote.text }}
         | 
| 97 | 
            +
                                    </div>
         | 
| 98 | 
            +
                                </td>
         | 
| 99 | 
            +
                            </tr>
         | 
| 100 | 
            +
                            {% endfor %}
         | 
| 101 | 
            +
                        </tbody>
         | 
| 102 | 
            +
                    </table>
         | 
| 103 | 
            +
                </div>
         | 
| 104 | 
            +
            </div>
         | 
| 105 | 
            +
             | 
| 106 | 
            +
            <script>
         | 
| 107 | 
            +
            document.addEventListener('DOMContentLoaded', function() {
         | 
| 108 | 
            +
                const hourlyData = {{ hourly_data|safe }};
         | 
| 109 | 
            +
                
         | 
| 110 | 
            +
                // Hourly activity chart
         | 
| 111 | 
            +
                const hourlyActivityCtx = document.getElementById('hourlyActivityChart').getContext('2d');
         | 
| 112 | 
            +
                new Chart(hourlyActivityCtx, {
         | 
| 113 | 
            +
                    type: 'bar',
         | 
| 114 | 
            +
                    data: {
         | 
| 115 | 
            +
                        labels: hourlyData.labels,
         | 
| 116 | 
            +
                        datasets: [{
         | 
| 117 | 
            +
                            label: 'Votes per Hour',
         | 
| 118 | 
            +
                            data: hourlyData.counts,
         | 
| 119 | 
            +
                            backgroundColor: 'rgba(80, 70, 229, 0.7)',
         | 
| 120 | 
            +
                            borderColor: 'rgba(80, 70, 229, 1)',
         | 
| 121 | 
            +
                            borderWidth: 1
         | 
| 122 | 
            +
                        }]
         | 
| 123 | 
            +
                    },
         | 
| 124 | 
            +
                    options: {
         | 
| 125 | 
            +
                        responsive: true,
         | 
| 126 | 
            +
                        maintainAspectRatio: false,
         | 
| 127 | 
            +
                        scales: {
         | 
| 128 | 
            +
                            yAxes: [{
         | 
| 129 | 
            +
                                ticks: {
         | 
| 130 | 
            +
                                    beginAtZero: true,
         | 
| 131 | 
            +
                                    precision: 0
         | 
| 132 | 
            +
                                }
         | 
| 133 | 
            +
                            }]
         | 
| 134 | 
            +
                        }
         | 
| 135 | 
            +
                    }
         | 
| 136 | 
            +
                });
         | 
| 137 | 
            +
            });
         | 
| 138 | 
            +
            </script>
         | 
| 139 | 
            +
            {% endblock %} 
         | 
    	
        templates/admin/base.html
    ADDED
    
    | @@ -0,0 +1,539 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block title %}Admin Panel - TTS Arena{% endblock %}
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            {% block extra_head %}
         | 
| 6 | 
            +
            <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.min.css">
         | 
| 7 | 
            +
            <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.bundle.min.js"></script>
         | 
| 8 | 
            +
            <style>
         | 
| 9 | 
            +
                .admin-container {
         | 
| 10 | 
            +
                    width: 100%;
         | 
| 11 | 
            +
                }
         | 
| 12 | 
            +
                
         | 
| 13 | 
            +
                /* Horizontal navigation tabs */
         | 
| 14 | 
            +
                .admin-nav {
         | 
| 15 | 
            +
                    display: flex;
         | 
| 16 | 
            +
                    overflow-x: auto;
         | 
| 17 | 
            +
                    white-space: nowrap;
         | 
| 18 | 
            +
                    margin-bottom: 24px;
         | 
| 19 | 
            +
                    padding-bottom: 8px;
         | 
| 20 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 21 | 
            +
                    -ms-overflow-style: none; /* Hide scrollbar IE and Edge */
         | 
| 22 | 
            +
                    scrollbar-width: none; /* Hide scrollbar Firefox */
         | 
| 23 | 
            +
                }
         | 
| 24 | 
            +
                
         | 
| 25 | 
            +
                /* Hide scrollbar for Chrome, Safari and Opera */
         | 
| 26 | 
            +
                .admin-nav::-webkit-scrollbar {
         | 
| 27 | 
            +
                    display: none;
         | 
| 28 | 
            +
                }
         | 
| 29 | 
            +
                
         | 
| 30 | 
            +
                .admin-nav-item {
         | 
| 31 | 
            +
                    display: flex;
         | 
| 32 | 
            +
                    align-items: center;
         | 
| 33 | 
            +
                    padding: 10px 16px;
         | 
| 34 | 
            +
                    margin-right: 8px;
         | 
| 35 | 
            +
                    border-radius: var(--radius);
         | 
| 36 | 
            +
                    cursor: pointer;
         | 
| 37 | 
            +
                    transition: all 0.2s;
         | 
| 38 | 
            +
                    color: var(--text-color);
         | 
| 39 | 
            +
                    text-decoration: none;
         | 
| 40 | 
            +
                    font-size: 14px;
         | 
| 41 | 
            +
                    position: relative;
         | 
| 42 | 
            +
                }
         | 
| 43 | 
            +
                
         | 
| 44 | 
            +
                .admin-nav-item.active {
         | 
| 45 | 
            +
                    color: var(--primary-color);
         | 
| 46 | 
            +
                    font-weight: 500;
         | 
| 47 | 
            +
                }
         | 
| 48 | 
            +
                
         | 
| 49 | 
            +
                .admin-nav-item.active::after {
         | 
| 50 | 
            +
                    content: '';
         | 
| 51 | 
            +
                    position: absolute;
         | 
| 52 | 
            +
                    bottom: -9px;
         | 
| 53 | 
            +
                    left: 0;
         | 
| 54 | 
            +
                    width: 100%;
         | 
| 55 | 
            +
                    height: 3px;
         | 
| 56 | 
            +
                    background-color: var(--primary-color);
         | 
| 57 | 
            +
                    border-radius: 3px 3px 0 0;
         | 
| 58 | 
            +
                }
         | 
| 59 | 
            +
                
         | 
| 60 | 
            +
                .admin-nav-item:hover:not(.active) {
         | 
| 61 | 
            +
                    background-color: rgba(0, 0, 0, 0.05);
         | 
| 62 | 
            +
                }
         | 
| 63 | 
            +
                
         | 
| 64 | 
            +
                .admin-nav-item svg {
         | 
| 65 | 
            +
                    margin-right: 8px;
         | 
| 66 | 
            +
                    width: 16px;
         | 
| 67 | 
            +
                    height: 16px;
         | 
| 68 | 
            +
                }
         | 
| 69 | 
            +
                
         | 
| 70 | 
            +
                .admin-content {
         | 
| 71 | 
            +
                    width: 100%;
         | 
| 72 | 
            +
                    padding: 20px;
         | 
| 73 | 
            +
                }
         | 
| 74 | 
            +
                
         | 
| 75 | 
            +
                .admin-header {
         | 
| 76 | 
            +
                    display: flex;
         | 
| 77 | 
            +
                    justify-content: space-between;
         | 
| 78 | 
            +
                    align-items: center;
         | 
| 79 | 
            +
                    margin-bottom: 24px;
         | 
| 80 | 
            +
                }
         | 
| 81 | 
            +
                
         | 
| 82 | 
            +
                .admin-title {
         | 
| 83 | 
            +
                    font-size: 24px;
         | 
| 84 | 
            +
                    font-weight: 600;
         | 
| 85 | 
            +
                    color: var(--primary-color);
         | 
| 86 | 
            +
                }
         | 
| 87 | 
            +
                
         | 
| 88 | 
            +
                .admin-card {
         | 
| 89 | 
            +
                    background-color: white;
         | 
| 90 | 
            +
                    border-radius: var(--radius);
         | 
| 91 | 
            +
                    box-shadow: var(--shadow);
         | 
| 92 | 
            +
                    padding: 20px;
         | 
| 93 | 
            +
                    margin-bottom: 24px;
         | 
| 94 | 
            +
                    overflow: auto; /* Add horizontal scrolling for content */
         | 
| 95 | 
            +
                }
         | 
| 96 | 
            +
                
         | 
| 97 | 
            +
                .admin-card-header {
         | 
| 98 | 
            +
                    display: flex;
         | 
| 99 | 
            +
                    justify-content: space-between;
         | 
| 100 | 
            +
                    align-items: center;
         | 
| 101 | 
            +
                    margin-bottom: 16px;
         | 
| 102 | 
            +
                }
         | 
| 103 | 
            +
                
         | 
| 104 | 
            +
                .admin-card-title {
         | 
| 105 | 
            +
                    font-size: 18px;
         | 
| 106 | 
            +
                    font-weight: 600;
         | 
| 107 | 
            +
                    color: var(--text-color);
         | 
| 108 | 
            +
                }
         | 
| 109 | 
            +
                
         | 
| 110 | 
            +
                .admin-stats {
         | 
| 111 | 
            +
                    display: grid;
         | 
| 112 | 
            +
                    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
         | 
| 113 | 
            +
                    gap: 16px;
         | 
| 114 | 
            +
                    margin-bottom: 24px;
         | 
| 115 | 
            +
                }
         | 
| 116 | 
            +
                
         | 
| 117 | 
            +
                .stat-card {
         | 
| 118 | 
            +
                    background-color: white;
         | 
| 119 | 
            +
                    border-radius: var(--radius);
         | 
| 120 | 
            +
                    box-shadow: var(--shadow);
         | 
| 121 | 
            +
                    padding: 16px;
         | 
| 122 | 
            +
                    text-align: center;
         | 
| 123 | 
            +
                }
         | 
| 124 | 
            +
                
         | 
| 125 | 
            +
                .stat-title {
         | 
| 126 | 
            +
                    font-size: 14px;
         | 
| 127 | 
            +
                    color: #666;
         | 
| 128 | 
            +
                    margin-bottom: 8px;
         | 
| 129 | 
            +
                }
         | 
| 130 | 
            +
                
         | 
| 131 | 
            +
                .stat-value {
         | 
| 132 | 
            +
                    font-size: 24px;
         | 
| 133 | 
            +
                    font-weight: 600;
         | 
| 134 | 
            +
                    color: var(--primary-color);
         | 
| 135 | 
            +
                }
         | 
| 136 | 
            +
                
         | 
| 137 | 
            +
                /* Improved table styles with responsiveness */
         | 
| 138 | 
            +
                .table-responsive {
         | 
| 139 | 
            +
                    width: 100%;
         | 
| 140 | 
            +
                    overflow-x: auto;
         | 
| 141 | 
            +
                    -webkit-overflow-scrolling: touch;
         | 
| 142 | 
            +
                    margin-bottom: 1rem;
         | 
| 143 | 
            +
                }
         | 
| 144 | 
            +
                
         | 
| 145 | 
            +
                .admin-table {
         | 
| 146 | 
            +
                    width: 100%;
         | 
| 147 | 
            +
                    border-collapse: separate;
         | 
| 148 | 
            +
                    border-spacing: 0;
         | 
| 149 | 
            +
                    border-radius: var(--radius);
         | 
| 150 | 
            +
                    overflow: hidden;
         | 
| 151 | 
            +
                    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
         | 
| 152 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 153 | 
            +
                    min-width: 600px; /* Ensures table doesn't get too squished */
         | 
| 154 | 
            +
                }
         | 
| 155 | 
            +
                
         | 
| 156 | 
            +
                .admin-table th, .admin-table td {
         | 
| 157 | 
            +
                    padding: 12px 16px;
         | 
| 158 | 
            +
                    text-align: left;
         | 
| 159 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 160 | 
            +
                }
         | 
| 161 | 
            +
                
         | 
| 162 | 
            +
                .admin-table th {
         | 
| 163 | 
            +
                    font-weight: 600;
         | 
| 164 | 
            +
                    background-color: var(--secondary-color);
         | 
| 165 | 
            +
                    position: sticky;
         | 
| 166 | 
            +
                    top: 0;
         | 
| 167 | 
            +
                    z-index: 1;
         | 
| 168 | 
            +
                    font-size: 13px;
         | 
| 169 | 
            +
                }
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                .admin-table tr:last-child td {
         | 
| 172 | 
            +
                    border-bottom: none;
         | 
| 173 | 
            +
                }
         | 
| 174 | 
            +
                
         | 
| 175 | 
            +
                .admin-table tr:hover {
         | 
| 176 | 
            +
                    background-color: rgba(0, 0, 0, 0.02);
         | 
| 177 | 
            +
                }
         | 
| 178 | 
            +
                
         | 
| 179 | 
            +
                /* Action buttons in tables */
         | 
| 180 | 
            +
                .action-btn {
         | 
| 181 | 
            +
                    display: inline-block;
         | 
| 182 | 
            +
                    padding: 6px 12px;
         | 
| 183 | 
            +
                    font-size: 13px;
         | 
| 184 | 
            +
                    border-radius: var(--radius);
         | 
| 185 | 
            +
                    text-decoration: none;
         | 
| 186 | 
            +
                    color: var(--text-color);
         | 
| 187 | 
            +
                    background-color: var(--secondary-color);
         | 
| 188 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 189 | 
            +
                    transition: all 0.2s;
         | 
| 190 | 
            +
                }
         | 
| 191 | 
            +
                
         | 
| 192 | 
            +
                .action-btn:hover {
         | 
| 193 | 
            +
                    background-color: #e0e0e0;
         | 
| 194 | 
            +
                }
         | 
| 195 | 
            +
                
         | 
| 196 | 
            +
                /* Enhanced form styles */
         | 
| 197 | 
            +
                .admin-form {
         | 
| 198 | 
            +
                    max-width: 700px;
         | 
| 199 | 
            +
                }
         | 
| 200 | 
            +
                
         | 
| 201 | 
            +
                .form-group {
         | 
| 202 | 
            +
                    margin-bottom: 20px;
         | 
| 203 | 
            +
                }
         | 
| 204 | 
            +
                
         | 
| 205 | 
            +
                .form-group label {
         | 
| 206 | 
            +
                    display: block;
         | 
| 207 | 
            +
                    margin-bottom: 8px;
         | 
| 208 | 
            +
                    font-weight: 500;
         | 
| 209 | 
            +
                    color: var(--text-color);
         | 
| 210 | 
            +
                }
         | 
| 211 | 
            +
             | 
| 212 | 
            +
                .form-group small {
         | 
| 213 | 
            +
                    display: block;
         | 
| 214 | 
            +
                    margin-top: 4px;
         | 
| 215 | 
            +
                    color: #666;
         | 
| 216 | 
            +
                    font-size: 12px;
         | 
| 217 | 
            +
                }
         | 
| 218 | 
            +
                
         | 
| 219 | 
            +
                .form-control {
         | 
| 220 | 
            +
                    width: 100%;
         | 
| 221 | 
            +
                    padding: 12px;
         | 
| 222 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 223 | 
            +
                    border-radius: var(--radius);
         | 
| 224 | 
            +
                    font-family: 'Inter', sans-serif;
         | 
| 225 | 
            +
                    background-color: white;
         | 
| 226 | 
            +
                    transition: border-color 0.2s, box-shadow 0.2s;
         | 
| 227 | 
            +
                }
         | 
| 228 | 
            +
                
         | 
| 229 | 
            +
                .form-control:focus {
         | 
| 230 | 
            +
                    border-color: var(--primary-color);
         | 
| 231 | 
            +
                    box-shadow: 0 0 0 2px rgba(80, 70, 229, 0.25);
         | 
| 232 | 
            +
                    outline: none;
         | 
| 233 | 
            +
                }
         | 
| 234 | 
            +
                
         | 
| 235 | 
            +
                /* Custom checkbox styles */
         | 
| 236 | 
            +
                .form-check {
         | 
| 237 | 
            +
                    display: flex;
         | 
| 238 | 
            +
                    align-items: center;
         | 
| 239 | 
            +
                    margin-bottom: 16px;
         | 
| 240 | 
            +
                    position: relative;
         | 
| 241 | 
            +
                    padding-left: 30px;
         | 
| 242 | 
            +
                    cursor: pointer;
         | 
| 243 | 
            +
                }
         | 
| 244 | 
            +
                
         | 
| 245 | 
            +
                .form-check input {
         | 
| 246 | 
            +
                    position: absolute;
         | 
| 247 | 
            +
                    opacity: 0;
         | 
| 248 | 
            +
                    cursor: pointer;
         | 
| 249 | 
            +
                    height: 0;
         | 
| 250 | 
            +
                    width: 0;
         | 
| 251 | 
            +
                }
         | 
| 252 | 
            +
                
         | 
| 253 | 
            +
                .form-check label {
         | 
| 254 | 
            +
                    margin-bottom: 0;
         | 
| 255 | 
            +
                    cursor: pointer;
         | 
| 256 | 
            +
                }
         | 
| 257 | 
            +
                
         | 
| 258 | 
            +
                .checkmark {
         | 
| 259 | 
            +
                    position: absolute;
         | 
| 260 | 
            +
                    top: 2px;
         | 
| 261 | 
            +
                    left: 0;
         | 
| 262 | 
            +
                    height: 18px;
         | 
| 263 | 
            +
                    width: 18px;
         | 
| 264 | 
            +
                    background-color: white;
         | 
| 265 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 266 | 
            +
                    border-radius: 4px;
         | 
| 267 | 
            +
                }
         | 
| 268 | 
            +
                
         | 
| 269 | 
            +
                .form-check:hover input ~ .checkmark {
         | 
| 270 | 
            +
                    background-color: #f5f5f5;
         | 
| 271 | 
            +
                }
         | 
| 272 | 
            +
                
         | 
| 273 | 
            +
                .form-check input:checked ~ .checkmark {
         | 
| 274 | 
            +
                    background-color: var(--primary-color);
         | 
| 275 | 
            +
                    border-color: var(--primary-color);
         | 
| 276 | 
            +
                }
         | 
| 277 | 
            +
                
         | 
| 278 | 
            +
                .checkmark:after {
         | 
| 279 | 
            +
                    content: "";
         | 
| 280 | 
            +
                    position: absolute;
         | 
| 281 | 
            +
                    display: none;
         | 
| 282 | 
            +
                }
         | 
| 283 | 
            +
                
         | 
| 284 | 
            +
                .form-check input:checked ~ .checkmark:after {
         | 
| 285 | 
            +
                    display: block;
         | 
| 286 | 
            +
                }
         | 
| 287 | 
            +
                
         | 
| 288 | 
            +
                .form-check .checkmark:after {
         | 
| 289 | 
            +
                    left: 6px;
         | 
| 290 | 
            +
                    top: 2px;
         | 
| 291 | 
            +
                    width: 4px;
         | 
| 292 | 
            +
                    height: 9px;
         | 
| 293 | 
            +
                    border: solid white;
         | 
| 294 | 
            +
                    border-width: 0 2px 2px 0;
         | 
| 295 | 
            +
                    transform: rotate(45deg);
         | 
| 296 | 
            +
                }
         | 
| 297 | 
            +
                
         | 
| 298 | 
            +
                .user-info {
         | 
| 299 | 
            +
                    background-color: var(--light-gray);
         | 
| 300 | 
            +
                    padding: 16px;
         | 
| 301 | 
            +
                    border-radius: var(--radius);
         | 
| 302 | 
            +
                    margin-bottom: 16px;
         | 
| 303 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 304 | 
            +
                }
         | 
| 305 | 
            +
                
         | 
| 306 | 
            +
                .user-info p {
         | 
| 307 | 
            +
                    margin-bottom: 8px;
         | 
| 308 | 
            +
                }
         | 
| 309 | 
            +
                
         | 
| 310 | 
            +
                .btn-primary {
         | 
| 311 | 
            +
                    background-color: var(--primary-color);
         | 
| 312 | 
            +
                    color: white;
         | 
| 313 | 
            +
                    border: none;
         | 
| 314 | 
            +
                    padding: 12px 20px;
         | 
| 315 | 
            +
                    border-radius: var(--radius);
         | 
| 316 | 
            +
                    cursor: pointer;
         | 
| 317 | 
            +
                    font-weight: 500;
         | 
| 318 | 
            +
                    text-decoration: none;
         | 
| 319 | 
            +
                    transition: background-color 0.2s;
         | 
| 320 | 
            +
                }
         | 
| 321 | 
            +
                
         | 
| 322 | 
            +
                .btn-primary:hover {
         | 
| 323 | 
            +
                    background-color: #4038c7;
         | 
| 324 | 
            +
                }
         | 
| 325 | 
            +
                
         | 
| 326 | 
            +
                .btn-secondary {
         | 
| 327 | 
            +
                    background-color: var(--secondary-color);
         | 
| 328 | 
            +
                    color: var(--text-color);
         | 
| 329 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 330 | 
            +
                    padding: 12px 20px;
         | 
| 331 | 
            +
                    border-radius: var(--radius);
         | 
| 332 | 
            +
                    cursor: pointer;
         | 
| 333 | 
            +
                    font-weight: 500;
         | 
| 334 | 
            +
                    text-decoration: none;
         | 
| 335 | 
            +
                    transition: background-color 0.2s;
         | 
| 336 | 
            +
                }
         | 
| 337 | 
            +
                
         | 
| 338 | 
            +
                .btn-secondary:hover {
         | 
| 339 | 
            +
                    background-color: #e0e0e0;
         | 
| 340 | 
            +
                }
         | 
| 341 | 
            +
                
         | 
| 342 | 
            +
                /* Badge styles */
         | 
| 343 | 
            +
                .badge {
         | 
| 344 | 
            +
                    display: inline-block;
         | 
| 345 | 
            +
                    padding: 4px 8px;
         | 
| 346 | 
            +
                    border-radius: 4px;
         | 
| 347 | 
            +
                    font-size: 12px;
         | 
| 348 | 
            +
                    font-weight: 500;
         | 
| 349 | 
            +
                }
         | 
| 350 | 
            +
                
         | 
| 351 | 
            +
                .badge-primary {
         | 
| 352 | 
            +
                    background-color: var(--primary-color);
         | 
| 353 | 
            +
                    color: white;
         | 
| 354 | 
            +
                }
         | 
| 355 | 
            +
                
         | 
| 356 | 
            +
                .badge-secondary {
         | 
| 357 | 
            +
                    background-color: var(--secondary-color);
         | 
| 358 | 
            +
                    color: var(--text-color);
         | 
| 359 | 
            +
                }
         | 
| 360 | 
            +
                
         | 
| 361 | 
            +
                .pagination {
         | 
| 362 | 
            +
                    display: flex;
         | 
| 363 | 
            +
                    justify-content: center;
         | 
| 364 | 
            +
                    list-style: none;
         | 
| 365 | 
            +
                    margin-top: 24px;
         | 
| 366 | 
            +
                }
         | 
| 367 | 
            +
                
         | 
| 368 | 
            +
                .pagination li {
         | 
| 369 | 
            +
                    margin: 0 4px;
         | 
| 370 | 
            +
                }
         | 
| 371 | 
            +
                
         | 
| 372 | 
            +
                .pagination li a {
         | 
| 373 | 
            +
                    display: block;
         | 
| 374 | 
            +
                    padding: 8px 12px;
         | 
| 375 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 376 | 
            +
                    border-radius: var(--radius);
         | 
| 377 | 
            +
                    color: var(--text-color);
         | 
| 378 | 
            +
                    text-decoration: none;
         | 
| 379 | 
            +
                }
         | 
| 380 | 
            +
                
         | 
| 381 | 
            +
                .pagination li.active a {
         | 
| 382 | 
            +
                    background-color: var(--primary-color);
         | 
| 383 | 
            +
                    color: white;
         | 
| 384 | 
            +
                    border-color: var(--primary-color);
         | 
| 385 | 
            +
                }
         | 
| 386 | 
            +
                
         | 
| 387 | 
            +
                /* Responsive adjustments */
         | 
| 388 | 
            +
                @media (max-width: 768px) {
         | 
| 389 | 
            +
                    .admin-content {
         | 
| 390 | 
            +
                        padding: 16px 12px;
         | 
| 391 | 
            +
                    }
         | 
| 392 | 
            +
                    
         | 
| 393 | 
            +
                    .admin-stats {
         | 
| 394 | 
            +
                        grid-template-columns: 1fr 1fr;
         | 
| 395 | 
            +
                    }
         | 
| 396 | 
            +
                    
         | 
| 397 | 
            +
                    .admin-header {
         | 
| 398 | 
            +
                        flex-direction: column;
         | 
| 399 | 
            +
                        align-items: flex-start;
         | 
| 400 | 
            +
                        gap: 12px;
         | 
| 401 | 
            +
                    }
         | 
| 402 | 
            +
                    
         | 
| 403 | 
            +
                    .admin-card {
         | 
| 404 | 
            +
                        padding: 15px 10px;
         | 
| 405 | 
            +
                    }
         | 
| 406 | 
            +
                    
         | 
| 407 | 
            +
                    .admin-table {
         | 
| 408 | 
            +
                        font-size: 13px;
         | 
| 409 | 
            +
                    }
         | 
| 410 | 
            +
                    
         | 
| 411 | 
            +
                    .admin-table th, .admin-table td {
         | 
| 412 | 
            +
                        padding: 8px 10px;
         | 
| 413 | 
            +
                    }
         | 
| 414 | 
            +
                    
         | 
| 415 | 
            +
                    .action-btn {
         | 
| 416 | 
            +
                        padding: 4px 8px;
         | 
| 417 | 
            +
                        font-size: 12px;
         | 
| 418 | 
            +
                    }
         | 
| 419 | 
            +
                    
         | 
| 420 | 
            +
                    .btn-primary, .btn-secondary {
         | 
| 421 | 
            +
                        padding: 8px 16px;
         | 
| 422 | 
            +
                        font-size: 14px;
         | 
| 423 | 
            +
                        display: block;
         | 
| 424 | 
            +
                        width: 100%;
         | 
| 425 | 
            +
                        text-align: center;
         | 
| 426 | 
            +
                        margin-bottom: 8px;
         | 
| 427 | 
            +
                    }
         | 
| 428 | 
            +
                }
         | 
| 429 | 
            +
                
         | 
| 430 | 
            +
                /* Dark mode adjustments */
         | 
| 431 | 
            +
                @media (prefers-color-scheme: dark) {
         | 
| 432 | 
            +
                    .admin-card, .stat-card {
         | 
| 433 | 
            +
                        background-color: var(--light-gray);
         | 
| 434 | 
            +
                    }
         | 
| 435 | 
            +
                    
         | 
| 436 | 
            +
                    .admin-table th {
         | 
| 437 | 
            +
                        background-color: rgba(80, 70, 229, 0.1);
         | 
| 438 | 
            +
                    }
         | 
| 439 | 
            +
                    
         | 
| 440 | 
            +
                    .admin-table tr:hover {
         | 
| 441 | 
            +
                        background-color: rgba(255, 255, 255, 0.05);
         | 
| 442 | 
            +
                    }
         | 
| 443 | 
            +
                    
         | 
| 444 | 
            +
                    .form-control {
         | 
| 445 | 
            +
                        background-color: var(--light-gray);
         | 
| 446 | 
            +
                        color: var(--text-color);
         | 
| 447 | 
            +
                        border-color: rgba(255, 255, 255, 0.1);
         | 
| 448 | 
            +
                    }
         | 
| 449 | 
            +
                    
         | 
| 450 | 
            +
                    .checkmark {
         | 
| 451 | 
            +
                        background-color: var(--light-gray);
         | 
| 452 | 
            +
                        border-color: rgba(255, 255, 255, 0.2);
         | 
| 453 | 
            +
                    }
         | 
| 454 | 
            +
                    
         | 
| 455 | 
            +
                    .form-check:hover input ~ .checkmark {
         | 
| 456 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 457 | 
            +
                    }
         | 
| 458 | 
            +
                    
         | 
| 459 | 
            +
                    .action-btn {
         | 
| 460 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 461 | 
            +
                        border-color: rgba(255, 255, 255, 0.15);
         | 
| 462 | 
            +
                    }
         | 
| 463 | 
            +
                    
         | 
| 464 | 
            +
                    .action-btn:hover {
         | 
| 465 | 
            +
                        background-color: rgba(255, 255, 255, 0.15);
         | 
| 466 | 
            +
                    }
         | 
| 467 | 
            +
                    
         | 
| 468 | 
            +
                    .btn-secondary {
         | 
| 469 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 470 | 
            +
                        border-color: rgba(255, 255, 255, 0.15);
         | 
| 471 | 
            +
                    }
         | 
| 472 | 
            +
                    
         | 
| 473 | 
            +
                    .btn-secondary:hover {
         | 
| 474 | 
            +
                        background-color: rgba(255, 255, 255, 0.15);
         | 
| 475 | 
            +
                    }
         | 
| 476 | 
            +
                    
         | 
| 477 | 
            +
                    .btn-primary:hover {
         | 
| 478 | 
            +
                        background-color: #5d51ff;
         | 
| 479 | 
            +
                    }
         | 
| 480 | 
            +
                }
         | 
| 481 | 
            +
                
         | 
| 482 | 
            +
                .user-detail-value {
         | 
| 483 | 
            +
                    flex: 1;
         | 
| 484 | 
            +
                }
         | 
| 485 | 
            +
                
         | 
| 486 | 
            +
                /* Truncation utility class */
         | 
| 487 | 
            +
                .text-truncate {
         | 
| 488 | 
            +
                    max-width: 300px;
         | 
| 489 | 
            +
                    white-space: nowrap;
         | 
| 490 | 
            +
                    overflow: hidden;
         | 
| 491 | 
            +
                    text-overflow: ellipsis;
         | 
| 492 | 
            +
                }
         | 
| 493 | 
            +
                
         | 
| 494 | 
            +
                @media (max-width: 576px) {
         | 
| 495 | 
            +
                    .text-truncate {
         | 
| 496 | 
            +
                        max-width: 150px;
         | 
| 497 | 
            +
                    }
         | 
| 498 | 
            +
                    
         | 
| 499 | 
            +
                    .admin-stats {
         | 
| 500 | 
            +
                        grid-template-columns: 1fr;
         | 
| 501 | 
            +
                    }
         | 
| 502 | 
            +
                }
         | 
| 503 | 
            +
            </style>
         | 
| 504 | 
            +
            {% endblock %}
         | 
| 505 | 
            +
             | 
| 506 | 
            +
            {% block content %}
         | 
| 507 | 
            +
            <div class="admin-container">
         | 
| 508 | 
            +
                <nav class="admin-nav">
         | 
| 509 | 
            +
                    <a href="{{ url_for('admin.index') }}" class="admin-nav-item {% if request.endpoint == 'admin.index' %}active{% endif %}">
         | 
| 510 | 
            +
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>
         | 
| 511 | 
            +
                        Dashboard
         | 
| 512 | 
            +
                    </a>
         | 
| 513 | 
            +
                    <a href="{{ url_for('admin.models') }}" class="admin-nav-item {% if request.endpoint in ['admin.models', 'admin.edit_model', 'admin.add_model'] %}active{% endif %}">
         | 
| 514 | 
            +
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1v3M12 20v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M1 12h3M20 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/></svg>
         | 
| 515 | 
            +
                        Models
         | 
| 516 | 
            +
                    </a>
         | 
| 517 | 
            +
                    <a href="{{ url_for('admin.users') }}" class="admin-nav-item {% if request.endpoint == 'admin.users' or request.endpoint == 'admin.user_detail' %}active{% endif %}">
         | 
| 518 | 
            +
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
         | 
| 519 | 
            +
                        Users
         | 
| 520 | 
            +
                    </a>
         | 
| 521 | 
            +
                    <a href="{{ url_for('admin.votes') }}" class="admin-nav-item {% if request.endpoint == 'admin.votes' %}active{% endif %}">
         | 
| 522 | 
            +
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
         | 
| 523 | 
            +
                        Votes
         | 
| 524 | 
            +
                    </a>
         | 
| 525 | 
            +
                    <a href="{{ url_for('admin.statistics') }}" class="admin-nav-item {% if request.endpoint == 'admin.statistics' %}active{% endif %}">
         | 
| 526 | 
            +
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 12V8"/><path d="M13 12v-2"/><path d="M8 12v-5"/></svg>
         | 
| 527 | 
            +
                        Statistics
         | 
| 528 | 
            +
                    </a>
         | 
| 529 | 
            +
                    <a href="{{ url_for('admin.activity') }}" class="admin-nav-item {% if request.endpoint == 'admin.activity' %}active{% endif %}">
         | 
| 530 | 
            +
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8v8h8v-8z"/><path d="M3 21V3h18v9"/><path d="M12 3v6H3"/></svg>
         | 
| 531 | 
            +
                        Activity
         | 
| 532 | 
            +
                    </a>
         | 
| 533 | 
            +
                </nav>
         | 
| 534 | 
            +
                
         | 
| 535 | 
            +
                <div class="admin-content">
         | 
| 536 | 
            +
                    {% block admin_content %}{% endblock %}
         | 
| 537 | 
            +
                </div>
         | 
| 538 | 
            +
            </div>
         | 
| 539 | 
            +
            {% endblock %} 
         | 
    	
        templates/admin/edit_model.html
    ADDED
    
    | @@ -0,0 +1,62 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "admin/base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block admin_content %}
         | 
| 4 | 
            +
            <div class="admin-header">
         | 
| 5 | 
            +
                <div class="admin-title">Edit Model</div>
         | 
| 6 | 
            +
                <a href="{{ url_for('admin.models') }}" class="btn-secondary">Back to Models</a>
         | 
| 7 | 
            +
            </div>
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            <div class="admin-card">
         | 
| 10 | 
            +
                <form method="POST" class="admin-form">
         | 
| 11 | 
            +
                    <div class="form-group">
         | 
| 12 | 
            +
                        <label for="id">Model ID</label>
         | 
| 13 | 
            +
                        <input type="text" id="id" name="id" class="form-control" value="{{ model.id }}" readonly>
         | 
| 14 | 
            +
                    </div>
         | 
| 15 | 
            +
                    
         | 
| 16 | 
            +
                    <div class="form-group">
         | 
| 17 | 
            +
                        <label for="name">Model Name</label>
         | 
| 18 | 
            +
                        <input type="text" id="name" name="name" class="form-control" value="{{ model.name }}" required>
         | 
| 19 | 
            +
                    </div>
         | 
| 20 | 
            +
                    
         | 
| 21 | 
            +
                    <div class="form-group">
         | 
| 22 | 
            +
                        <label for="model_type">Model Type</label>
         | 
| 23 | 
            +
                        <input type="text" id="model_type" name="model_type" class="form-control" value="{{ model.model_type }}" readonly>
         | 
| 24 | 
            +
                    </div>
         | 
| 25 | 
            +
                    
         | 
| 26 | 
            +
                    <div class="form-group">
         | 
| 27 | 
            +
                        <label for="model_url">Model URL</label>
         | 
| 28 | 
            +
                        <input type="url" id="model_url" name="model_url" class="form-control" value="{{ model.model_url or '' }}">
         | 
| 29 | 
            +
                        <small>Optional: URL to the model's page/repository</small>
         | 
| 30 | 
            +
                    </div>
         | 
| 31 | 
            +
                    
         | 
| 32 | 
            +
                    <div class="form-group">
         | 
| 33 | 
            +
                        <label for="current_elo">Current ELO Score</label>
         | 
| 34 | 
            +
                        <input type="number" id="current_elo" name="current_elo" class="form-control" value="{{ model.current_elo }}" readonly>
         | 
| 35 | 
            +
                        <small>ELO score is calculated automatically from votes</small>
         | 
| 36 | 
            +
                    </div>
         | 
| 37 | 
            +
                    
         | 
| 38 | 
            +
                    <div class="form-check">
         | 
| 39 | 
            +
                        <input type="checkbox" id="is_active" name="is_active" {% if model.is_active %}checked{% endif %}>
         | 
| 40 | 
            +
                        <span class="checkmark"></span>
         | 
| 41 | 
            +
                        <label for="is_active">Active (available for voting)</label>
         | 
| 42 | 
            +
                    </div>
         | 
| 43 | 
            +
                    
         | 
| 44 | 
            +
                    <div class="form-check">
         | 
| 45 | 
            +
                        <input type="checkbox" id="is_open" name="is_open" {% if model.is_open %}checked{% endif %}>
         | 
| 46 | 
            +
                        <span class="checkmark"></span>
         | 
| 47 | 
            +
                        <label for="is_open">Open Source</label>
         | 
| 48 | 
            +
                    </div>
         | 
| 49 | 
            +
                    
         | 
| 50 | 
            +
                    <div class="form-group">
         | 
| 51 | 
            +
                        <label>Statistics</label>
         | 
| 52 | 
            +
                        <div class="user-info">
         | 
| 53 | 
            +
                            <p><strong>Matches:</strong> {{ model.match_count }}</p>
         | 
| 54 | 
            +
                            <p><strong>Wins:</strong> {{ model.win_count }}</p>
         | 
| 55 | 
            +
                            <p><strong>Win Rate:</strong> {{ model.win_rate|round(2) }}%</p>
         | 
| 56 | 
            +
                        </div>
         | 
| 57 | 
            +
                    </div>
         | 
| 58 | 
            +
                    
         | 
| 59 | 
            +
                    <button type="submit" class="btn-primary">Save Changes</button>
         | 
| 60 | 
            +
                </form>
         | 
| 61 | 
            +
            </div>
         | 
| 62 | 
            +
            {% endblock %} 
         | 
    	
        templates/admin/index.html
    ADDED
    
    | @@ -0,0 +1,213 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "admin/base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block admin_content %}
         | 
| 4 | 
            +
            <div class="admin-header">
         | 
| 5 | 
            +
                <div class="admin-title">Dashboard</div>
         | 
| 6 | 
            +
            </div>
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            <div class="admin-stats">
         | 
| 9 | 
            +
                <div class="stat-card">
         | 
| 10 | 
            +
                    <div class="stat-title">Total Users</div>
         | 
| 11 | 
            +
                    <div class="stat-value">{{ stats.total_users }}</div>
         | 
| 12 | 
            +
                </div>
         | 
| 13 | 
            +
                <div class="stat-card">
         | 
| 14 | 
            +
                    <div class="stat-title">Total Votes</div>
         | 
| 15 | 
            +
                    <div class="stat-value">{{ stats.total_votes }}</div>
         | 
| 16 | 
            +
                </div>
         | 
| 17 | 
            +
                <div class="stat-card">
         | 
| 18 | 
            +
                    <div class="stat-title">TTS Votes</div>
         | 
| 19 | 
            +
                    <div class="stat-value">{{ stats.tts_votes }}</div>
         | 
| 20 | 
            +
                </div>
         | 
| 21 | 
            +
                <div class="stat-card">
         | 
| 22 | 
            +
                    <div class="stat-title">Conversational Votes</div>
         | 
| 23 | 
            +
                    <div class="stat-value">{{ stats.conversational_votes }}</div>
         | 
| 24 | 
            +
                </div>
         | 
| 25 | 
            +
                <div class="stat-card">
         | 
| 26 | 
            +
                    <div class="stat-title">TTS Models</div>
         | 
| 27 | 
            +
                    <div class="stat-value">{{ stats.tts_models }}</div>
         | 
| 28 | 
            +
                </div>
         | 
| 29 | 
            +
                <div class="stat-card">
         | 
| 30 | 
            +
                    <div class="stat-title">Conversational Models</div>
         | 
| 31 | 
            +
                    <div class="stat-value">{{ stats.conversational_models }}</div>
         | 
| 32 | 
            +
                </div>
         | 
| 33 | 
            +
            </div>
         | 
| 34 | 
            +
             | 
| 35 | 
            +
            <div class="admin-card">
         | 
| 36 | 
            +
                <div class="admin-card-header">
         | 
| 37 | 
            +
                    <div class="admin-card-title">Daily Votes (Last 30 Days)</div>
         | 
| 38 | 
            +
                </div>
         | 
| 39 | 
            +
                <canvas id="votesChart" height="200"></canvas>
         | 
| 40 | 
            +
            </div>
         | 
| 41 | 
            +
             | 
| 42 | 
            +
            <div class="admin-card">
         | 
| 43 | 
            +
                <div class="admin-card-header">
         | 
| 44 | 
            +
                    <div class="admin-card-title">Top TTS Models</div>
         | 
| 45 | 
            +
                </div>
         | 
| 46 | 
            +
                <div class="table-responsive">
         | 
| 47 | 
            +
                    <table class="admin-table">
         | 
| 48 | 
            +
                        <thead>
         | 
| 49 | 
            +
                            <tr>
         | 
| 50 | 
            +
                                <th>Rank</th>
         | 
| 51 | 
            +
                                <th>Model</th>
         | 
| 52 | 
            +
                                <th>ELO Score</th>
         | 
| 53 | 
            +
                                <th>Win Rate</th>
         | 
| 54 | 
            +
                                <th>Total Matches</th>
         | 
| 55 | 
            +
                            </tr>
         | 
| 56 | 
            +
                        </thead>
         | 
| 57 | 
            +
                        <tbody>
         | 
| 58 | 
            +
                            {% for model in top_tts_models %}
         | 
| 59 | 
            +
                            <tr>
         | 
| 60 | 
            +
                                <td>{{ loop.index }}</td>
         | 
| 61 | 
            +
                                <td>{{ model.name }}</td>
         | 
| 62 | 
            +
                                <td>{{ model.current_elo|int }}</td>
         | 
| 63 | 
            +
                                <td>{{ model.win_rate|round }}%</td>
         | 
| 64 | 
            +
                                <td>{{ model.match_count }}</td>
         | 
| 65 | 
            +
                            </tr>
         | 
| 66 | 
            +
                            {% endfor %}
         | 
| 67 | 
            +
                        </tbody>
         | 
| 68 | 
            +
                    </table>
         | 
| 69 | 
            +
                </div>
         | 
| 70 | 
            +
            </div>
         | 
| 71 | 
            +
             | 
| 72 | 
            +
            <div class="admin-card">
         | 
| 73 | 
            +
                <div class="admin-card-header">
         | 
| 74 | 
            +
                    <div class="admin-card-title">Top Conversational Models</div>
         | 
| 75 | 
            +
                </div>
         | 
| 76 | 
            +
                <div class="table-responsive">
         | 
| 77 | 
            +
                    <table class="admin-table">
         | 
| 78 | 
            +
                        <thead>
         | 
| 79 | 
            +
                            <tr>
         | 
| 80 | 
            +
                                <th>Rank</th>
         | 
| 81 | 
            +
                                <th>Model</th>
         | 
| 82 | 
            +
                                <th>ELO Score</th>
         | 
| 83 | 
            +
                                <th>Win Rate</th>
         | 
| 84 | 
            +
                                <th>Total Matches</th>
         | 
| 85 | 
            +
                            </tr>
         | 
| 86 | 
            +
                        </thead>
         | 
| 87 | 
            +
                        <tbody>
         | 
| 88 | 
            +
                            {% for model in top_conversational_models %}
         | 
| 89 | 
            +
                            <tr>
         | 
| 90 | 
            +
                                <td>{{ loop.index }}</td>
         | 
| 91 | 
            +
                                <td>{{ model.name }}</td>
         | 
| 92 | 
            +
                                <td>{{ model.current_elo|int }}</td>
         | 
| 93 | 
            +
                                <td>{{ model.win_rate|round }}%</td>
         | 
| 94 | 
            +
                                <td>{{ model.match_count }}</td>
         | 
| 95 | 
            +
                            </tr>
         | 
| 96 | 
            +
                            {% endfor %}
         | 
| 97 | 
            +
                        </tbody>
         | 
| 98 | 
            +
                    </table>
         | 
| 99 | 
            +
                </div>
         | 
| 100 | 
            +
            </div>
         | 
| 101 | 
            +
             | 
| 102 | 
            +
            <div class="admin-row">
         | 
| 103 | 
            +
                <div class="admin-card">
         | 
| 104 | 
            +
                    <div class="admin-card-header">
         | 
| 105 | 
            +
                        <div class="admin-card-title">Recent Votes</div>
         | 
| 106 | 
            +
                        <a href="{{ url_for('admin.votes') }}" class="btn-secondary">View All</a>
         | 
| 107 | 
            +
                    </div>
         | 
| 108 | 
            +
                    <div class="table-responsive">
         | 
| 109 | 
            +
                        <table class="admin-table">
         | 
| 110 | 
            +
                            <thead>
         | 
| 111 | 
            +
                                <tr>
         | 
| 112 | 
            +
                                    <th>Date</th>
         | 
| 113 | 
            +
                                    <th>Type</th>
         | 
| 114 | 
            +
                                    <th>User</th>
         | 
| 115 | 
            +
                                    <th>Chosen Model</th>
         | 
| 116 | 
            +
                                    <th>Rejected Model</th>
         | 
| 117 | 
            +
                                </tr>
         | 
| 118 | 
            +
                            </thead>
         | 
| 119 | 
            +
                            <tbody>
         | 
| 120 | 
            +
                                {% for vote in recent_votes %}
         | 
| 121 | 
            +
                                <tr>
         | 
| 122 | 
            +
                                    <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
         | 
| 123 | 
            +
                                    <td>{{ vote.model_type }}</td>
         | 
| 124 | 
            +
                                    <td>
         | 
| 125 | 
            +
                                        {% if vote.user %}
         | 
| 126 | 
            +
                                            <a href="{{ url_for('admin.user_detail', user_id=vote.user.id) }}">{{ vote.user.username }}</a>
         | 
| 127 | 
            +
                                        {% else %}
         | 
| 128 | 
            +
                                            Anonymous
         | 
| 129 | 
            +
                                        {% endif %}
         | 
| 130 | 
            +
                                    </td>
         | 
| 131 | 
            +
                                    <td>{{ vote.chosen.name }}</td>
         | 
| 132 | 
            +
                                    <td>{{ vote.rejected.name }}</td>
         | 
| 133 | 
            +
                                </tr>
         | 
| 134 | 
            +
                                {% endfor %}
         | 
| 135 | 
            +
                            </tbody>
         | 
| 136 | 
            +
                        </table>
         | 
| 137 | 
            +
                    </div>
         | 
| 138 | 
            +
                </div>
         | 
| 139 | 
            +
            </div>
         | 
| 140 | 
            +
             | 
| 141 | 
            +
            <div class="admin-row">
         | 
| 142 | 
            +
                <div class="admin-card">
         | 
| 143 | 
            +
                    <div class="admin-card-header">
         | 
| 144 | 
            +
                        <div class="admin-card-title">Recent Users</div>
         | 
| 145 | 
            +
                        <a href="{{ url_for('admin.users') }}" class="btn-secondary">View All</a>
         | 
| 146 | 
            +
                    </div>
         | 
| 147 | 
            +
                    <div class="table-responsive">
         | 
| 148 | 
            +
                        <table class="admin-table">
         | 
| 149 | 
            +
                            <thead>
         | 
| 150 | 
            +
                                <tr>
         | 
| 151 | 
            +
                                    <th>Username</th>
         | 
| 152 | 
            +
                                    <th>Join Date</th>
         | 
| 153 | 
            +
                                    <th>Actions</th>
         | 
| 154 | 
            +
                                </tr>
         | 
| 155 | 
            +
                            </thead>
         | 
| 156 | 
            +
                            <tbody>
         | 
| 157 | 
            +
                                {% for user in recent_users %}
         | 
| 158 | 
            +
                                <tr>
         | 
| 159 | 
            +
                                    <td>{{ user.username }}</td>
         | 
| 160 | 
            +
                                    <td>{{ user.join_date.strftime('%Y-%m-%d %H:%M') }}</td>
         | 
| 161 | 
            +
                                    <td>
         | 
| 162 | 
            +
                                        <a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="btn-secondary">View Details</a>
         | 
| 163 | 
            +
                                    </td>
         | 
| 164 | 
            +
                                </tr>
         | 
| 165 | 
            +
                                {% endfor %}
         | 
| 166 | 
            +
                            </tbody>
         | 
| 167 | 
            +
                        </table>
         | 
| 168 | 
            +
                    </div>
         | 
| 169 | 
            +
                </div>
         | 
| 170 | 
            +
            </div>
         | 
| 171 | 
            +
             | 
| 172 | 
            +
            <script>
         | 
| 173 | 
            +
            document.addEventListener('DOMContentLoaded', function() {
         | 
| 174 | 
            +
                const votesData = {{ daily_votes_data|safe }};
         | 
| 175 | 
            +
                
         | 
| 176 | 
            +
                // Daily votes chart
         | 
| 177 | 
            +
                const votesCtx = document.getElementById('votesChart').getContext('2d');
         | 
| 178 | 
            +
                new Chart(votesCtx, {
         | 
| 179 | 
            +
                    type: 'line',
         | 
| 180 | 
            +
                    data: {
         | 
| 181 | 
            +
                        labels: votesData.labels,
         | 
| 182 | 
            +
                        datasets: [{
         | 
| 183 | 
            +
                            label: 'Daily Votes',
         | 
| 184 | 
            +
                            data: votesData.counts,
         | 
| 185 | 
            +
                            backgroundColor: 'rgba(80, 70, 229, 0.1)',
         | 
| 186 | 
            +
                            borderColor: 'rgba(80, 70, 229, 1)',
         | 
| 187 | 
            +
                            borderWidth: 2,
         | 
| 188 | 
            +
                            tension: 0.3,
         | 
| 189 | 
            +
                            fill: true,
         | 
| 190 | 
            +
                            pointRadius: 3,
         | 
| 191 | 
            +
                            pointBackgroundColor: '#5046e5'
         | 
| 192 | 
            +
                        }]
         | 
| 193 | 
            +
                    },
         | 
| 194 | 
            +
                    options: {
         | 
| 195 | 
            +
                        responsive: true,
         | 
| 196 | 
            +
                        maintainAspectRatio: false,
         | 
| 197 | 
            +
                        scales: {
         | 
| 198 | 
            +
                            yAxes: [{
         | 
| 199 | 
            +
                                ticks: {
         | 
| 200 | 
            +
                                    beginAtZero: true,
         | 
| 201 | 
            +
                                    precision: 0
         | 
| 202 | 
            +
                                }
         | 
| 203 | 
            +
                            }]
         | 
| 204 | 
            +
                        },
         | 
| 205 | 
            +
                        tooltips: {
         | 
| 206 | 
            +
                            mode: 'index',
         | 
| 207 | 
            +
                            intersect: false
         | 
| 208 | 
            +
                        }
         | 
| 209 | 
            +
                    }
         | 
| 210 | 
            +
                });
         | 
| 211 | 
            +
            });
         | 
| 212 | 
            +
            </script>
         | 
| 213 | 
            +
            {% endblock %} 
         | 
    	
        templates/admin/models.html
    ADDED
    
    | @@ -0,0 +1,79 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "admin/base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block admin_content %}
         | 
| 4 | 
            +
            <div class="admin-header">
         | 
| 5 | 
            +
                <div class="admin-title">Manage Models</div>
         | 
| 6 | 
            +
            </div>
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            <div class="admin-card">
         | 
| 9 | 
            +
                <div class="admin-card-header">
         | 
| 10 | 
            +
                    <div class="admin-card-title">TTS Models</div>
         | 
| 11 | 
            +
                </div>
         | 
| 12 | 
            +
                <div class="table-responsive">
         | 
| 13 | 
            +
                    <table class="admin-table">
         | 
| 14 | 
            +
                        <thead>
         | 
| 15 | 
            +
                            <tr>
         | 
| 16 | 
            +
                                <th>ID</th>
         | 
| 17 | 
            +
                                <th>Name</th>
         | 
| 18 | 
            +
                                <th>ELO Score</th>
         | 
| 19 | 
            +
                                <th>Matches</th>
         | 
| 20 | 
            +
                                <th>Active</th>
         | 
| 21 | 
            +
                                <th>Open Source</th>
         | 
| 22 | 
            +
                                <th>Actions</th>
         | 
| 23 | 
            +
                            </tr>
         | 
| 24 | 
            +
                        </thead>
         | 
| 25 | 
            +
                        <tbody>
         | 
| 26 | 
            +
                            {% for model in tts_models %}
         | 
| 27 | 
            +
                            <tr>
         | 
| 28 | 
            +
                                <td>{{ model.id }}</td>
         | 
| 29 | 
            +
                                <td>{{ model.name }}</td>
         | 
| 30 | 
            +
                                <td>{{ model.current_elo|int }}</td>
         | 
| 31 | 
            +
                                <td>{{ model.match_count }}</td>
         | 
| 32 | 
            +
                                <td>{{ "Yes" if model.is_active else "No" }}</td>
         | 
| 33 | 
            +
                                <td>{{ "Yes" if model.is_open else "No" }}</td>
         | 
| 34 | 
            +
                                <td>
         | 
| 35 | 
            +
                                    <a href="{{ url_for('admin.edit_model', model_id=model.id) }}" class="action-btn">Edit</a>
         | 
| 36 | 
            +
                                </td>
         | 
| 37 | 
            +
                            </tr>
         | 
| 38 | 
            +
                            {% endfor %}
         | 
| 39 | 
            +
                        </tbody>
         | 
| 40 | 
            +
                    </table>
         | 
| 41 | 
            +
                </div>
         | 
| 42 | 
            +
            </div>
         | 
| 43 | 
            +
             | 
| 44 | 
            +
            <div class="admin-card">
         | 
| 45 | 
            +
                <div class="admin-card-header">
         | 
| 46 | 
            +
                    <div class="admin-card-title">Conversational Models</div>
         | 
| 47 | 
            +
                </div>
         | 
| 48 | 
            +
                <div class="table-responsive">
         | 
| 49 | 
            +
                    <table class="admin-table">
         | 
| 50 | 
            +
                        <thead>
         | 
| 51 | 
            +
                            <tr>
         | 
| 52 | 
            +
                                <th>ID</th>
         | 
| 53 | 
            +
                                <th>Name</th>
         | 
| 54 | 
            +
                                <th>ELO Score</th>
         | 
| 55 | 
            +
                                <th>Matches</th>
         | 
| 56 | 
            +
                                <th>Active</th>
         | 
| 57 | 
            +
                                <th>Open Source</th>
         | 
| 58 | 
            +
                                <th>Actions</th>
         | 
| 59 | 
            +
                            </tr>
         | 
| 60 | 
            +
                        </thead>
         | 
| 61 | 
            +
                        <tbody>
         | 
| 62 | 
            +
                            {% for model in conversational_models %}
         | 
| 63 | 
            +
                            <tr>
         | 
| 64 | 
            +
                                <td>{{ model.id }}</td>
         | 
| 65 | 
            +
                                <td>{{ model.name }}</td>
         | 
| 66 | 
            +
                                <td>{{ model.current_elo|int }}</td>
         | 
| 67 | 
            +
                                <td>{{ model.match_count }}</td>
         | 
| 68 | 
            +
                                <td>{{ "Yes" if model.is_active else "No" }}</td>
         | 
| 69 | 
            +
                                <td>{{ "Yes" if model.is_open else "No" }}</td>
         | 
| 70 | 
            +
                                <td>
         | 
| 71 | 
            +
                                    <a href="{{ url_for('admin.edit_model', model_id=model.id) }}" class="action-btn">Edit</a>
         | 
| 72 | 
            +
                                </td>
         | 
| 73 | 
            +
                            </tr>
         | 
| 74 | 
            +
                            {% endfor %}
         | 
| 75 | 
            +
                        </tbody>
         | 
| 76 | 
            +
                    </table>
         | 
| 77 | 
            +
                </div>
         | 
| 78 | 
            +
            </div>
         | 
| 79 | 
            +
            {% endblock %} 
         | 
    	
        templates/admin/statistics.html
    ADDED
    
    | @@ -0,0 +1,173 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "admin/base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block admin_content %}
         | 
| 4 | 
            +
            <div class="admin-header">
         | 
| 5 | 
            +
                <div class="admin-title">Statistics</div>
         | 
| 6 | 
            +
            </div>
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            <div class="admin-card">
         | 
| 9 | 
            +
                <div class="admin-card-header">
         | 
| 10 | 
            +
                    <div class="admin-card-title">Daily Votes by Model Type (Last 30 Days)</div>
         | 
| 11 | 
            +
                </div>
         | 
| 12 | 
            +
                <canvas id="dailyVotesChart" height="250"></canvas>
         | 
| 13 | 
            +
            </div>
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            <div class="admin-card">
         | 
| 16 | 
            +
                <div class="admin-card-header">
         | 
| 17 | 
            +
                    <div class="admin-card-title">New Users by Month</div>
         | 
| 18 | 
            +
                </div>
         | 
| 19 | 
            +
                <canvas id="monthlyUsersChart" height="250"></canvas>
         | 
| 20 | 
            +
            </div>
         | 
| 21 | 
            +
             | 
| 22 | 
            +
            <div class="admin-card">
         | 
| 23 | 
            +
                <div class="admin-card-header">
         | 
| 24 | 
            +
                    <div class="admin-card-title">Model ELO History</div>
         | 
| 25 | 
            +
                </div>
         | 
| 26 | 
            +
                <canvas id="modelHistoryChart" height="300"></canvas>
         | 
| 27 | 
            +
            </div>
         | 
| 28 | 
            +
             | 
| 29 | 
            +
            <script>
         | 
| 30 | 
            +
            document.addEventListener('DOMContentLoaded', function() {
         | 
| 31 | 
            +
                const chartData = {{ chart_data|safe }};
         | 
| 32 | 
            +
                
         | 
| 33 | 
            +
                // Daily votes by model type
         | 
| 34 | 
            +
                const dailyVotesCtx = document.getElementById('dailyVotesChart').getContext('2d');
         | 
| 35 | 
            +
                new Chart(dailyVotesCtx, {
         | 
| 36 | 
            +
                    type: 'line',
         | 
| 37 | 
            +
                    data: {
         | 
| 38 | 
            +
                        labels: chartData.dailyVotes.labels,
         | 
| 39 | 
            +
                        datasets: [
         | 
| 40 | 
            +
                            {
         | 
| 41 | 
            +
                                label: 'TTS Votes',
         | 
| 42 | 
            +
                                data: chartData.dailyVotes.ttsCounts,
         | 
| 43 | 
            +
                                backgroundColor: 'rgba(80, 70, 229, 0.1)',
         | 
| 44 | 
            +
                                borderColor: 'rgba(80, 70, 229, 1)',
         | 
| 45 | 
            +
                                borderWidth: 2,
         | 
| 46 | 
            +
                                tension: 0.3,
         | 
| 47 | 
            +
                                fill: true,
         | 
| 48 | 
            +
                                pointRadius: 2,
         | 
| 49 | 
            +
                                pointBackgroundColor: '#5046e5'
         | 
| 50 | 
            +
                            },
         | 
| 51 | 
            +
                            {
         | 
| 52 | 
            +
                                label: 'Conversational Votes',
         | 
| 53 | 
            +
                                data: chartData.dailyVotes.convCounts,
         | 
| 54 | 
            +
                                backgroundColor: 'rgba(236, 72, 153, 0.1)',
         | 
| 55 | 
            +
                                borderColor: 'rgba(236, 72, 153, 1)',
         | 
| 56 | 
            +
                                borderWidth: 2,
         | 
| 57 | 
            +
                                tension: 0.3,
         | 
| 58 | 
            +
                                fill: true,
         | 
| 59 | 
            +
                                pointRadius: 2,
         | 
| 60 | 
            +
                                pointBackgroundColor: '#ec4899'
         | 
| 61 | 
            +
                            }
         | 
| 62 | 
            +
                        ]
         | 
| 63 | 
            +
                    },
         | 
| 64 | 
            +
                    options: {
         | 
| 65 | 
            +
                        responsive: true,
         | 
| 66 | 
            +
                        maintainAspectRatio: false,
         | 
| 67 | 
            +
                        scales: {
         | 
| 68 | 
            +
                            yAxes: [{
         | 
| 69 | 
            +
                                ticks: {
         | 
| 70 | 
            +
                                    beginAtZero: true,
         | 
| 71 | 
            +
                                    precision: 0
         | 
| 72 | 
            +
                                }
         | 
| 73 | 
            +
                            }]
         | 
| 74 | 
            +
                        },
         | 
| 75 | 
            +
                        tooltips: {
         | 
| 76 | 
            +
                            mode: 'index',
         | 
| 77 | 
            +
                            intersect: false
         | 
| 78 | 
            +
                        }
         | 
| 79 | 
            +
                    }
         | 
| 80 | 
            +
                });
         | 
| 81 | 
            +
                
         | 
| 82 | 
            +
                // Monthly users chart
         | 
| 83 | 
            +
                const monthlyUsersCtx = document.getElementById('monthlyUsersChart').getContext('2d');
         | 
| 84 | 
            +
                new Chart(monthlyUsersCtx, {
         | 
| 85 | 
            +
                    type: 'bar',
         | 
| 86 | 
            +
                    data: {
         | 
| 87 | 
            +
                        labels: chartData.monthlyUsers.labels,
         | 
| 88 | 
            +
                        datasets: [{
         | 
| 89 | 
            +
                            label: 'New Users',
         | 
| 90 | 
            +
                            data: chartData.monthlyUsers.counts,
         | 
| 91 | 
            +
                            backgroundColor: 'rgba(16, 185, 129, 0.7)',
         | 
| 92 | 
            +
                            borderColor: 'rgba(16, 185, 129, 1)',
         | 
| 93 | 
            +
                            borderWidth: 1
         | 
| 94 | 
            +
                        }]
         | 
| 95 | 
            +
                    },
         | 
| 96 | 
            +
                    options: {
         | 
| 97 | 
            +
                        responsive: true,
         | 
| 98 | 
            +
                        maintainAspectRatio: false,
         | 
| 99 | 
            +
                        scales: {
         | 
| 100 | 
            +
                            yAxes: [{
         | 
| 101 | 
            +
                                ticks: {
         | 
| 102 | 
            +
                                    beginAtZero: true,
         | 
| 103 | 
            +
                                    precision: 0
         | 
| 104 | 
            +
                                }
         | 
| 105 | 
            +
                            }]
         | 
| 106 | 
            +
                        }
         | 
| 107 | 
            +
                    }
         | 
| 108 | 
            +
                });
         | 
| 109 | 
            +
                
         | 
| 110 | 
            +
                // Model ELO history chart
         | 
| 111 | 
            +
                const modelHistoryCtx = document.getElementById('modelHistoryChart').getContext('2d');
         | 
| 112 | 
            +
                
         | 
| 113 | 
            +
                // Prepare datasets for each model
         | 
| 114 | 
            +
                const modelDatasets = [];
         | 
| 115 | 
            +
                const colors = [
         | 
| 116 | 
            +
                    { backgroundColor: 'rgba(80, 70, 229, 0.1)', borderColor: 'rgba(80, 70, 229, 1)' },
         | 
| 117 | 
            +
                    { backgroundColor: 'rgba(236, 72, 153, 0.1)', borderColor: 'rgba(236, 72, 153, 1)' },
         | 
| 118 | 
            +
                    { backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 1)' },
         | 
| 119 | 
            +
                    { backgroundColor: 'rgba(245, 158, 11, 0.1)', borderColor: 'rgba(245, 158, 11, 1)' },
         | 
| 120 | 
            +
                    { backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 1)' }
         | 
| 121 | 
            +
                ];
         | 
| 122 | 
            +
                
         | 
| 123 | 
            +
                let colorIndex = 0;
         | 
| 124 | 
            +
                for (const modelName in chartData.modelHistory) {
         | 
| 125 | 
            +
                    const model = chartData.modelHistory[modelName];
         | 
| 126 | 
            +
                    modelDatasets.push({
         | 
| 127 | 
            +
                        label: modelName,
         | 
| 128 | 
            +
                        data: model.scores,
         | 
| 129 | 
            +
                        backgroundColor: colors[colorIndex % colors.length].backgroundColor,
         | 
| 130 | 
            +
                        borderColor: colors[colorIndex % colors.length].borderColor,
         | 
| 131 | 
            +
                        borderWidth: 2,
         | 
| 132 | 
            +
                        tension: 0.3,
         | 
| 133 | 
            +
                        fill: false,
         | 
| 134 | 
            +
                        pointRadius: 1
         | 
| 135 | 
            +
                    });
         | 
| 136 | 
            +
                    colorIndex++;
         | 
| 137 | 
            +
                }
         | 
| 138 | 
            +
                
         | 
| 139 | 
            +
                // If we have any model data, create the chart
         | 
| 140 | 
            +
                if (modelDatasets.length > 0) {
         | 
| 141 | 
            +
                    // Get timestamps from the first model (they should all have the same timepoints)
         | 
| 142 | 
            +
                    const firstModel = Object.values(chartData.modelHistory)[0];
         | 
| 143 | 
            +
                    
         | 
| 144 | 
            +
                    new Chart(modelHistoryCtx, {
         | 
| 145 | 
            +
                        type: 'line',
         | 
| 146 | 
            +
                        data: {
         | 
| 147 | 
            +
                            labels: firstModel.timestamps,
         | 
| 148 | 
            +
                            datasets: modelDatasets
         | 
| 149 | 
            +
                        },
         | 
| 150 | 
            +
                        options: {
         | 
| 151 | 
            +
                            responsive: true,
         | 
| 152 | 
            +
                            maintainAspectRatio: false,
         | 
| 153 | 
            +
                            scales: {
         | 
| 154 | 
            +
                                yAxes: [{
         | 
| 155 | 
            +
                                    ticks: {
         | 
| 156 | 
            +
                                        beginAtZero: false
         | 
| 157 | 
            +
                                    }
         | 
| 158 | 
            +
                                }]
         | 
| 159 | 
            +
                            },
         | 
| 160 | 
            +
                            tooltips: {
         | 
| 161 | 
            +
                                mode: 'index',
         | 
| 162 | 
            +
                                intersect: false
         | 
| 163 | 
            +
                            }
         | 
| 164 | 
            +
                        }
         | 
| 165 | 
            +
                    });
         | 
| 166 | 
            +
                } else {
         | 
| 167 | 
            +
                    // If no model data, show a message
         | 
| 168 | 
            +
                    document.getElementById('modelHistoryChart').parentNode.innerHTML = 
         | 
| 169 | 
            +
                        '<div style="text-align: center; padding: 20px;">No model history data available yet.</div>';
         | 
| 170 | 
            +
                }
         | 
| 171 | 
            +
            });
         | 
| 172 | 
            +
            </script>
         | 
| 173 | 
            +
            {% endblock %} 
         | 
    	
        templates/admin/user_detail.html
    ADDED
    
    | @@ -0,0 +1,145 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "admin/base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block admin_content %}
         | 
| 4 | 
            +
            <div class="admin-header">
         | 
| 5 | 
            +
                <div class="admin-title">User Details</div>
         | 
| 6 | 
            +
                <a href="{{ url_for('admin.users') }}" class="btn-secondary">Back to Users</a>
         | 
| 7 | 
            +
            </div>
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            <div class="admin-card">
         | 
| 10 | 
            +
                <div class="admin-card-header">
         | 
| 11 | 
            +
                    <div class="admin-card-title">User Information</div>
         | 
| 12 | 
            +
                </div>
         | 
| 13 | 
            +
                <div class="user-info">
         | 
| 14 | 
            +
                    <div class="user-detail-row">
         | 
| 15 | 
            +
                        <div class="user-detail-label">Username:</div>
         | 
| 16 | 
            +
                        <div class="user-detail-value">{{ user.username }}</div>
         | 
| 17 | 
            +
                    </div>
         | 
| 18 | 
            +
                    <div class="user-detail-row">
         | 
| 19 | 
            +
                        <div class="user-detail-label">Hugging Face ID:</div>
         | 
| 20 | 
            +
                        <div class="user-detail-value">{{ user.hf_id }}</div>
         | 
| 21 | 
            +
                    </div>
         | 
| 22 | 
            +
                    <div class="user-detail-row">
         | 
| 23 | 
            +
                        <div class="user-detail-label">Join Date:</div>
         | 
| 24 | 
            +
                        <div class="user-detail-value">{{ user.join_date.strftime('%Y-%m-%d %H:%M:%S') }}</div>
         | 
| 25 | 
            +
                    </div>
         | 
| 26 | 
            +
                </div>
         | 
| 27 | 
            +
                
         | 
| 28 | 
            +
                <div class="user-stats">
         | 
| 29 | 
            +
                    <div class="stat-card">
         | 
| 30 | 
            +
                        <div class="stat-title">Total Votes</div>
         | 
| 31 | 
            +
                        <div class="stat-value">{{ total_votes }}</div>
         | 
| 32 | 
            +
                    </div>
         | 
| 33 | 
            +
                    <div class="stat-card">
         | 
| 34 | 
            +
                        <div class="stat-title">TTS Votes</div>
         | 
| 35 | 
            +
                        <div class="stat-value">{{ tts_votes }}</div>
         | 
| 36 | 
            +
                    </div>
         | 
| 37 | 
            +
                    <div class="stat-card">
         | 
| 38 | 
            +
                        <div class="stat-title">Conversational Votes</div>
         | 
| 39 | 
            +
                        <div class="stat-value">{{ conversational_votes }}</div>
         | 
| 40 | 
            +
                    </div>
         | 
| 41 | 
            +
                </div>
         | 
| 42 | 
            +
            </div>
         | 
| 43 | 
            +
             | 
| 44 | 
            +
            {% if favorite_models %}
         | 
| 45 | 
            +
            <div class="admin-card">
         | 
| 46 | 
            +
                <div class="admin-card-header">
         | 
| 47 | 
            +
                    <div class="admin-card-title">Favorite Models</div>
         | 
| 48 | 
            +
                </div>
         | 
| 49 | 
            +
                <div class="table-responsive">
         | 
| 50 | 
            +
                    <table class="admin-table">
         | 
| 51 | 
            +
                        <thead>
         | 
| 52 | 
            +
                            <tr>
         | 
| 53 | 
            +
                                <th>Model</th>
         | 
| 54 | 
            +
                                <th>Votes</th>
         | 
| 55 | 
            +
                            </tr>
         | 
| 56 | 
            +
                        </thead>
         | 
| 57 | 
            +
                        <tbody>
         | 
| 58 | 
            +
                            {% for model in favorite_models %}
         | 
| 59 | 
            +
                            <tr>
         | 
| 60 | 
            +
                                <td>{{ model.name }}</td>
         | 
| 61 | 
            +
                                <td>{{ model.count }}</td>
         | 
| 62 | 
            +
                            </tr>
         | 
| 63 | 
            +
                            {% endfor %}
         | 
| 64 | 
            +
                        </tbody>
         | 
| 65 | 
            +
                    </table>
         | 
| 66 | 
            +
                </div>
         | 
| 67 | 
            +
            </div>
         | 
| 68 | 
            +
            {% endif %}
         | 
| 69 | 
            +
             | 
| 70 | 
            +
            {% if recent_votes %}
         | 
| 71 | 
            +
            <div class="admin-card">
         | 
| 72 | 
            +
                <div class="admin-card-header">
         | 
| 73 | 
            +
                    <div class="admin-card-title">Recent Votes</div>
         | 
| 74 | 
            +
                </div>
         | 
| 75 | 
            +
                <div class="table-responsive">
         | 
| 76 | 
            +
                    <table class="admin-table">
         | 
| 77 | 
            +
                        <thead>
         | 
| 78 | 
            +
                            <tr>
         | 
| 79 | 
            +
                                <th>Date</th>
         | 
| 80 | 
            +
                                <th>Type</th>
         | 
| 81 | 
            +
                                <th>Chosen Model</th>
         | 
| 82 | 
            +
                                <th>Rejected Model</th>
         | 
| 83 | 
            +
                                <th>Text</th>
         | 
| 84 | 
            +
                            </tr>
         | 
| 85 | 
            +
                        </thead>
         | 
| 86 | 
            +
                        <tbody>
         | 
| 87 | 
            +
                            {% for vote in recent_votes %}
         | 
| 88 | 
            +
                            <tr>
         | 
| 89 | 
            +
                                <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
         | 
| 90 | 
            +
                                <td>{{ vote.model_type }}</td>
         | 
| 91 | 
            +
                                <td>{{ vote.chosen.name }}</td>
         | 
| 92 | 
            +
                                <td>{{ vote.rejected.name }}</td>
         | 
| 93 | 
            +
                                <td>
         | 
| 94 | 
            +
                                    <div class="text-truncate" title="{{ vote.text }}">
         | 
| 95 | 
            +
                                        {{ vote.text }}
         | 
| 96 | 
            +
                                    </div>
         | 
| 97 | 
            +
                                </td>
         | 
| 98 | 
            +
                            </tr>
         | 
| 99 | 
            +
                            {% endfor %}
         | 
| 100 | 
            +
                        </tbody>
         | 
| 101 | 
            +
                    </table>
         | 
| 102 | 
            +
                </div>
         | 
| 103 | 
            +
            </div>
         | 
| 104 | 
            +
            {% endif %}
         | 
| 105 | 
            +
             | 
| 106 | 
            +
            <style>
         | 
| 107 | 
            +
                .user-detail-row {
         | 
| 108 | 
            +
                    display: flex;
         | 
| 109 | 
            +
                    margin-bottom: 10px;
         | 
| 110 | 
            +
                }
         | 
| 111 | 
            +
                
         | 
| 112 | 
            +
                .user-detail-label {
         | 
| 113 | 
            +
                    font-weight: 600;
         | 
| 114 | 
            +
                    min-width: 150px;
         | 
| 115 | 
            +
                }
         | 
| 116 | 
            +
                
         | 
| 117 | 
            +
                .user-detail-value {
         | 
| 118 | 
            +
                    flex: 1;
         | 
| 119 | 
            +
                }
         | 
| 120 | 
            +
                
         | 
| 121 | 
            +
                .user-stats {
         | 
| 122 | 
            +
                    display: grid;
         | 
| 123 | 
            +
                    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
         | 
| 124 | 
            +
                    gap: 16px;
         | 
| 125 | 
            +
                    margin-top: 24px;
         | 
| 126 | 
            +
                }
         | 
| 127 | 
            +
                
         | 
| 128 | 
            +
                .text-truncate {
         | 
| 129 | 
            +
                    max-width: 300px;
         | 
| 130 | 
            +
                    white-space: nowrap;
         | 
| 131 | 
            +
                    overflow: hidden;
         | 
| 132 | 
            +
                    text-overflow: ellipsis;
         | 
| 133 | 
            +
                }
         | 
| 134 | 
            +
                
         | 
| 135 | 
            +
                @media (max-width: 576px) {
         | 
| 136 | 
            +
                    .user-detail-row {
         | 
| 137 | 
            +
                        flex-direction: column;
         | 
| 138 | 
            +
                    }
         | 
| 139 | 
            +
                    
         | 
| 140 | 
            +
                    .user-detail-label {
         | 
| 141 | 
            +
                        margin-bottom: 4px;
         | 
| 142 | 
            +
                    }
         | 
| 143 | 
            +
                }
         | 
| 144 | 
            +
            </style>
         | 
| 145 | 
            +
            {% endblock %} 
         | 
    	
        templates/admin/users.html
    ADDED
    
    | @@ -0,0 +1,47 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "admin/base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block admin_content %}
         | 
| 4 | 
            +
            <div class="admin-header">
         | 
| 5 | 
            +
                <div class="admin-title">Manage Users</div>
         | 
| 6 | 
            +
            </div>
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            <div class="admin-card">
         | 
| 9 | 
            +
                <div class="admin-card-header">
         | 
| 10 | 
            +
                    <div class="admin-card-title">All Users</div>
         | 
| 11 | 
            +
                </div>
         | 
| 12 | 
            +
                <div class="table-responsive">
         | 
| 13 | 
            +
                    <table class="admin-table">
         | 
| 14 | 
            +
                        <thead>
         | 
| 15 | 
            +
                            <tr>
         | 
| 16 | 
            +
                                <th>ID</th>
         | 
| 17 | 
            +
                                <th>Username</th>
         | 
| 18 | 
            +
                                <th>HF ID</th>
         | 
| 19 | 
            +
                                <th>Join Date</th>
         | 
| 20 | 
            +
                                <th>Admin Status</th>
         | 
| 21 | 
            +
                                <th>Actions</th>
         | 
| 22 | 
            +
                            </tr>
         | 
| 23 | 
            +
                        </thead>
         | 
| 24 | 
            +
                        <tbody>
         | 
| 25 | 
            +
                            {% for user in users %}
         | 
| 26 | 
            +
                            <tr>
         | 
| 27 | 
            +
                                <td>{{ user.id }}</td>
         | 
| 28 | 
            +
                                <td>{{ user.username }}</td>
         | 
| 29 | 
            +
                                <td>{{ user.hf_id }}</td>
         | 
| 30 | 
            +
                                <td>{{ user.join_date.strftime('%Y-%m-%d %H:%M') }}</td>
         | 
| 31 | 
            +
                                <td>
         | 
| 32 | 
            +
                                    {% if g.is_admin and user.username in admin_users %}
         | 
| 33 | 
            +
                                    <span class="badge badge-primary">Admin</span>
         | 
| 34 | 
            +
                                    {% else %}
         | 
| 35 | 
            +
                                    <span class="badge badge-secondary">User</span>
         | 
| 36 | 
            +
                                    {% endif %}
         | 
| 37 | 
            +
                                </td>
         | 
| 38 | 
            +
                                <td>
         | 
| 39 | 
            +
                                    <a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="action-btn">View Details</a>
         | 
| 40 | 
            +
                                </td>
         | 
| 41 | 
            +
                            </tr>
         | 
| 42 | 
            +
                            {% endfor %}
         | 
| 43 | 
            +
                        </tbody>
         | 
| 44 | 
            +
                    </table>
         | 
| 45 | 
            +
                </div>
         | 
| 46 | 
            +
            </div>
         | 
| 47 | 
            +
            {% endblock %} 
         | 
    	
        templates/admin/votes.html
    ADDED
    
    | @@ -0,0 +1,77 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "admin/base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block admin_content %}
         | 
| 4 | 
            +
            <div class="admin-header">
         | 
| 5 | 
            +
                <div class="admin-title">Votes</div>
         | 
| 6 | 
            +
            </div>
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            <div class="admin-card">
         | 
| 9 | 
            +
                <div class="admin-card-header">
         | 
| 10 | 
            +
                    <div class="admin-card-title">Recent Votes</div>
         | 
| 11 | 
            +
                </div>
         | 
| 12 | 
            +
                <div class="table-responsive">
         | 
| 13 | 
            +
                    <table class="admin-table">
         | 
| 14 | 
            +
                        <thead>
         | 
| 15 | 
            +
                            <tr>
         | 
| 16 | 
            +
                                <th>ID</th>
         | 
| 17 | 
            +
                                <th>Date</th>
         | 
| 18 | 
            +
                                <th>Type</th>
         | 
| 19 | 
            +
                                <th>User</th>
         | 
| 20 | 
            +
                                <th>Chosen Model</th>
         | 
| 21 | 
            +
                                <th>Rejected Model</th>
         | 
| 22 | 
            +
                                <th>Text</th>
         | 
| 23 | 
            +
                            </tr>
         | 
| 24 | 
            +
                        </thead>
         | 
| 25 | 
            +
                        <tbody>
         | 
| 26 | 
            +
                            {% for vote in votes %}
         | 
| 27 | 
            +
                            <tr>
         | 
| 28 | 
            +
                                <td>{{ vote.id }}</td>
         | 
| 29 | 
            +
                                <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
         | 
| 30 | 
            +
                                <td>{{ vote.model_type }}</td>
         | 
| 31 | 
            +
                                <td>
         | 
| 32 | 
            +
                                    {% if vote.user %}
         | 
| 33 | 
            +
                                        <a href="{{ url_for('admin.user_detail', user_id=vote.user.id) }}">{{ vote.user.username }}</a>
         | 
| 34 | 
            +
                                    {% else %}
         | 
| 35 | 
            +
                                        Anonymous
         | 
| 36 | 
            +
                                    {% endif %}
         | 
| 37 | 
            +
                                </td>
         | 
| 38 | 
            +
                                <td>{{ vote.chosen.name }}</td>
         | 
| 39 | 
            +
                                <td>{{ vote.rejected.name }}</td>
         | 
| 40 | 
            +
                                <td>
         | 
| 41 | 
            +
                                    <div class="text-truncate" title="{{ vote.text }}">
         | 
| 42 | 
            +
                                        {{ vote.text }}
         | 
| 43 | 
            +
                                    </div>
         | 
| 44 | 
            +
                                </td>
         | 
| 45 | 
            +
                            </tr>
         | 
| 46 | 
            +
                            {% endfor %}
         | 
| 47 | 
            +
                        </tbody>
         | 
| 48 | 
            +
                    </table>
         | 
| 49 | 
            +
                </div>
         | 
| 50 | 
            +
                
         | 
| 51 | 
            +
                {% if pagination.pages > 1 %}
         | 
| 52 | 
            +
                <nav aria-label="Page navigation">
         | 
| 53 | 
            +
                    <ul class="pagination">
         | 
| 54 | 
            +
                        {% if pagination.has_prev %}
         | 
| 55 | 
            +
                        <li><a href="{{ url_for('admin.votes', page=pagination.prev_num) }}">« Previous</a></li>
         | 
| 56 | 
            +
                        {% endif %}
         | 
| 57 | 
            +
                        
         | 
| 58 | 
            +
                        {% for page_num in pagination.iter_pages(left_edge=2, left_current=2, right_current=3, right_edge=2) %}
         | 
| 59 | 
            +
                            {% if page_num %}
         | 
| 60 | 
            +
                                {% if page_num == pagination.page %}
         | 
| 61 | 
            +
                                <li class="active"><a href="#">{{ page_num }}</a></li>
         | 
| 62 | 
            +
                                {% else %}
         | 
| 63 | 
            +
                                <li><a href="{{ url_for('admin.votes', page=page_num) }}">{{ page_num }}</a></li>
         | 
| 64 | 
            +
                                {% endif %}
         | 
| 65 | 
            +
                            {% else %}
         | 
| 66 | 
            +
                                <li class="disabled"><a href="#">...</a></li>
         | 
| 67 | 
            +
                            {% endif %}
         | 
| 68 | 
            +
                        {% endfor %}
         | 
| 69 | 
            +
                        
         | 
| 70 | 
            +
                        {% if pagination.has_next %}
         | 
| 71 | 
            +
                        <li><a href="{{ url_for('admin.votes', page=pagination.next_num) }}">Next »</a></li>
         | 
| 72 | 
            +
                        {% endif %}
         | 
| 73 | 
            +
                    </ul>
         | 
| 74 | 
            +
                </nav>
         | 
| 75 | 
            +
                {% endif %}
         | 
| 76 | 
            +
            </div>
         | 
| 77 | 
            +
            {% endblock %} 
         | 
    	
        templates/arena.html
    ADDED
    
    | @@ -0,0 +1,1959 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block title %}Arena - TTS Arena{% endblock %}
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            {% block current_page %}Arena{% endblock %}
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            {% block content %}
         | 
| 8 | 
            +
            <div class="tabs">
         | 
| 9 | 
            +
                <div class="tab active" data-tab="tts">TTS</div>
         | 
| 10 | 
            +
                <div class="tab" data-tab="conversational">Conversational</div>
         | 
| 11 | 
            +
            </div>
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            <div id="tts-tab" class="tab-content active">
         | 
| 14 | 
            +
                <form class="input-container">
         | 
| 15 | 
            +
                    <div class="input-group">
         | 
| 16 | 
            +
                        <button type="button" class="segmented-btn random-btn" title="Roll random text">
         | 
| 17 | 
            +
                            <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="lucide lucide-shuffle-icon lucide-shuffle">
         | 
| 18 | 
            +
                                <path d="m18 14 4 4-4 4" />
         | 
| 19 | 
            +
                                <path d="m18 2 4 4-4 4" />
         | 
| 20 | 
            +
                                <path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22" />
         | 
| 21 | 
            +
                                <path d="M2 6h1.972a4 4 0 0 1 3.6 2.2" />
         | 
| 22 | 
            +
                                <path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45" />
         | 
| 23 | 
            +
                            </svg>
         | 
| 24 | 
            +
                        </button>
         | 
| 25 | 
            +
                        <input type="text" class="text-input" placeholder="Enter text to synthesize...">
         | 
| 26 | 
            +
                        <button type="submit" class="segmented-btn synth-btn">Synthesize</button>
         | 
| 27 | 
            +
                    </div>
         | 
| 28 | 
            +
                    <button type="submit" class="mobile-synth-btn">Synthesize</button>
         | 
| 29 | 
            +
                </form>
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                <div class="loading-container" style="display: none;">
         | 
| 32 | 
            +
                    <div class="loader-wrapper">
         | 
| 33 | 
            +
                        <div class="loader-animation">
         | 
| 34 | 
            +
                            <div class="sound-wave">
         | 
| 35 | 
            +
                                <span></span>
         | 
| 36 | 
            +
                                <span></span>
         | 
| 37 | 
            +
                                <span></span>
         | 
| 38 | 
            +
                                <span></span>
         | 
| 39 | 
            +
                                <span></span>
         | 
| 40 | 
            +
                                <span></span>
         | 
| 41 | 
            +
                            </div>
         | 
| 42 | 
            +
                        </div>
         | 
| 43 | 
            +
                        <div class="loader-text">Generating audio samples...</div>
         | 
| 44 | 
            +
                        <div class="loader-subtext">This may take up to 30 seconds</div>
         | 
| 45 | 
            +
                    </div>
         | 
| 46 | 
            +
                </div>
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                <div class="players-container" style="display: none;">
         | 
| 49 | 
            +
                    <div class="players-row">
         | 
| 50 | 
            +
                        <div class="player">
         | 
| 51 | 
            +
                            <div class="player-label">Model A <span class="model-name-display"></span></div>
         | 
| 52 | 
            +
                            <div class="wave-player-container" data-model="a"></div>
         | 
| 53 | 
            +
                            <button class="vote-btn" data-model="a" disabled>
         | 
| 54 | 
            +
                                Vote for A
         | 
| 55 | 
            +
                                <span class="shortcut-key">A</span>
         | 
| 56 | 
            +
                                <span class="vote-loader" style="display: none;">
         | 
| 57 | 
            +
                                    <div class="vote-spinner"></div>
         | 
| 58 | 
            +
                                </span>
         | 
| 59 | 
            +
                            </button>
         | 
| 60 | 
            +
                        </div>
         | 
| 61 | 
            +
                
         | 
| 62 | 
            +
                        <div class="player">
         | 
| 63 | 
            +
                            <div class="player-label">Model B <span class="model-name-display"></span></div>
         | 
| 64 | 
            +
                            <div class="wave-player-container" data-model="b"></div>
         | 
| 65 | 
            +
                            <button class="vote-btn" data-model="b" disabled>
         | 
| 66 | 
            +
                                Vote for B
         | 
| 67 | 
            +
                                <span class="shortcut-key">B</span>
         | 
| 68 | 
            +
                                <span class="vote-loader" style="display: none;">
         | 
| 69 | 
            +
                                    <div class="vote-spinner"></div>
         | 
| 70 | 
            +
                                </span>
         | 
| 71 | 
            +
                            </button>
         | 
| 72 | 
            +
                        </div>
         | 
| 73 | 
            +
                    </div>
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                    <div class="keyboard-hint">
         | 
| 76 | 
            +
                        Press <kbd>Space</kbd> to play/pause audio, <kbd>A</kbd> or <kbd>B</kbd> to vote after listening
         | 
| 77 | 
            +
                    </div>
         | 
| 78 | 
            +
                </div>
         | 
| 79 | 
            +
             | 
| 80 | 
            +
                <div class="vote-results" style="display: none;">
         | 
| 81 | 
            +
                    <h3 class="results-heading">Vote Recorded!</h3>
         | 
| 82 | 
            +
                    <div class="results-content">
         | 
| 83 | 
            +
                        <div class="chosen-model">
         | 
| 84 | 
            +
                            <strong>You chose:</strong> <span class="chosen-model-name"></span>
         | 
| 85 | 
            +
                        </div>
         | 
| 86 | 
            +
                        <div class="rejected-model">
         | 
| 87 | 
            +
                            <strong>Over:</strong> <span class="rejected-model-name"></span>
         | 
| 88 | 
            +
                        </div>
         | 
| 89 | 
            +
                    </div>
         | 
| 90 | 
            +
                </div>
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                <div class="next-round-container" style="display: none;">
         | 
| 93 | 
            +
                    <button class="next-round-btn">Next Round <span class="shortcut-key">N</span></button>
         | 
| 94 | 
            +
                </div>
         | 
| 95 | 
            +
            </div>
         | 
| 96 | 
            +
             | 
| 97 | 
            +
            <div id="conversational-tab" class="tab-content">
         | 
| 98 | 
            +
                <div class="podcast-container">
         | 
| 99 | 
            +
                    <div class="podcast-controls">
         | 
| 100 | 
            +
                        <button type="button" class="segmented-btn random-script-btn" title="Load random script">
         | 
| 101 | 
            +
                            <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="lucide lucide-shuffle-icon lucide-shuffle">
         | 
| 102 | 
            +
                                <path d="m18 14 4 4-4 4" />
         | 
| 103 | 
            +
                                <path d="m18 2 4 4-4 4" />
         | 
| 104 | 
            +
                                <path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22" />
         | 
| 105 | 
            +
                                <path d="M2 6h1.972a4 4 0 0 1 3.6 2.2" />
         | 
| 106 | 
            +
                                <path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45" />
         | 
| 107 | 
            +
                            </svg>
         | 
| 108 | 
            +
                            Random Script
         | 
| 109 | 
            +
                        </button>
         | 
| 110 | 
            +
                        <button type="button" class="podcast-synth-btn">Generate Podcast</button>
         | 
| 111 | 
            +
                    </div>
         | 
| 112 | 
            +
                    
         | 
| 113 | 
            +
                    <div class="podcast-script-container">
         | 
| 114 | 
            +
                        <div class="podcast-lines">
         | 
| 115 | 
            +
                            <!-- Script lines will be added here -->
         | 
| 116 | 
            +
                        </div>
         | 
| 117 | 
            +
                        
         | 
| 118 | 
            +
                        <button type="button" class="add-line-btn">+ Add Line</button>
         | 
| 119 | 
            +
                        
         | 
| 120 | 
            +
                        <div class="keyboard-hint podcast-keyboard-hint">
         | 
| 121 | 
            +
                            Press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> or <kbd>Alt</kbd>+<kbd>Enter</kbd> to add a new line
         | 
| 122 | 
            +
                        </div>
         | 
| 123 | 
            +
                    </div>
         | 
| 124 | 
            +
                    
         | 
| 125 | 
            +
                    <div class="podcast-loading-container" style="display: none;">
         | 
| 126 | 
            +
                        <div class="loader-wrapper">
         | 
| 127 | 
            +
                            <div class="loader-animation">
         | 
| 128 | 
            +
                                <div class="sound-wave">
         | 
| 129 | 
            +
                                    <span></span>
         | 
| 130 | 
            +
                                    <span></span>
         | 
| 131 | 
            +
                                    <span></span>
         | 
| 132 | 
            +
                                    <span></span>
         | 
| 133 | 
            +
                                    <span></span>
         | 
| 134 | 
            +
                                    <span></span>
         | 
| 135 | 
            +
                                </div>
         | 
| 136 | 
            +
                            </div>
         | 
| 137 | 
            +
                            <div class="loader-text">Generating podcast...</div>
         | 
| 138 | 
            +
                            <div class="loader-subtext">This may take up to a minute</div>
         | 
| 139 | 
            +
                        </div>
         | 
| 140 | 
            +
                    </div>
         | 
| 141 | 
            +
             | 
| 142 | 
            +
                    <div class="podcast-player-container" style="display: none;">
         | 
| 143 | 
            +
                        <div class="players-row">
         | 
| 144 | 
            +
                            <div class="player">
         | 
| 145 | 
            +
                                <div class="player-label">Model A <span class="model-name-display"></span></div>
         | 
| 146 | 
            +
                                <div class="podcast-wave-player-a"></div>
         | 
| 147 | 
            +
                                <button class="vote-btn" data-model="a" disabled>
         | 
| 148 | 
            +
                                    Vote for A
         | 
| 149 | 
            +
                                    <span class="shortcut-key">A</span>
         | 
| 150 | 
            +
                                    <span class="vote-loader" style="display: none;">
         | 
| 151 | 
            +
                                        <div class="vote-spinner"></div>
         | 
| 152 | 
            +
                                    </span>
         | 
| 153 | 
            +
                                </button>
         | 
| 154 | 
            +
                            </div>
         | 
| 155 | 
            +
                    
         | 
| 156 | 
            +
                            <div class="player">
         | 
| 157 | 
            +
                                <div class="player-label">Model B <span class="model-name-display"></span></div>
         | 
| 158 | 
            +
                                <div class="podcast-wave-player-b"></div>
         | 
| 159 | 
            +
                                <button class="vote-btn" data-model="b" disabled>
         | 
| 160 | 
            +
                                    Vote for B
         | 
| 161 | 
            +
                                    <span class="shortcut-key">B</span>
         | 
| 162 | 
            +
                                    <span class="vote-loader" style="display: none;">
         | 
| 163 | 
            +
                                        <div class="vote-spinner"></div>
         | 
| 164 | 
            +
                                    </span>
         | 
| 165 | 
            +
                                </button>
         | 
| 166 | 
            +
                            </div>
         | 
| 167 | 
            +
                        </div>
         | 
| 168 | 
            +
             | 
| 169 | 
            +
                        <div class="keyboard-hint">
         | 
| 170 | 
            +
                            Press <kbd>Space</kbd> to play/pause audio, <kbd>A</kbd> or <kbd>B</kbd> to vote after listening
         | 
| 171 | 
            +
                        </div>
         | 
| 172 | 
            +
                        
         | 
| 173 | 
            +
                        <div class="podcast-vote-results vote-results" style="display: none;">
         | 
| 174 | 
            +
                            <h3 class="results-heading">Vote Recorded!</h3>
         | 
| 175 | 
            +
                            <div class="results-content">
         | 
| 176 | 
            +
                                <div class="chosen-model">
         | 
| 177 | 
            +
                                    <strong>You chose:</strong> <span class="chosen-model-name"></span>
         | 
| 178 | 
            +
                                </div>
         | 
| 179 | 
            +
                                <div class="rejected-model">
         | 
| 180 | 
            +
                                    <strong>Over:</strong> <span class="rejected-model-name"></span>
         | 
| 181 | 
            +
                                </div>
         | 
| 182 | 
            +
                            </div>
         | 
| 183 | 
            +
                        </div>
         | 
| 184 | 
            +
                        
         | 
| 185 | 
            +
                        <div class="podcast-next-round-container next-round-container" style="display: none;">
         | 
| 186 | 
            +
                            <button class="podcast-next-round-btn next-round-btn">Next Round <span class="shortcut-key">N</span></button>
         | 
| 187 | 
            +
                        </div>
         | 
| 188 | 
            +
                    </div>
         | 
| 189 | 
            +
                </div>
         | 
| 190 | 
            +
            </div>
         | 
| 191 | 
            +
            {% endblock %}
         | 
| 192 | 
            +
             | 
| 193 | 
            +
            {% block extra_head %}
         | 
| 194 | 
            +
            <link rel="stylesheet" href="{{ url_for('static', filename='css/waveplayer.css') }}">
         | 
| 195 | 
            +
            <script src="https://unpkg.com/wavesurfer.js@6/dist/wavesurfer.min.js"></script>
         | 
| 196 | 
            +
            <style>
         | 
| 197 | 
            +
                .input-container {
         | 
| 198 | 
            +
                    display: flex;
         | 
| 199 | 
            +
                    flex-direction: column;
         | 
| 200 | 
            +
                    margin-bottom: 24px;
         | 
| 201 | 
            +
                }
         | 
| 202 | 
            +
                
         | 
| 203 | 
            +
                .input-group {
         | 
| 204 | 
            +
                    display: flex;
         | 
| 205 | 
            +
                    width: 100%;
         | 
| 206 | 
            +
                    border-radius: var(--radius);
         | 
| 207 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 208 | 
            +
                    overflow: hidden;
         | 
| 209 | 
            +
                }
         | 
| 210 | 
            +
                
         | 
| 211 | 
            +
                /* Override base styles to remove duplicate borders */
         | 
| 212 | 
            +
                .input-group .text-input {
         | 
| 213 | 
            +
                    flex: 1;
         | 
| 214 | 
            +
                    padding: 12px 16px;
         | 
| 215 | 
            +
                    border: none;
         | 
| 216 | 
            +
                    border-radius: 0;
         | 
| 217 | 
            +
                    font-size: 16px;
         | 
| 218 | 
            +
                    outline: none;
         | 
| 219 | 
            +
                    height: 48px;
         | 
| 220 | 
            +
                    transition: none;
         | 
| 221 | 
            +
                }
         | 
| 222 | 
            +
                
         | 
| 223 | 
            +
                .input-group .text-input:focus {
         | 
| 224 | 
            +
                    border: none;
         | 
| 225 | 
            +
                    outline: none;
         | 
| 226 | 
            +
                    background-color: rgba(80, 70, 229, 0.03);
         | 
| 227 | 
            +
                }
         | 
| 228 | 
            +
                
         | 
| 229 | 
            +
                .segmented-btn {
         | 
| 230 | 
            +
                    background-color: white;
         | 
| 231 | 
            +
                    border: none;
         | 
| 232 | 
            +
                    height: 48px;
         | 
| 233 | 
            +
                    display: flex;
         | 
| 234 | 
            +
                    align-items: center;
         | 
| 235 | 
            +
                    justify-content: center;
         | 
| 236 | 
            +
                    cursor: pointer;
         | 
| 237 | 
            +
                    transition: background-color 0.2s;
         | 
| 238 | 
            +
                }
         | 
| 239 | 
            +
                
         | 
| 240 | 
            +
                .random-btn {
         | 
| 241 | 
            +
                    width: 48px;
         | 
| 242 | 
            +
                    border-right: 1px solid var(--border-color);
         | 
| 243 | 
            +
                }
         | 
| 244 | 
            +
             | 
| 245 | 
            +
                .random-btn svg {
         | 
| 246 | 
            +
                    color: var(--primary-color);
         | 
| 247 | 
            +
                }
         | 
| 248 | 
            +
                
         | 
| 249 | 
            +
                .synth-btn {
         | 
| 250 | 
            +
                    padding: 0 24px;
         | 
| 251 | 
            +
                    font-weight: 500;
         | 
| 252 | 
            +
                    border-left: 1px solid var(--border-color);
         | 
| 253 | 
            +
                    background-color: var(--primary-color);
         | 
| 254 | 
            +
                    color: white;
         | 
| 255 | 
            +
                    font-size: 1em;
         | 
| 256 | 
            +
                }
         | 
| 257 | 
            +
                
         | 
| 258 | 
            +
                .synth-btn:hover {
         | 
| 259 | 
            +
                    background-color: #4038c7;
         | 
| 260 | 
            +
                }
         | 
| 261 | 
            +
                
         | 
| 262 | 
            +
                .random-btn:hover {
         | 
| 263 | 
            +
                    background-color: var(--light-gray);
         | 
| 264 | 
            +
                }
         | 
| 265 | 
            +
                
         | 
| 266 | 
            +
                .mobile-synth-btn {
         | 
| 267 | 
            +
                    display: none;
         | 
| 268 | 
            +
                    width: 100%;
         | 
| 269 | 
            +
                    padding: 12px;
         | 
| 270 | 
            +
                    margin-top: 12px;
         | 
| 271 | 
            +
                    background-color: var(--primary-color);
         | 
| 272 | 
            +
                    color: white;
         | 
| 273 | 
            +
                    border: none;
         | 
| 274 | 
            +
                    border-radius: var(--radius);
         | 
| 275 | 
            +
                    font-weight: 500;
         | 
| 276 | 
            +
                    cursor: pointer;
         | 
| 277 | 
            +
                    font-size: 1em;
         | 
| 278 | 
            +
                }
         | 
| 279 | 
            +
                
         | 
| 280 | 
            +
                .loading-container {
         | 
| 281 | 
            +
                    display: flex;
         | 
| 282 | 
            +
                    justify-content: center;
         | 
| 283 | 
            +
                    align-items: center;
         | 
| 284 | 
            +
                    margin: 40px 0;
         | 
| 285 | 
            +
                }
         | 
| 286 | 
            +
                
         | 
| 287 | 
            +
                .loader-wrapper {
         | 
| 288 | 
            +
                    text-align: center;
         | 
| 289 | 
            +
                }
         | 
| 290 | 
            +
                
         | 
| 291 | 
            +
                .loader-animation {
         | 
| 292 | 
            +
                    margin-bottom: 24px;
         | 
| 293 | 
            +
                }
         | 
| 294 | 
            +
                
         | 
| 295 | 
            +
                .loader-text {
         | 
| 296 | 
            +
                    font-size: 18px;
         | 
| 297 | 
            +
                    font-weight: 600;
         | 
| 298 | 
            +
                    margin-bottom: 8px;
         | 
| 299 | 
            +
                    color: var(--text-color);
         | 
| 300 | 
            +
                }
         | 
| 301 | 
            +
                
         | 
| 302 | 
            +
                .loader-subtext {
         | 
| 303 | 
            +
                    font-size: 14px;
         | 
| 304 | 
            +
                    color: #666;
         | 
| 305 | 
            +
                }
         | 
| 306 | 
            +
                
         | 
| 307 | 
            +
                .sound-wave {
         | 
| 308 | 
            +
                    height: 60px;
         | 
| 309 | 
            +
                    display: flex;
         | 
| 310 | 
            +
                    align-items: center;
         | 
| 311 | 
            +
                    justify-content: center;
         | 
| 312 | 
            +
                    gap: 8px;
         | 
| 313 | 
            +
                }
         | 
| 314 | 
            +
                
         | 
| 315 | 
            +
                .sound-wave span {
         | 
| 316 | 
            +
                    display: block;
         | 
| 317 | 
            +
                    width: 6px;
         | 
| 318 | 
            +
                    height: 20px;
         | 
| 319 | 
            +
                    background-color: var(--primary-color);
         | 
| 320 | 
            +
                    border-radius: 8px;
         | 
| 321 | 
            +
                    animation: sound-wave-animation 1.2s infinite ease-in-out;
         | 
| 322 | 
            +
                }
         | 
| 323 | 
            +
                
         | 
| 324 | 
            +
                .sound-wave span:nth-child(2) {
         | 
| 325 | 
            +
                    animation-delay: 0.2s;
         | 
| 326 | 
            +
                }
         | 
| 327 | 
            +
                
         | 
| 328 | 
            +
                .sound-wave span:nth-child(3) {
         | 
| 329 | 
            +
                    animation-delay: 0.4s;
         | 
| 330 | 
            +
                }
         | 
| 331 | 
            +
                
         | 
| 332 | 
            +
                .sound-wave span:nth-child(4) {
         | 
| 333 | 
            +
                    animation-delay: 0.6s;
         | 
| 334 | 
            +
                }
         | 
| 335 | 
            +
                
         | 
| 336 | 
            +
                .sound-wave span:nth-child(5) {
         | 
| 337 | 
            +
                    animation-delay: 0.8s;
         | 
| 338 | 
            +
                }
         | 
| 339 | 
            +
                
         | 
| 340 | 
            +
                .sound-wave span:nth-child(6) {
         | 
| 341 | 
            +
                    animation-delay: 1s;
         | 
| 342 | 
            +
                }
         | 
| 343 | 
            +
                
         | 
| 344 | 
            +
                @keyframes sound-wave-animation {
         | 
| 345 | 
            +
                    0%, 100% {
         | 
| 346 | 
            +
                        height: 20px;
         | 
| 347 | 
            +
                    }
         | 
| 348 | 
            +
                    50% {
         | 
| 349 | 
            +
                        height: 50px;
         | 
| 350 | 
            +
                    }
         | 
| 351 | 
            +
                }
         | 
| 352 | 
            +
                
         | 
| 353 | 
            +
                .vote-btn {
         | 
| 354 | 
            +
                    position: relative;
         | 
| 355 | 
            +
                    color: black;
         | 
| 356 | 
            +
                    font-size: 1rem;
         | 
| 357 | 
            +
                }
         | 
| 358 | 
            +
                
         | 
| 359 | 
            +
                .vote-btn.selected {
         | 
| 360 | 
            +
                    background-color: var(--primary-color);
         | 
| 361 | 
            +
                    color: white;
         | 
| 362 | 
            +
                }
         | 
| 363 | 
            +
                
         | 
| 364 | 
            +
                .vote-btn:disabled {
         | 
| 365 | 
            +
                    opacity: 0.7;
         | 
| 366 | 
            +
                    cursor: not-allowed;
         | 
| 367 | 
            +
                }
         | 
| 368 | 
            +
                
         | 
| 369 | 
            +
                .vote-loader {
         | 
| 370 | 
            +
                    position: absolute;
         | 
| 371 | 
            +
                    top: 0;
         | 
| 372 | 
            +
                    left: 0;
         | 
| 373 | 
            +
                    width: 100%;
         | 
| 374 | 
            +
                    height: 100%;
         | 
| 375 | 
            +
                    display: flex;
         | 
| 376 | 
            +
                    align-items: center;
         | 
| 377 | 
            +
                    justify-content: center;
         | 
| 378 | 
            +
                    background-color: rgba(255, 255, 255, 0.8);
         | 
| 379 | 
            +
                }
         | 
| 380 | 
            +
                
         | 
| 381 | 
            +
                .vote-spinner {
         | 
| 382 | 
            +
                    width: 20px;
         | 
| 383 | 
            +
                    height: 20px;
         | 
| 384 | 
            +
                    border: 2px solid rgba(80, 70, 229, 0.3);
         | 
| 385 | 
            +
                    border-radius: 50%;
         | 
| 386 | 
            +
                    border-top-color: var(--primary-color);
         | 
| 387 | 
            +
                    animation: spin 1s linear infinite;
         | 
| 388 | 
            +
                }
         | 
| 389 | 
            +
                
         | 
| 390 | 
            +
                .next-round-container {
         | 
| 391 | 
            +
                    margin-top: 24px;
         | 
| 392 | 
            +
                    text-align: center;
         | 
| 393 | 
            +
                }
         | 
| 394 | 
            +
                
         | 
| 395 | 
            +
                .next-round-btn {
         | 
| 396 | 
            +
                    padding: 12px 24px;
         | 
| 397 | 
            +
                    background-color: var(--primary-color);
         | 
| 398 | 
            +
                    color: white;
         | 
| 399 | 
            +
                    border: none;
         | 
| 400 | 
            +
                    border-radius: var(--radius);
         | 
| 401 | 
            +
                    font-weight: 500;
         | 
| 402 | 
            +
                    cursor: pointer;
         | 
| 403 | 
            +
                    position: relative;
         | 
| 404 | 
            +
                    width: 100%;
         | 
| 405 | 
            +
                    font-size: 1rem;
         | 
| 406 | 
            +
                    transition: background-color 0.2s;
         | 
| 407 | 
            +
                }
         | 
| 408 | 
            +
                
         | 
| 409 | 
            +
                .next-round-btn:hover {
         | 
| 410 | 
            +
                    background-color: #4038c7;
         | 
| 411 | 
            +
                }
         | 
| 412 | 
            +
                
         | 
| 413 | 
            +
                /* Vote results styling */
         | 
| 414 | 
            +
                .vote-results {
         | 
| 415 | 
            +
                    background-color: #f0f4ff;
         | 
| 416 | 
            +
                    border: 1px solid #d0d7f7;
         | 
| 417 | 
            +
                    border-radius: var(--radius);
         | 
| 418 | 
            +
                    padding: 16px;
         | 
| 419 | 
            +
                    margin: 24px 0;
         | 
| 420 | 
            +
                }
         | 
| 421 | 
            +
                
         | 
| 422 | 
            +
                .results-heading {
         | 
| 423 | 
            +
                    color: var(--primary-color);
         | 
| 424 | 
            +
                    margin-bottom: 12px;
         | 
| 425 | 
            +
                    font-size: 18px;
         | 
| 426 | 
            +
                }
         | 
| 427 | 
            +
                
         | 
| 428 | 
            +
                .results-content {
         | 
| 429 | 
            +
                    display: flex;
         | 
| 430 | 
            +
                    flex-direction: column;
         | 
| 431 | 
            +
                    gap: 8px;
         | 
| 432 | 
            +
                }
         | 
| 433 | 
            +
                
         | 
| 434 | 
            +
                @keyframes spin {
         | 
| 435 | 
            +
                    to {
         | 
| 436 | 
            +
                        transform: rotate(360deg);
         | 
| 437 | 
            +
                    }
         | 
| 438 | 
            +
                }
         | 
| 439 | 
            +
                
         | 
| 440 | 
            +
                /* Tab styling */
         | 
| 441 | 
            +
                .tabs {
         | 
| 442 | 
            +
                    display: flex;
         | 
| 443 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 444 | 
            +
                    margin-bottom: 24px;
         | 
| 445 | 
            +
                }
         | 
| 446 | 
            +
                
         | 
| 447 | 
            +
                .tab {
         | 
| 448 | 
            +
                    padding: 12px 24px;
         | 
| 449 | 
            +
                    cursor: pointer;
         | 
| 450 | 
            +
                    position: relative;
         | 
| 451 | 
            +
                    font-weight: 500;
         | 
| 452 | 
            +
                }
         | 
| 453 | 
            +
                
         | 
| 454 | 
            +
                .tab.active {
         | 
| 455 | 
            +
                    color: var(--primary-color);
         | 
| 456 | 
            +
                }
         | 
| 457 | 
            +
                
         | 
| 458 | 
            +
                .tab.active::after {
         | 
| 459 | 
            +
                    content: '';
         | 
| 460 | 
            +
                    position: absolute;
         | 
| 461 | 
            +
                    bottom: -1px;
         | 
| 462 | 
            +
                    left: 0;
         | 
| 463 | 
            +
                    width: 100%;
         | 
| 464 | 
            +
                    height: 2px;
         | 
| 465 | 
            +
                    background-color: var(--primary-color);
         | 
| 466 | 
            +
                }
         | 
| 467 | 
            +
                
         | 
| 468 | 
            +
                .tab-content {
         | 
| 469 | 
            +
                    display: none;
         | 
| 470 | 
            +
                }
         | 
| 471 | 
            +
                
         | 
| 472 | 
            +
                .tab-content.active {
         | 
| 473 | 
            +
                    display: block;
         | 
| 474 | 
            +
                }
         | 
| 475 | 
            +
                
         | 
| 476 | 
            +
                /* Coming soon styling */
         | 
| 477 | 
            +
                .coming-soon-container {
         | 
| 478 | 
            +
                    display: flex;
         | 
| 479 | 
            +
                    flex-direction: column;
         | 
| 480 | 
            +
                    align-items: center;
         | 
| 481 | 
            +
                    justify-content: center;
         | 
| 482 | 
            +
                    text-align: center;
         | 
| 483 | 
            +
                    padding: 60px 20px;
         | 
| 484 | 
            +
                    background-color: var(--light-gray);
         | 
| 485 | 
            +
                    border-radius: var(--radius);
         | 
| 486 | 
            +
                    margin: 20px 0;
         | 
| 487 | 
            +
                }
         | 
| 488 | 
            +
                
         | 
| 489 | 
            +
                .coming-soon-icon {
         | 
| 490 | 
            +
                    color: var(--primary-color);
         | 
| 491 | 
            +
                    margin-bottom: 20px;
         | 
| 492 | 
            +
                }
         | 
| 493 | 
            +
                
         | 
| 494 | 
            +
                .coming-soon-title {
         | 
| 495 | 
            +
                    font-size: 24px;
         | 
| 496 | 
            +
                    font-weight: 600;
         | 
| 497 | 
            +
                    margin-bottom: 16px;
         | 
| 498 | 
            +
                    color: var(--text-color);
         | 
| 499 | 
            +
                }
         | 
| 500 | 
            +
                
         | 
| 501 | 
            +
                .coming-soon-text {
         | 
| 502 | 
            +
                    font-size: 16px;
         | 
| 503 | 
            +
                    color: #666;
         | 
| 504 | 
            +
                    max-width: 500px;
         | 
| 505 | 
            +
                    line-height: 1.5;
         | 
| 506 | 
            +
                }
         | 
| 507 | 
            +
                
         | 
| 508 | 
            +
                .model-name-display {
         | 
| 509 | 
            +
                    font-size: 0.9em;
         | 
| 510 | 
            +
                    color: #666;
         | 
| 511 | 
            +
                    font-style: italic;
         | 
| 512 | 
            +
                }
         | 
| 513 | 
            +
             | 
| 514 | 
            +
                /* WaveSurfer Custom Styles */
         | 
| 515 | 
            +
                .player {
         | 
| 516 | 
            +
                    padding-bottom: 20px;
         | 
| 517 | 
            +
                }
         | 
| 518 | 
            +
             | 
| 519 | 
            +
                .wave-player-container {
         | 
| 520 | 
            +
                    margin-bottom: 16px;
         | 
| 521 | 
            +
                }
         | 
| 522 | 
            +
             | 
| 523 | 
            +
                /* Keyboard shortcut hint */
         | 
| 524 | 
            +
                .keyboard-hint {
         | 
| 525 | 
            +
                    text-align: center;
         | 
| 526 | 
            +
                    margin-top: 8px;
         | 
| 527 | 
            +
                    font-size: 13px;
         | 
| 528 | 
            +
                    color: #888;
         | 
| 529 | 
            +
                }
         | 
| 530 | 
            +
             | 
| 531 | 
            +
                .keyboard-hint kbd {
         | 
| 532 | 
            +
                    display: inline-block;
         | 
| 533 | 
            +
                    padding: 3px 5px;
         | 
| 534 | 
            +
                    font-size: 11px;
         | 
| 535 | 
            +
                    line-height: 10px;
         | 
| 536 | 
            +
                    color: #444;
         | 
| 537 | 
            +
                    vertical-align: middle;
         | 
| 538 | 
            +
                    background-color: #fafafa;
         | 
| 539 | 
            +
                    border: 1px solid #ccc;
         | 
| 540 | 
            +
                    border-radius: 3px;
         | 
| 541 | 
            +
                    box-shadow: 0 1px 0 rgba(0,0,0,0.2);
         | 
| 542 | 
            +
                    margin: 0 2px;
         | 
| 543 | 
            +
                }
         | 
| 544 | 
            +
                
         | 
| 545 | 
            +
                @media (max-width: 768px) {
         | 
| 546 | 
            +
                    .input-group {
         | 
| 547 | 
            +
                        border-radius: var(--radius);
         | 
| 548 | 
            +
                    }
         | 
| 549 | 
            +
                    
         | 
| 550 | 
            +
                    .synth-btn {
         | 
| 551 | 
            +
                        display: none;
         | 
| 552 | 
            +
                    }
         | 
| 553 | 
            +
                    
         | 
| 554 | 
            +
                    .mobile-synth-btn {
         | 
| 555 | 
            +
                        display: block;
         | 
| 556 | 
            +
                    }
         | 
| 557 | 
            +
                    
         | 
| 558 | 
            +
                    /* Stack players vertically on mobile */
         | 
| 559 | 
            +
                    .players-row {
         | 
| 560 | 
            +
                        flex-direction: column;
         | 
| 561 | 
            +
                        gap: 16px;
         | 
| 562 | 
            +
                    }
         | 
| 563 | 
            +
                }
         | 
| 564 | 
            +
                /* Dark mode styles */
         | 
| 565 | 
            +
                @media (prefers-color-scheme: dark) {
         | 
| 566 | 
            +
                    .coming-soon-container {
         | 
| 567 | 
            +
                        background-color: var(--light-gray);
         | 
| 568 | 
            +
                    }
         | 
| 569 | 
            +
                    
         | 
| 570 | 
            +
                    .coming-soon-text {
         | 
| 571 | 
            +
                        color: #aaa;
         | 
| 572 | 
            +
                    }
         | 
| 573 | 
            +
                    
         | 
| 574 | 
            +
                    .model-name-display {
         | 
| 575 | 
            +
                        color: #aaa;
         | 
| 576 | 
            +
                    }
         | 
| 577 | 
            +
                    
         | 
| 578 | 
            +
                    /* Fix vote recorded section in dark mode */
         | 
| 579 | 
            +
                    .vote-results {
         | 
| 580 | 
            +
                        background-color: var(--light-gray);
         | 
| 581 | 
            +
                        border-color: var(--border-color);
         | 
| 582 | 
            +
                    }
         | 
| 583 | 
            +
                    
         | 
| 584 | 
            +
                    .results-heading {
         | 
| 585 | 
            +
                        color: var(--primary-color);
         | 
| 586 | 
            +
                    }
         | 
| 587 | 
            +
                    
         | 
| 588 | 
            +
                    .results-content {
         | 
| 589 | 
            +
                        color: var(--text-color);
         | 
| 590 | 
            +
                    }
         | 
| 591 | 
            +
                    
         | 
| 592 | 
            +
                    .chosen-model,
         | 
| 593 | 
            +
                    .rejected-model {
         | 
| 594 | 
            +
                        color: var(--text-color);
         | 
| 595 | 
            +
                    }
         | 
| 596 | 
            +
                    
         | 
| 597 | 
            +
                    .chosen-model strong,
         | 
| 598 | 
            +
                    .rejected-model strong {
         | 
| 599 | 
            +
                        color: var(--text-color);
         | 
| 600 | 
            +
                    }
         | 
| 601 | 
            +
                    
         | 
| 602 | 
            +
                    .chosen-model-name,
         | 
| 603 | 
            +
                    .rejected-model-name {
         | 
| 604 | 
            +
                        color: var(--text-color);
         | 
| 605 | 
            +
                    }
         | 
| 606 | 
            +
                    
         | 
| 607 | 
            +
                    .vote-btn {
         | 
| 608 | 
            +
                        background-color: var(--light-gray);
         | 
| 609 | 
            +
                        color: var(--text-color);
         | 
| 610 | 
            +
                        border-color: var(--border-color);
         | 
| 611 | 
            +
                    }
         | 
| 612 | 
            +
                    
         | 
| 613 | 
            +
                    .vote-btn:hover {
         | 
| 614 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 615 | 
            +
                        border-color: var(--border-color);
         | 
| 616 | 
            +
                    }
         | 
| 617 | 
            +
                    
         | 
| 618 | 
            +
                    .vote-btn.selected {
         | 
| 619 | 
            +
                        background-color: var(--primary-color);
         | 
| 620 | 
            +
                        color: white;
         | 
| 621 | 
            +
                        border-color: var(--primary-color);
         | 
| 622 | 
            +
                    }
         | 
| 623 | 
            +
                    
         | 
| 624 | 
            +
                    .shortcut-key {
         | 
| 625 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 626 | 
            +
                        color: var(--text-color);
         | 
| 627 | 
            +
                        border-color: var(--border-color);
         | 
| 628 | 
            +
                    }
         | 
| 629 | 
            +
                    
         | 
| 630 | 
            +
                    .vote-btn.selected .shortcut-key {
         | 
| 631 | 
            +
                        background-color: rgba(255, 255, 255, 0.2);
         | 
| 632 | 
            +
                        color: white;
         | 
| 633 | 
            +
                        border-color: transparent;
         | 
| 634 | 
            +
                    }
         | 
| 635 | 
            +
                    
         | 
| 636 | 
            +
                    .random-btn {
         | 
| 637 | 
            +
                        background-color: var(--light-gray);
         | 
| 638 | 
            +
                        color: var(--text-color);
         | 
| 639 | 
            +
                        border-color: var(--border-color);
         | 
| 640 | 
            +
                    }
         | 
| 641 | 
            +
                    
         | 
| 642 | 
            +
                    .random-btn:hover {
         | 
| 643 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 644 | 
            +
                    }
         | 
| 645 | 
            +
                    
         | 
| 646 | 
            +
                    .vote-recorded {
         | 
| 647 | 
            +
                        background-color: var(--light-gray);
         | 
| 648 | 
            +
                        border-color: var(--border-color);
         | 
| 649 | 
            +
                    }
         | 
| 650 | 
            +
                    
         | 
| 651 | 
            +
                    /* Ensure border-radius is maintained during loading state */
         | 
| 652 | 
            +
                    .vote-btn.loading {
         | 
| 653 | 
            +
                        border-radius: var(--radius);
         | 
| 654 | 
            +
                    }
         | 
| 655 | 
            +
             | 
| 656 | 
            +
                    /* Dark mode keyboard hint */
         | 
| 657 | 
            +
                    .keyboard-hint {
         | 
| 658 | 
            +
                        color: #aaa;
         | 
| 659 | 
            +
                    }
         | 
| 660 | 
            +
             | 
| 661 | 
            +
                    .keyboard-hint kbd {
         | 
| 662 | 
            +
                        color: #ddd;
         | 
| 663 | 
            +
                        background-color: #333;
         | 
| 664 | 
            +
                        border-color: #555;
         | 
| 665 | 
            +
                        box-shadow: 0 1px 0 rgba(255,255,255,0.1);
         | 
| 666 | 
            +
                    }
         | 
| 667 | 
            +
                }
         | 
| 668 | 
            +
                
         | 
| 669 | 
            +
                /* Podcast UI styles */
         | 
| 670 | 
            +
                .podcast-container {
         | 
| 671 | 
            +
                    width: 100%;
         | 
| 672 | 
            +
                }
         | 
| 673 | 
            +
                
         | 
| 674 | 
            +
                .podcast-controls {
         | 
| 675 | 
            +
                    display: flex;
         | 
| 676 | 
            +
                    gap: 12px;
         | 
| 677 | 
            +
                    margin-bottom: 24px;
         | 
| 678 | 
            +
                }
         | 
| 679 | 
            +
                
         | 
| 680 | 
            +
                .random-script-btn {
         | 
| 681 | 
            +
                    display: flex;
         | 
| 682 | 
            +
                    align-items: center;
         | 
| 683 | 
            +
                    gap: 8px;
         | 
| 684 | 
            +
                    padding: 0 16px;
         | 
| 685 | 
            +
                    height: 40px;
         | 
| 686 | 
            +
                    background-color: white;
         | 
| 687 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 688 | 
            +
                    border-radius: var(--radius);
         | 
| 689 | 
            +
                    cursor: pointer;
         | 
| 690 | 
            +
                    transition: background-color 0.2s;
         | 
| 691 | 
            +
                }
         | 
| 692 | 
            +
                
         | 
| 693 | 
            +
                .random-script-btn:hover {
         | 
| 694 | 
            +
                    background-color: var(--light-gray);
         | 
| 695 | 
            +
                }
         | 
| 696 | 
            +
                
         | 
| 697 | 
            +
                .podcast-synth-btn {
         | 
| 698 | 
            +
                    padding: 0 24px;
         | 
| 699 | 
            +
                    height: 40px;
         | 
| 700 | 
            +
                    background-color: var(--primary-color);
         | 
| 701 | 
            +
                    color: white;
         | 
| 702 | 
            +
                    border: none;
         | 
| 703 | 
            +
                    border-radius: var(--radius);
         | 
| 704 | 
            +
                    font-weight: 500;
         | 
| 705 | 
            +
                    cursor: pointer;
         | 
| 706 | 
            +
                    transition: background-color 0.2s;
         | 
| 707 | 
            +
                }
         | 
| 708 | 
            +
                
         | 
| 709 | 
            +
                .podcast-synth-btn:hover {
         | 
| 710 | 
            +
                    background-color: #4038c7;
         | 
| 711 | 
            +
                }
         | 
| 712 | 
            +
                
         | 
| 713 | 
            +
                .podcast-script-container {
         | 
| 714 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 715 | 
            +
                    border-radius: var(--radius);
         | 
| 716 | 
            +
                    overflow: hidden;
         | 
| 717 | 
            +
                    margin-bottom: 24px;
         | 
| 718 | 
            +
                }
         | 
| 719 | 
            +
                
         | 
| 720 | 
            +
                .podcast-lines {
         | 
| 721 | 
            +
                    max-height: 500px;
         | 
| 722 | 
            +
                    overflow-y: auto;
         | 
| 723 | 
            +
                }
         | 
| 724 | 
            +
                
         | 
| 725 | 
            +
                .podcast-line {
         | 
| 726 | 
            +
                    display: flex;
         | 
| 727 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 728 | 
            +
                }
         | 
| 729 | 
            +
                
         | 
| 730 | 
            +
                .speaker-label {
         | 
| 731 | 
            +
                    width: 120px;
         | 
| 732 | 
            +
                    padding: 12px;
         | 
| 733 | 
            +
                    display: flex;
         | 
| 734 | 
            +
                    align-items: center;
         | 
| 735 | 
            +
                    justify-content: center;
         | 
| 736 | 
            +
                    font-weight: 500;
         | 
| 737 | 
            +
                    border-right: 1px solid var(--border-color);
         | 
| 738 | 
            +
                    background-color: var(--light-gray);
         | 
| 739 | 
            +
                    white-space: nowrap;
         | 
| 740 | 
            +
                }
         | 
| 741 | 
            +
                
         | 
| 742 | 
            +
                .speaker-1 {
         | 
| 743 | 
            +
                    color: #3b82f6;
         | 
| 744 | 
            +
                }
         | 
| 745 | 
            +
                
         | 
| 746 | 
            +
                .speaker-2 {
         | 
| 747 | 
            +
                    color: #ef4444;
         | 
| 748 | 
            +
                }
         | 
| 749 | 
            +
                
         | 
| 750 | 
            +
                .line-input {
         | 
| 751 | 
            +
                    flex: 1;
         | 
| 752 | 
            +
                    padding: 12px;
         | 
| 753 | 
            +
                    border: none;
         | 
| 754 | 
            +
                    outline: none;
         | 
| 755 | 
            +
                    font-size: 1em;
         | 
| 756 | 
            +
                }
         | 
| 757 | 
            +
                
         | 
| 758 | 
            +
                .line-input:focus {
         | 
| 759 | 
            +
                    background-color: rgba(80, 70, 229, 0.03);
         | 
| 760 | 
            +
                }
         | 
| 761 | 
            +
                
         | 
| 762 | 
            +
                .remove-line-btn {
         | 
| 763 | 
            +
                    width: 40px;
         | 
| 764 | 
            +
                    display: flex;
         | 
| 765 | 
            +
                    align-items: center;
         | 
| 766 | 
            +
                    justify-content: center;
         | 
| 767 | 
            +
                    background: none;
         | 
| 768 | 
            +
                    border: none;
         | 
| 769 | 
            +
                    border-left: 1px solid var(--border-color);
         | 
| 770 | 
            +
                    cursor: pointer;
         | 
| 771 | 
            +
                    color: #888;
         | 
| 772 | 
            +
                    transition: color 0.2s, background-color 0.2s;
         | 
| 773 | 
            +
                }
         | 
| 774 | 
            +
                
         | 
| 775 | 
            +
                .remove-line-btn:hover {
         | 
| 776 | 
            +
                    color: #ef4444;
         | 
| 777 | 
            +
                    background-color: rgba(239, 68, 68, 0.1);
         | 
| 778 | 
            +
                }
         | 
| 779 | 
            +
                
         | 
| 780 | 
            +
                .add-line-btn {
         | 
| 781 | 
            +
                    width: 100%;
         | 
| 782 | 
            +
                    padding: 12px;
         | 
| 783 | 
            +
                    border: none;
         | 
| 784 | 
            +
                    background-color: var(--light-gray);
         | 
| 785 | 
            +
                    cursor: pointer;
         | 
| 786 | 
            +
                    font-weight: 500;
         | 
| 787 | 
            +
                    transition: background-color 0.2s;
         | 
| 788 | 
            +
                    margin-bottom: 0;
         | 
| 789 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 790 | 
            +
                }
         | 
| 791 | 
            +
                
         | 
| 792 | 
            +
                .add-line-btn:hover {
         | 
| 793 | 
            +
                    background-color: rgba(80, 70, 229, 0.1);
         | 
| 794 | 
            +
                }
         | 
| 795 | 
            +
                
         | 
| 796 | 
            +
                .podcast-keyboard-hint {
         | 
| 797 | 
            +
                    padding: 10px;
         | 
| 798 | 
            +
                    text-align: center;
         | 
| 799 | 
            +
                    background-color: var(--light-gray);
         | 
| 800 | 
            +
                    border-top: 1px solid var(--border-color);
         | 
| 801 | 
            +
                    margin-top: 0;
         | 
| 802 | 
            +
                    font-size: 13px;
         | 
| 803 | 
            +
                }
         | 
| 804 | 
            +
                
         | 
| 805 | 
            +
                .podcast-player {
         | 
| 806 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 807 | 
            +
                    border-radius: var(--radius);
         | 
| 808 | 
            +
                    padding: 20px;
         | 
| 809 | 
            +
                    margin-bottom: 24px;
         | 
| 810 | 
            +
                }
         | 
| 811 | 
            +
                
         | 
| 812 | 
            +
                .podcast-wave-player {
         | 
| 813 | 
            +
                    margin: 20px 0;
         | 
| 814 | 
            +
                }
         | 
| 815 | 
            +
                
         | 
| 816 | 
            +
                .podcast-transcript-container {
         | 
| 817 | 
            +
                    margin-top: 20px;
         | 
| 818 | 
            +
                    padding-top: 20px;
         | 
| 819 | 
            +
                    border-top: 1px solid var(--border-color);
         | 
| 820 | 
            +
                }
         | 
| 821 | 
            +
                
         | 
| 822 | 
            +
                .podcast-transcript {
         | 
| 823 | 
            +
                    margin-top: 12px;
         | 
| 824 | 
            +
                    line-height: 1.6;
         | 
| 825 | 
            +
                }
         | 
| 826 | 
            +
                
         | 
| 827 | 
            +
                .transcript-line {
         | 
| 828 | 
            +
                    margin-bottom: 12px;
         | 
| 829 | 
            +
                }
         | 
| 830 | 
            +
                
         | 
| 831 | 
            +
                .transcript-speaker {
         | 
| 832 | 
            +
                    font-weight: 600;
         | 
| 833 | 
            +
                    margin-right: 8px;
         | 
| 834 | 
            +
                }
         | 
| 835 | 
            +
                
         | 
| 836 | 
            +
                .transcript-speaker.speaker-1 {
         | 
| 837 | 
            +
                    color: #3b82f6;
         | 
| 838 | 
            +
                }
         | 
| 839 | 
            +
                
         | 
| 840 | 
            +
                .transcript-speaker.speaker-2 {
         | 
| 841 | 
            +
                    color: #ef4444;
         | 
| 842 | 
            +
                }
         | 
| 843 | 
            +
                
         | 
| 844 | 
            +
                /* Responsive styles for podcast UI */
         | 
| 845 | 
            +
                @media (max-width: 768px) {
         | 
| 846 | 
            +
                    .podcast-controls {
         | 
| 847 | 
            +
                        flex-direction: column;
         | 
| 848 | 
            +
                    }
         | 
| 849 | 
            +
                    
         | 
| 850 | 
            +
                    .random-script-btn,
         | 
| 851 | 
            +
                    .podcast-synth-btn {
         | 
| 852 | 
            +
                        width: 100%;
         | 
| 853 | 
            +
                        height: 48px;
         | 
| 854 | 
            +
                    }
         | 
| 855 | 
            +
                    
         | 
| 856 | 
            +
                    /* Stack podcast players vertically on mobile */
         | 
| 857 | 
            +
                    .podcast-player-container .players-row {
         | 
| 858 | 
            +
                        flex-direction: column;
         | 
| 859 | 
            +
                        gap: 16px;
         | 
| 860 | 
            +
                    }
         | 
| 861 | 
            +
                    
         | 
| 862 | 
            +
                    .podcast-line {
         | 
| 863 | 
            +
                        flex-direction: column;
         | 
| 864 | 
            +
                        padding-bottom: 0;
         | 
| 865 | 
            +
                        margin-bottom: 0;
         | 
| 866 | 
            +
                    }
         | 
| 867 | 
            +
                    
         | 
| 868 | 
            +
                    .speaker-label {
         | 
| 869 | 
            +
                        width: 100%;
         | 
| 870 | 
            +
                        border-right: none;
         | 
| 871 | 
            +
                        border-bottom: 1px solid var(--border-color);
         | 
| 872 | 
            +
                        padding: 8px 10px;
         | 
| 873 | 
            +
                        justify-content: flex-start;
         | 
| 874 | 
            +
                    }
         | 
| 875 | 
            +
                    
         | 
| 876 | 
            +
                    .line-input {
         | 
| 877 | 
            +
                        width: 100%;
         | 
| 878 | 
            +
                        padding: 8px 10px;
         | 
| 879 | 
            +
                    }
         | 
| 880 | 
            +
                    
         | 
| 881 | 
            +
                    .remove-line-btn {
         | 
| 882 | 
            +
                        position: absolute;
         | 
| 883 | 
            +
                        top: 6px;
         | 
| 884 | 
            +
                        right: 10px;
         | 
| 885 | 
            +
                        border-left: none;
         | 
| 886 | 
            +
                        background-color: rgba(255, 255, 255, 0.5);
         | 
| 887 | 
            +
                        border-radius: 4px;
         | 
| 888 | 
            +
                        width: 30px;
         | 
| 889 | 
            +
                        height: 30px;
         | 
| 890 | 
            +
                    }
         | 
| 891 | 
            +
                    
         | 
| 892 | 
            +
                    .podcast-line {
         | 
| 893 | 
            +
                        position: relative;
         | 
| 894 | 
            +
                    }
         | 
| 895 | 
            +
                    
         | 
| 896 | 
            +
                    /* Dark mode adjustments for mobile */
         | 
| 897 | 
            +
                    @media (prefers-color-scheme: dark) {
         | 
| 898 | 
            +
                        .remove-line-btn {
         | 
| 899 | 
            +
                            background-color: rgba(50, 50, 60, 0.7);
         | 
| 900 | 
            +
                        }
         | 
| 901 | 
            +
                    }
         | 
| 902 | 
            +
                }
         | 
| 903 | 
            +
                
         | 
| 904 | 
            +
                /* Dark mode styles for podcast UI */
         | 
| 905 | 
            +
                @media (prefers-color-scheme: dark) {
         | 
| 906 | 
            +
                    .random-script-btn {
         | 
| 907 | 
            +
                        background-color: var(--light-gray);
         | 
| 908 | 
            +
                        color: var(--text-color);
         | 
| 909 | 
            +
                        border-color: var(--border-color);
         | 
| 910 | 
            +
                    }
         | 
| 911 | 
            +
             | 
| 912 | 
            +
                    .add-line-btn {
         | 
| 913 | 
            +
                        background-color: var(--light-gray);
         | 
| 914 | 
            +
                        color: var(--text-color);
         | 
| 915 | 
            +
                        border-color: var(--border-color);
         | 
| 916 | 
            +
                    }
         | 
| 917 | 
            +
                    
         | 
| 918 | 
            +
                    .random-script-btn:hover {
         | 
| 919 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 920 | 
            +
                    }
         | 
| 921 | 
            +
                    
         | 
| 922 | 
            +
                    .line-input {
         | 
| 923 | 
            +
                        background-color: var(--light-gray);
         | 
| 924 | 
            +
                        color: var(--text-color);
         | 
| 925 | 
            +
                    }
         | 
| 926 | 
            +
                    
         | 
| 927 | 
            +
                    .line-input:focus {
         | 
| 928 | 
            +
                        background-color: rgba(108, 99, 255, 0.1);
         | 
| 929 | 
            +
                    }
         | 
| 930 | 
            +
                }
         | 
| 931 | 
            +
             | 
| 932 | 
            +
                .podcast-loading-container {
         | 
| 933 | 
            +
                    display: flex;
         | 
| 934 | 
            +
                    justify-content: center;
         | 
| 935 | 
            +
                    align-items: center;
         | 
| 936 | 
            +
                    position: fixed;
         | 
| 937 | 
            +
                    top: 0;
         | 
| 938 | 
            +
                    left: 0;
         | 
| 939 | 
            +
                    width: 100%;
         | 
| 940 | 
            +
                    height: 100vh;
         | 
| 941 | 
            +
                    background-color: rgba(255, 255, 255, 0.9);
         | 
| 942 | 
            +
                    z-index: 1000;
         | 
| 943 | 
            +
                }
         | 
| 944 | 
            +
                
         | 
| 945 | 
            +
                @media (prefers-color-scheme: dark) {
         | 
| 946 | 
            +
                    .podcast-loading-container {
         | 
| 947 | 
            +
                        background-color: rgba(18, 18, 24, 0.9);
         | 
| 948 | 
            +
                    }
         | 
| 949 | 
            +
                }
         | 
| 950 | 
            +
             | 
| 951 | 
            +
                .podcast-vote-results {
         | 
| 952 | 
            +
                    background-color: #f0f4ff;
         | 
| 953 | 
            +
                    border: 1px solid #d0d7f7;
         | 
| 954 | 
            +
                    border-radius: var(--radius);
         | 
| 955 | 
            +
                    padding: 16px;
         | 
| 956 | 
            +
                    margin: 24px 0;
         | 
| 957 | 
            +
                }
         | 
| 958 | 
            +
                
         | 
| 959 | 
            +
                .podcast-next-round-container {
         | 
| 960 | 
            +
                    margin-top: 24px;
         | 
| 961 | 
            +
                    text-align: center;
         | 
| 962 | 
            +
                }
         | 
| 963 | 
            +
                
         | 
| 964 | 
            +
                .podcast-next-round-btn {
         | 
| 965 | 
            +
                    padding: 12px 24px;
         | 
| 966 | 
            +
                    background-color: var(--primary-color);
         | 
| 967 | 
            +
                    color: white;
         | 
| 968 | 
            +
                    border: none;
         | 
| 969 | 
            +
                    border-radius: var(--radius);
         | 
| 970 | 
            +
                    font-weight: 500;
         | 
| 971 | 
            +
                    cursor: pointer;
         | 
| 972 | 
            +
                    position: relative;
         | 
| 973 | 
            +
                    width: 100%;
         | 
| 974 | 
            +
                    font-size: 1rem;
         | 
| 975 | 
            +
                    transition: background-color 0.2s;
         | 
| 976 | 
            +
                }
         | 
| 977 | 
            +
                
         | 
| 978 | 
            +
                .podcast-next-round-btn:hover {
         | 
| 979 | 
            +
                    background-color: #4038c7;
         | 
| 980 | 
            +
                }
         | 
| 981 | 
            +
                
         | 
| 982 | 
            +
                /* Dark mode adjustments */
         | 
| 983 | 
            +
                @media (prefers-color-scheme: dark) {
         | 
| 984 | 
            +
                    .podcast-vote-results {
         | 
| 985 | 
            +
                        background-color: var(--light-gray);
         | 
| 986 | 
            +
                        border-color: var(--border-color);
         | 
| 987 | 
            +
                    }
         | 
| 988 | 
            +
                }
         | 
| 989 | 
            +
            </style>
         | 
| 990 | 
            +
            {% endblock %} 
         | 
| 991 | 
            +
             | 
| 992 | 
            +
            {% block extra_scripts %}
         | 
| 993 | 
            +
            <script src="{{ url_for('static', filename='js/waveplayer.js') }}"></script>
         | 
| 994 | 
            +
            <script>
         | 
| 995 | 
            +
                document.addEventListener('DOMContentLoaded', function() {
         | 
| 996 | 
            +
                    const synthForm = document.querySelector('.input-container');
         | 
| 997 | 
            +
                    const synthBtn = document.querySelector('.synth-btn');
         | 
| 998 | 
            +
                    const mobileSynthBtn = document.querySelector('.mobile-synth-btn');
         | 
| 999 | 
            +
                    const loadingContainer = document.querySelector('.loading-container');
         | 
| 1000 | 
            +
                    const playersContainer = document.querySelector('.players-container');
         | 
| 1001 | 
            +
                    const voteButtons = document.querySelectorAll('.vote-btn');
         | 
| 1002 | 
            +
                    const textInput = document.querySelector('.text-input');
         | 
| 1003 | 
            +
                    const nextRoundBtn = document.querySelector('.next-round-btn');
         | 
| 1004 | 
            +
                    const nextRoundContainer = document.querySelector('.next-round-container');
         | 
| 1005 | 
            +
                    const randomBtn = document.querySelector('.random-btn');
         | 
| 1006 | 
            +
                    const tabs = document.querySelectorAll('.tab');
         | 
| 1007 | 
            +
                    const tabContents = document.querySelectorAll('.tab-content');
         | 
| 1008 | 
            +
                    const voteResultsContainer = document.querySelector('.vote-results');
         | 
| 1009 | 
            +
                    const chosenModelNameElement = document.querySelector('.chosen-model-name');
         | 
| 1010 | 
            +
                    const rejectedModelNameElement = document.querySelector('.rejected-model-name');
         | 
| 1011 | 
            +
                    const modelNameDisplays = document.querySelectorAll('.model-name-display');
         | 
| 1012 | 
            +
                    const wavePlayerContainers = document.querySelectorAll('.wave-player-container');
         | 
| 1013 | 
            +
                    
         | 
| 1014 | 
            +
                    let bothSamplesPlayed = false;
         | 
| 1015 | 
            +
                    let currentSessionId = null;
         | 
| 1016 | 
            +
                    let modelNames = { a: '', b: '' };
         | 
| 1017 | 
            +
                    let wavePlayers = { a: null, b: null };
         | 
| 1018 | 
            +
                    
         | 
| 1019 | 
            +
                    // Initialize WavePlayers with mobile settings
         | 
| 1020 | 
            +
                    wavePlayerContainers.forEach(container => {
         | 
| 1021 | 
            +
                        const model = container.dataset.model;
         | 
| 1022 | 
            +
                        wavePlayers[model] = new WavePlayer(container, {
         | 
| 1023 | 
            +
                            // Add mobile-friendly options but hide native controls
         | 
| 1024 | 
            +
                            backend: 'MediaElement',
         | 
| 1025 | 
            +
                            mediaControls: false // Hide native audio controls
         | 
| 1026 | 
            +
                        });
         | 
| 1027 | 
            +
                    });
         | 
| 1028 | 
            +
                    
         | 
| 1029 | 
            +
                    // Random text options
         | 
| 1030 | 
            +
                    const randomTexts = [
         | 
| 1031 | 
            +
                        "The quick brown fox jumps over the lazy dog.",
         | 
| 1032 | 
            +
                        "To be or not to be, that is the question.",
         | 
| 1033 | 
            +
                        "Life is like a box of chocolates, you never know what you're going to get.",
         | 
| 1034 | 
            +
                        "In a world where technology and humanity intertwine, the voice is our most natural interface.",
         | 
| 1035 | 
            +
                        "Artificial intelligence will transform how we interact with computers and each other.",
         | 
| 1036 | 
            +
                        "The sunset painted the sky with hues of orange and purple.",
         | 
| 1037 | 
            +
                        "I can't believe it's not butter!",
         | 
| 1038 | 
            +
                        "Four score and seven years ago, our forefathers brought forth upon this continent a new nation.",
         | 
| 1039 | 
            +
                        "Houston, we have a problem.",
         | 
| 1040 | 
            +
                        "May the force be with you.",
         | 
| 1041 | 
            +
                        "The early bird catches the worm, but the second mouse gets the cheese.",
         | 
| 1042 | 
            +
                        "All that glitters is not gold; all who wander are not lost.",
         | 
| 1043 | 
            +
                        "Be yourself; everyone else is already taken.",
         | 
| 1044 | 
            +
                        "Two things are infinite: the universe and human stupidity; and I'm not sure about the universe.",
         | 
| 1045 | 
            +
                        "So many books, so little time.",
         | 
| 1046 | 
            +
                        "You only live once, but if you do it right, once is enough.",
         | 
| 1047 | 
            +
                        "In three words I can sum up everything I've learned about life: it goes on.",
         | 
| 1048 | 
            +
                        "The future belongs to those who believe in the beauty of their dreams.",
         | 
| 1049 | 
            +
                        "Yesterday is history, tomorrow is a mystery, but today is a gift. That's why it's called the present.",
         | 
| 1050 | 
            +
                        "The only way to do great work is to love what you do.",
         | 
| 1051 | 
            +
                        "Life is what happens when you're busy making other plans.",
         | 
| 1052 | 
            +
                        "The greatest glory in living lies not in never falling, but in rising every time we fall.",
         | 
| 1053 | 
            +
                        "The way to get started is to quit talking and begin doing.",
         | 
| 1054 | 
            +
                        "If life were predictable it would cease to be life, and be without flavor.",
         | 
| 1055 | 
            +
                        "If you set your goals ridiculously high and it's a failure, you will fail above everyone else's success.",
         | 
| 1056 | 
            +
                        "Life is either a daring adventure or nothing at all.",
         | 
| 1057 | 
            +
                        "Many of life's failures are people who did not realize how close they were to success when they gave up.",
         | 
| 1058 | 
            +
                        "You have brains in your head. You have feet in your shoes. You can steer yourself any direction you choose.",
         | 
| 1059 | 
            +
                        "It is during our darkest moments that we must focus to see the light.",
         | 
| 1060 | 
            +
                        "Don't judge each day by the harvest you reap but by the seeds that you plant.",
         | 
| 1061 | 
            +
                        "The future belongs to those who believe in the beauty of their dreams.",
         | 
| 1062 | 
            +
                        "Tell me and I forget. Teach me and I remember. Involve me and I learn.",
         | 
| 1063 | 
            +
                        "The best and most beautiful things in the world cannot be seen or even touched — they must be felt with the heart.",
         | 
| 1064 | 
            +
                        "It is better to fail in originality than to succeed in imitation.",
         | 
| 1065 | 
            +
                        "Darkness cannot drive out darkness: only light can do that. Hate cannot drive out hate: only love can do that.",
         | 
| 1066 | 
            +
                        "Do not go where the path may lead, go instead where there is no path and leave a trail.",
         | 
| 1067 | 
            +
                        "You will face many defeats in life, but never let yourself be defeated.",
         | 
| 1068 | 
            +
                        "In the end, it's not the years in your life that count. It's the life in your years.",
         | 
| 1069 | 
            +
                        "Never let the fear of striking out keep you from playing the game.",
         | 
| 1070 | 
            +
                        "Life is never fair, and perhaps it is a good thing for most of us that it is not.",
         | 
| 1071 | 
            +
                    ];
         | 
| 1072 | 
            +
                    
         | 
| 1073 | 
            +
                    // Check URL hash for direct tab access
         | 
| 1074 | 
            +
                    function checkHashAndSetTab() {
         | 
| 1075 | 
            +
                        const hash = window.location.hash.toLowerCase();
         | 
| 1076 | 
            +
                        if (hash === '#conversational') {
         | 
| 1077 | 
            +
                            // Switch to conversational tab
         | 
| 1078 | 
            +
                            tabs.forEach(t => t.classList.remove('active'));
         | 
| 1079 | 
            +
                            tabContents.forEach(c => c.classList.remove('active'));
         | 
| 1080 | 
            +
                            
         | 
| 1081 | 
            +
                            document.querySelector('.tab[data-tab="conversational"]').classList.add('active');
         | 
| 1082 | 
            +
                            document.getElementById('conversational-tab').classList.add('active');
         | 
| 1083 | 
            +
                        } else if (hash === '#tts') {
         | 
| 1084 | 
            +
                            // Switch to TTS tab (explicit)
         | 
| 1085 | 
            +
                            tabs.forEach(t => t.classList.remove('active'));
         | 
| 1086 | 
            +
                            tabContents.forEach(c => c.classList.remove('active'));
         | 
| 1087 | 
            +
                            
         | 
| 1088 | 
            +
                            document.querySelector('.tab[data-tab="tts"]').classList.add('active');
         | 
| 1089 | 
            +
                            document.getElementById('tts-tab').classList.add('active');
         | 
| 1090 | 
            +
                        }
         | 
| 1091 | 
            +
                    }
         | 
| 1092 | 
            +
                    
         | 
| 1093 | 
            +
                    // Check hash on page load
         | 
| 1094 | 
            +
                    checkHashAndSetTab();
         | 
| 1095 | 
            +
                    
         | 
| 1096 | 
            +
                    // Listen for hash changes
         | 
| 1097 | 
            +
                    window.addEventListener('hashchange', checkHashAndSetTab);
         | 
| 1098 | 
            +
                    
         | 
| 1099 | 
            +
                    // Tab switching functionality
         | 
| 1100 | 
            +
                    tabs.forEach(tab => {
         | 
| 1101 | 
            +
                        tab.addEventListener('click', function() {
         | 
| 1102 | 
            +
                            const tabId = this.dataset.tab;
         | 
| 1103 | 
            +
                            
         | 
| 1104 | 
            +
                            // Update URL hash without page reload
         | 
| 1105 | 
            +
                            history.replaceState(null, null, `#${tabId}`);
         | 
| 1106 | 
            +
                            
         | 
| 1107 | 
            +
                            // Remove active class from all tabs and contents
         | 
| 1108 | 
            +
                            tabs.forEach(t => t.classList.remove('active'));
         | 
| 1109 | 
            +
                            tabContents.forEach(c => c.classList.remove('active'));
         | 
| 1110 | 
            +
                            
         | 
| 1111 | 
            +
                            // Add active class to clicked tab and corresponding content
         | 
| 1112 | 
            +
                            this.classList.add('active');
         | 
| 1113 | 
            +
                            document.getElementById(`${tabId}-tab`).classList.add('active');
         | 
| 1114 | 
            +
                            
         | 
| 1115 | 
            +
                            // Reset TTS tab state if switching away from it
         | 
| 1116 | 
            +
                            if (tabId !== 'tts') {
         | 
| 1117 | 
            +
                                resetToInitialState();
         | 
| 1118 | 
            +
                            }
         | 
| 1119 | 
            +
                        });
         | 
| 1120 | 
            +
                    });
         | 
| 1121 | 
            +
                    
         | 
| 1122 | 
            +
                    function handleSynthesize(e) {
         | 
| 1123 | 
            +
                        if (e) {
         | 
| 1124 | 
            +
                            e.preventDefault();
         | 
| 1125 | 
            +
                        }
         | 
| 1126 | 
            +
             | 
| 1127 | 
            +
                        const text = textInput.value.trim();
         | 
| 1128 | 
            +
                        if (!text) {
         | 
| 1129 | 
            +
                            openToast("Please enter some text to synthesize", "warning");
         | 
| 1130 | 
            +
                            return;
         | 
| 1131 | 
            +
                        }
         | 
| 1132 | 
            +
                        
         | 
| 1133 | 
            +
                        if (text.length > 1000) {
         | 
| 1134 | 
            +
                            openToast("Text is too long. Please keep it under 1000 characters.", "warning");
         | 
| 1135 | 
            +
                            return;
         | 
| 1136 | 
            +
                        }
         | 
| 1137 | 
            +
             | 
| 1138 | 
            +
                        textInput.blur();
         | 
| 1139 | 
            +
                        
         | 
| 1140 | 
            +
                        // Show loading animation
         | 
| 1141 | 
            +
                        loadingContainer.style.display = 'flex';
         | 
| 1142 | 
            +
                        playersContainer.style.display = 'none';
         | 
| 1143 | 
            +
                        voteResultsContainer.style.display = 'none';
         | 
| 1144 | 
            +
                        nextRoundContainer.style.display = 'none';
         | 
| 1145 | 
            +
                        
         | 
| 1146 | 
            +
                        // Reset vote buttons
         | 
| 1147 | 
            +
                        voteButtons.forEach(btn => {
         | 
| 1148 | 
            +
                            btn.disabled = true;
         | 
| 1149 | 
            +
                            btn.classList.remove('selected');
         | 
| 1150 | 
            +
                            btn.querySelector('.vote-loader').style.display = 'none';
         | 
| 1151 | 
            +
                        });
         | 
| 1152 | 
            +
                        
         | 
| 1153 | 
            +
                        // Clear model name displays
         | 
| 1154 | 
            +
                        modelNameDisplays.forEach(display => {
         | 
| 1155 | 
            +
                            display.textContent = '';
         | 
| 1156 | 
            +
                        });
         | 
| 1157 | 
            +
                        
         | 
| 1158 | 
            +
                        // Reset the flag for both samples played
         | 
| 1159 | 
            +
                        bothSamplesPlayed = false;
         | 
| 1160 | 
            +
                        
         | 
| 1161 | 
            +
                        // Call the API to generate TTS
         | 
| 1162 | 
            +
                        fetch('/api/tts/generate', {
         | 
| 1163 | 
            +
                            method: 'POST',
         | 
| 1164 | 
            +
                            headers: {
         | 
| 1165 | 
            +
                                'Content-Type': 'application/json',
         | 
| 1166 | 
            +
                            },
         | 
| 1167 | 
            +
                            body: JSON.stringify({ text: text }),
         | 
| 1168 | 
            +
                        })
         | 
| 1169 | 
            +
                        .then(response => {
         | 
| 1170 | 
            +
                            if (!response.ok) {
         | 
| 1171 | 
            +
                                return response.json().then(err => {
         | 
| 1172 | 
            +
                                    throw new Error(err.error || 'Failed to generate TTS');
         | 
| 1173 | 
            +
                                });
         | 
| 1174 | 
            +
                            }
         | 
| 1175 | 
            +
                            return response.json();
         | 
| 1176 | 
            +
                        })
         | 
| 1177 | 
            +
                        .then(data => {
         | 
| 1178 | 
            +
                            currentSessionId = data.session_id;
         | 
| 1179 | 
            +
                            
         | 
| 1180 | 
            +
                            // Load audio in waveplayers
         | 
| 1181 | 
            +
                            wavePlayers.a.loadAudio(data.audio_a);
         | 
| 1182 | 
            +
                            wavePlayers.b.loadAudio(data.audio_b);
         | 
| 1183 | 
            +
                            
         | 
| 1184 | 
            +
                            // Show players
         | 
| 1185 | 
            +
                            loadingContainer.style.display = 'none';
         | 
| 1186 | 
            +
                            playersContainer.style.display = 'flex';
         | 
| 1187 | 
            +
                            
         | 
| 1188 | 
            +
                            // Setup automatic sequential playback
         | 
| 1189 | 
            +
                            wavePlayers.a.wavesurfer.once('ready', function() {
         | 
| 1190 | 
            +
                                wavePlayers.a.play();
         | 
| 1191 | 
            +
                                
         | 
| 1192 | 
            +
                                // When audio A ends, play audio B
         | 
| 1193 | 
            +
                                wavePlayers.a.wavesurfer.once('finish', function() {
         | 
| 1194 | 
            +
                                    // Wait a short moment before playing B
         | 
| 1195 | 
            +
                                    setTimeout(() => {
         | 
| 1196 | 
            +
                                        wavePlayers.b.play();
         | 
| 1197 | 
            +
                                        
         | 
| 1198 | 
            +
                                        // When audio B ends, enable voting
         | 
| 1199 | 
            +
                                        wavePlayers.b.wavesurfer.once('finish', function() {
         | 
| 1200 | 
            +
                                            bothSamplesPlayed = true;
         | 
| 1201 | 
            +
                                            voteButtons.forEach(btn => {
         | 
| 1202 | 
            +
                                                btn.disabled = false;
         | 
| 1203 | 
            +
                                            });
         | 
| 1204 | 
            +
                                        });
         | 
| 1205 | 
            +
                                    }, 500);
         | 
| 1206 | 
            +
                                });
         | 
| 1207 | 
            +
                            });
         | 
| 1208 | 
            +
                        })
         | 
| 1209 | 
            +
                        .catch(error => {
         | 
| 1210 | 
            +
                            loadingContainer.style.display = 'none';
         | 
| 1211 | 
            +
                            openToast(error.message, "error");
         | 
| 1212 | 
            +
                            console.error('Error:', error);
         | 
| 1213 | 
            +
                        });
         | 
| 1214 | 
            +
                    }
         | 
| 1215 | 
            +
                    
         | 
| 1216 | 
            +
                    function handleVote(model) {
         | 
| 1217 | 
            +
                        // Disable both vote buttons
         | 
| 1218 | 
            +
                        voteButtons.forEach(btn => {
         | 
| 1219 | 
            +
                            btn.disabled = true;
         | 
| 1220 | 
            +
                            if (btn.dataset.model === model) {
         | 
| 1221 | 
            +
                                btn.querySelector('.vote-loader').style.display = 'flex';
         | 
| 1222 | 
            +
                            }
         | 
| 1223 | 
            +
                        });
         | 
| 1224 | 
            +
                        
         | 
| 1225 | 
            +
                        // Send vote to server
         | 
| 1226 | 
            +
                        fetch('/api/tts/vote', {
         | 
| 1227 | 
            +
                            method: 'POST',
         | 
| 1228 | 
            +
                            headers: {
         | 
| 1229 | 
            +
                                'Content-Type': 'application/json',
         | 
| 1230 | 
            +
                            },
         | 
| 1231 | 
            +
                            body: JSON.stringify({
         | 
| 1232 | 
            +
                                session_id: currentSessionId,
         | 
| 1233 | 
            +
                                chosen_model: model
         | 
| 1234 | 
            +
                            }),
         | 
| 1235 | 
            +
                        })
         | 
| 1236 | 
            +
                        .then(response => {
         | 
| 1237 | 
            +
                            if (!response.ok) {
         | 
| 1238 | 
            +
                                return response.json().then(err => {
         | 
| 1239 | 
            +
                                    throw new Error(err.error || 'Failed to submit vote');
         | 
| 1240 | 
            +
                                });
         | 
| 1241 | 
            +
                            }
         | 
| 1242 | 
            +
                            return response.json();
         | 
| 1243 | 
            +
                        })
         | 
| 1244 | 
            +
                        .then(data => {
         | 
| 1245 | 
            +
                            // Hide loaders
         | 
| 1246 | 
            +
                            voteButtons.forEach(btn => {
         | 
| 1247 | 
            +
                                btn.querySelector('.vote-loader').style.display = 'none';
         | 
| 1248 | 
            +
                                
         | 
| 1249 | 
            +
                                // Highlight the selected button
         | 
| 1250 | 
            +
                                if (btn.dataset.model === model) {
         | 
| 1251 | 
            +
                                    btn.classList.add('selected');
         | 
| 1252 | 
            +
                                }
         | 
| 1253 | 
            +
                            });
         | 
| 1254 | 
            +
             | 
| 1255 | 
            +
             | 
| 1256 | 
            +
                            // Store model names from vote response
         | 
| 1257 | 
            +
                            if (data.chosen_model && data.chosen_model.name) {
         | 
| 1258 | 
            +
                                modelNames.a = data.names.a;
         | 
| 1259 | 
            +
                                modelNames.b = data.names.b;
         | 
| 1260 | 
            +
                            }
         | 
| 1261 | 
            +
                            
         | 
| 1262 | 
            +
                            // Now display model names after voting
         | 
| 1263 | 
            +
                            modelNameDisplays[0].textContent = modelNames.a ? `(${modelNames.a})` : '';
         | 
| 1264 | 
            +
                            modelNameDisplays[1].textContent = modelNames.b ? `(${modelNames.b})` : '';
         | 
| 1265 | 
            +
                            
         | 
| 1266 | 
            +
                            // Show vote results
         | 
| 1267 | 
            +
                            chosenModelNameElement.textContent = data.chosen_model.name;
         | 
| 1268 | 
            +
                            rejectedModelNameElement.textContent = data.rejected_model.name;
         | 
| 1269 | 
            +
                            voteResultsContainer.style.display = 'block';
         | 
| 1270 | 
            +
                            
         | 
| 1271 | 
            +
                            // Show next round button
         | 
| 1272 | 
            +
                            nextRoundContainer.style.display = 'block';
         | 
| 1273 | 
            +
                            
         | 
| 1274 | 
            +
                            // Show success toast
         | 
| 1275 | 
            +
                            openToast("Vote recorded successfully!", "success");
         | 
| 1276 | 
            +
                        })
         | 
| 1277 | 
            +
                        .catch(error => {
         | 
| 1278 | 
            +
                            // Re-enable vote buttons
         | 
| 1279 | 
            +
                            voteButtons.forEach(btn => {
         | 
| 1280 | 
            +
                                btn.disabled = false;
         | 
| 1281 | 
            +
                                btn.querySelector('.vote-loader').style.display = 'none';
         | 
| 1282 | 
            +
                            });
         | 
| 1283 | 
            +
                            
         | 
| 1284 | 
            +
                            openToast(error.message, "error");
         | 
| 1285 | 
            +
                            console.error('Error:', error);
         | 
| 1286 | 
            +
                        });
         | 
| 1287 | 
            +
                    }
         | 
| 1288 | 
            +
                    
         | 
| 1289 | 
            +
                    function resetToInitialState() {
         | 
| 1290 | 
            +
                        // Hide players, results, and next round button
         | 
| 1291 | 
            +
                        playersContainer.style.display = 'none';
         | 
| 1292 | 
            +
                        voteResultsContainer.style.display = 'none';
         | 
| 1293 | 
            +
                        nextRoundContainer.style.display = 'none';
         | 
| 1294 | 
            +
                        
         | 
| 1295 | 
            +
                        // Reset vote buttons
         | 
| 1296 | 
            +
                        voteButtons.forEach(btn => {
         | 
| 1297 | 
            +
                            btn.disabled = true;
         | 
| 1298 | 
            +
                            btn.classList.remove('selected');
         | 
| 1299 | 
            +
                            btn.querySelector('.vote-loader').style.display = 'none';
         | 
| 1300 | 
            +
                        });
         | 
| 1301 | 
            +
                        
         | 
| 1302 | 
            +
                        // Clear model name displays
         | 
| 1303 | 
            +
                        modelNameDisplays.forEach(display => {
         | 
| 1304 | 
            +
                            display.textContent = '';
         | 
| 1305 | 
            +
                        });
         | 
| 1306 | 
            +
                        
         | 
| 1307 | 
            +
                        // Reset model names
         | 
| 1308 | 
            +
                        modelNames = { a: '', b: '' };
         | 
| 1309 | 
            +
                        
         | 
| 1310 | 
            +
                        // Clear text input
         | 
| 1311 | 
            +
                        textInput.value = '';
         | 
| 1312 | 
            +
                        
         | 
| 1313 | 
            +
                        // Stop any playing audio and destroy wavesurfers
         | 
| 1314 | 
            +
                        for (const model in wavePlayers) {
         | 
| 1315 | 
            +
                            if (wavePlayers[model]) {
         | 
| 1316 | 
            +
                                wavePlayers[model].stop();
         | 
| 1317 | 
            +
                            }
         | 
| 1318 | 
            +
                        }
         | 
| 1319 | 
            +
                        
         | 
| 1320 | 
            +
                        // Reset session
         | 
| 1321 | 
            +
                        currentSessionId = null;
         | 
| 1322 | 
            +
                        
         | 
| 1323 | 
            +
                        // Reset the flag for both samples played
         | 
| 1324 | 
            +
                        bothSamplesPlayed = false;
         | 
| 1325 | 
            +
                    }
         | 
| 1326 | 
            +
                    
         | 
| 1327 | 
            +
                    function handleRandom() {
         | 
| 1328 | 
            +
                        // Select a random text from the array
         | 
| 1329 | 
            +
                        const randomText = randomTexts[Math.floor(Math.random() * randomTexts.length)];
         | 
| 1330 | 
            +
                        textInput.value = randomText;
         | 
| 1331 | 
            +
                        textInput.focus();
         | 
| 1332 | 
            +
                    }
         | 
| 1333 | 
            +
                    
         | 
| 1334 | 
            +
                    function showListenToastMessage() {
         | 
| 1335 | 
            +
                        openToast("Please listen to both audio samples before voting", "info");
         | 
| 1336 | 
            +
                    }
         | 
| 1337 | 
            +
                    
         | 
| 1338 | 
            +
                    // Add submit event listener to form
         | 
| 1339 | 
            +
                    synthForm.addEventListener('submit', handleSynthesize);
         | 
| 1340 | 
            +
                    
         | 
| 1341 | 
            +
                    // Add click event listeners to vote buttons
         | 
| 1342 | 
            +
                    voteButtons.forEach(btn => {
         | 
| 1343 | 
            +
                        btn.addEventListener('click', function() {
         | 
| 1344 | 
            +
                            if (bothSamplesPlayed) {
         | 
| 1345 | 
            +
                                const model = this.dataset.model;
         | 
| 1346 | 
            +
                                handleVote(model);
         | 
| 1347 | 
            +
                            } else {
         | 
| 1348 | 
            +
                                showListenToastMessage();
         | 
| 1349 | 
            +
                            }
         | 
| 1350 | 
            +
                        });
         | 
| 1351 | 
            +
                    });
         | 
| 1352 | 
            +
                    
         | 
| 1353 | 
            +
                    // Add keyboard shortcut listeners
         | 
| 1354 | 
            +
                    document.addEventListener('keydown', function(e) {
         | 
| 1355 | 
            +
                        // Check if TTS tab is active
         | 
| 1356 | 
            +
                        const ttsTab = document.getElementById('tts-tab');
         | 
| 1357 | 
            +
                        if (!ttsTab.classList.contains('active')) return;
         | 
| 1358 | 
            +
                        
         | 
| 1359 | 
            +
                        // Only process keyboard shortcuts if text input is not focused
         | 
| 1360 | 
            +
                        if (document.activeElement === textInput) {
         | 
| 1361 | 
            +
                            return;
         | 
| 1362 | 
            +
                        }
         | 
| 1363 | 
            +
                        
         | 
| 1364 | 
            +
                        if (e.key.toLowerCase() === 'a') {
         | 
| 1365 | 
            +
                            if (bothSamplesPlayed && !voteButtons[0].disabled) {
         | 
| 1366 | 
            +
                                handleVote('a');
         | 
| 1367 | 
            +
                            } else if (playersContainer.style.display !== 'none' && !bothSamplesPlayed) {
         | 
| 1368 | 
            +
                                showListenToastMessage();
         | 
| 1369 | 
            +
                            }
         | 
| 1370 | 
            +
                        } else if (e.key.toLowerCase() === 'b') {
         | 
| 1371 | 
            +
                            if (bothSamplesPlayed && !voteButtons[1].disabled) {
         | 
| 1372 | 
            +
                                handleVote('b');
         | 
| 1373 | 
            +
                            } else if (playersContainer.style.display !== 'none' && !bothSamplesPlayed) {
         | 
| 1374 | 
            +
                                showListenToastMessage();
         | 
| 1375 | 
            +
                            }
         | 
| 1376 | 
            +
                        } else if (e.key.toLowerCase() === 'n') {
         | 
| 1377 | 
            +
                            if (nextRoundContainer.style.display === 'block') {
         | 
| 1378 | 
            +
                                if (!e.ctrlKey && !e.metaKey) {
         | 
| 1379 | 
            +
                                    e.preventDefault();
         | 
| 1380 | 
            +
                                }
         | 
| 1381 | 
            +
                                resetToInitialState();
         | 
| 1382 | 
            +
                            }
         | 
| 1383 | 
            +
                        } else if (e.key.toLowerCase() === 'r') {
         | 
| 1384 | 
            +
                            // Only trigger random if not trying to reload (Ctrl+R or Cmd+R)
         | 
| 1385 | 
            +
                            if (!e.ctrlKey && !e.metaKey) {
         | 
| 1386 | 
            +
                                e.preventDefault();
         | 
| 1387 | 
            +
                                handleRandom();
         | 
| 1388 | 
            +
                            }
         | 
| 1389 | 
            +
                        } else if (e.key === ' ') {
         | 
| 1390 | 
            +
                            // Space to play/pause current audio
         | 
| 1391 | 
            +
                            if (playersContainer.style.display !== 'none') {
         | 
| 1392 | 
            +
                                e.preventDefault();
         | 
| 1393 | 
            +
                                // If A is playing, toggle A, else if B is playing, toggle B, else play A
         | 
| 1394 | 
            +
                                if (wavePlayers.a.isPlaying) {
         | 
| 1395 | 
            +
                                    wavePlayers.a.togglePlayPause();
         | 
| 1396 | 
            +
                                } else if (wavePlayers.b.isPlaying) {
         | 
| 1397 | 
            +
                                    wavePlayers.b.togglePlayPause();
         | 
| 1398 | 
            +
                                } else {
         | 
| 1399 | 
            +
                                    wavePlayers.a.play();
         | 
| 1400 | 
            +
                                }
         | 
| 1401 | 
            +
                            }
         | 
| 1402 | 
            +
                        }
         | 
| 1403 | 
            +
                    });
         | 
| 1404 | 
            +
                    
         | 
| 1405 | 
            +
                    // Add event listener for random button
         | 
| 1406 | 
            +
                    randomBtn.addEventListener('click', handleRandom);
         | 
| 1407 | 
            +
                    
         | 
| 1408 | 
            +
                    // Add event listener for next round button
         | 
| 1409 | 
            +
                    nextRoundBtn.addEventListener('click', resetToInitialState);
         | 
| 1410 | 
            +
                });
         | 
| 1411 | 
            +
            </script>
         | 
| 1412 | 
            +
             | 
| 1413 | 
            +
            <script>
         | 
| 1414 | 
            +
                document.addEventListener('DOMContentLoaded', function() {
         | 
| 1415 | 
            +
                    // Variables for podcast UI
         | 
| 1416 | 
            +
                    const podcastContainer = document.querySelector('.podcast-container');
         | 
| 1417 | 
            +
                    const podcastLinesContainer = document.querySelector('.podcast-lines');
         | 
| 1418 | 
            +
                    const addLineBtn = document.querySelector('.add-line-btn');
         | 
| 1419 | 
            +
                    const randomScriptBtn = document.querySelector('.random-script-btn');
         | 
| 1420 | 
            +
                    const podcastSynthBtn = document.querySelector('.podcast-synth-btn');
         | 
| 1421 | 
            +
                    const podcastLoadingContainer = document.querySelector('.podcast-loading-container');
         | 
| 1422 | 
            +
                    const podcastPlayerContainer = document.querySelector('.podcast-player-container');
         | 
| 1423 | 
            +
                    const podcastWavePlayerA = document.querySelector('.podcast-wave-player-a');
         | 
| 1424 | 
            +
                    const podcastWavePlayerB = document.querySelector('.podcast-wave-player-b');
         | 
| 1425 | 
            +
                    const podcastVoteButtons = podcastPlayerContainer.querySelectorAll('.vote-btn');
         | 
| 1426 | 
            +
                    const podcastVoteResults = podcastPlayerContainer.querySelector('.vote-results');
         | 
| 1427 | 
            +
                    const podcastNextRoundContainer = podcastPlayerContainer.querySelector('.next-round-container');
         | 
| 1428 | 
            +
                    const podcastNextRoundBtn = podcastPlayerContainer.querySelector('.next-round-btn');
         | 
| 1429 | 
            +
                    const chosenModelNameElement = podcastVoteResults.querySelector('.chosen-model-name');
         | 
| 1430 | 
            +
                    const rejectedModelNameElement = podcastVoteResults.querySelector('.rejected-model-name');
         | 
| 1431 | 
            +
                    
         | 
| 1432 | 
            +
                    let podcastWavePlayers = { a: null, b: null };
         | 
| 1433 | 
            +
                    let bothPodcastSamplesPlayed = false;
         | 
| 1434 | 
            +
                    let currentPodcastSessionId = null;
         | 
| 1435 | 
            +
                    let podcastModelNames = { a: 'Model A', b: 'Model B' };
         | 
| 1436 | 
            +
                    
         | 
| 1437 | 
            +
                    // Sample random scripts for the podcast
         | 
| 1438 | 
            +
                    const randomScripts = [
         | 
| 1439 | 
            +
                        [
         | 
| 1440 | 
            +
                            { speaker: 1, text: "Welcome to our podcast about artificial intelligence. Today we're discussing the latest advances in text-to-speech technology." },
         | 
| 1441 | 
            +
                            { speaker: 2, text: "That's right! Text-to-speech has come a long way in recent years. The voices sound increasingly natural." },
         | 
| 1442 | 
            +
                            { speaker: 1, text: "What do you think are the most impressive recent developments?" },
         | 
| 1443 | 
            +
                            { speaker: 2, text: "I'd say the emotion and inflection that modern TTS systems can convey is truly remarkable." }
         | 
| 1444 | 
            +
                        ],
         | 
| 1445 | 
            +
                        [
         | 
| 1446 | 
            +
                            { speaker: 1, text: "So today we're talking about climate change and its effects on our planet." },
         | 
| 1447 | 
            +
                            { speaker: 2, text: "It's such an important topic. We're seeing more extreme weather events every year." },
         | 
| 1448 | 
            +
                            { speaker: 1, text: "Absolutely. And the science is clear that human activity is the primary driver." },
         | 
| 1449 | 
            +
                            { speaker: 2, text: "What can individuals do to help address this global challenge?" }
         | 
| 1450 | 
            +
                        ],
         | 
| 1451 | 
            +
                        [
         | 
| 1452 | 
            +
                            { speaker: 1, text: "In today's episode, we're exploring the world of modern cinema." },
         | 
| 1453 | 
            +
                            { speaker: 2, text: "Film has evolved so much since its early days. What's your favorite era of movies?" },
         | 
| 1454 | 
            +
                            { speaker: 1, text: "I'm particularly fond of the 1970s New Hollywood movement. Films like The Godfather and Taxi Driver really pushed boundaries." },
         | 
| 1455 | 
            +
                            { speaker: 2, text: "Interesting choice! I'm more drawn to contemporary international cinema, especially from directors like Bong Joon-ho and Park Chan-wook." }
         | 
| 1456 | 
            +
                        ],
         | 
| 1457 | 
            +
                        [
         | 
| 1458 | 
            +
                            { speaker: 1, text: "Today we're discussing the future of remote work. How do you think it's changed the workplace?" },
         | 
| 1459 | 
            +
                            { speaker: 2, text: "I believe it's revolutionized how we think about productivity and work-life balance." },
         | 
| 1460 | 
            +
                            { speaker: 1, text: "Do you think companies will continue to offer remote options post-pandemic?" },
         | 
| 1461 | 
            +
                            { speaker: 2, text: "Absolutely. Companies that don't embrace flexibility will struggle to attract top talent." }
         | 
| 1462 | 
            +
                        ],
         | 
| 1463 | 
            +
                        [
         | 
| 1464 | 
            +
                            { speaker: 1, text: "Let's talk about the latest developments in renewable energy." },
         | 
| 1465 | 
            +
                            { speaker: 2, text: "Solar and wind have become increasingly cost-effective in recent years." },
         | 
| 1466 | 
            +
                            { speaker: 1, text: "What about emerging technologies like green hydrogen?" },
         | 
| 1467 | 
            +
                            { speaker: 2, text: "That's a fascinating area with huge potential, especially for industries that are difficult to electrify." }
         | 
| 1468 | 
            +
                        ],
         | 
| 1469 | 
            +
                        [
         | 
| 1470 | 
            +
                            { speaker: 1, text: "The world of cryptocurrency has seen massive changes lately. What's your take?" },
         | 
| 1471 | 
            +
                            { speaker: 2, text: "It's certainly volatile, but I think blockchain technology has applications beyond just digital currency." },
         | 
| 1472 | 
            +
                            { speaker: 1, text: "Do you see it becoming mainstream in the financial sector?" },
         | 
| 1473 | 
            +
                            { speaker: 2, text: "Parts of it already are. Central banks are exploring digital currencies, and major companies are investing in blockchain." }
         | 
| 1474 | 
            +
                        ],
         | 
| 1475 | 
            +
                        [
         | 
| 1476 | 
            +
                            { speaker: 1, text: "Mental health awareness has grown significantly in recent years." },
         | 
| 1477 | 
            +
                            { speaker: 2, text: "Yes, and it's about time. The stigma around seeking help is finally starting to diminish." },
         | 
| 1478 | 
            +
                            { speaker: 1, text: "What do you think has driven this change?" },
         | 
| 1479 | 
            +
                            { speaker: 2, text: "I think social media has played a role, with more people openly sharing their experiences." }
         | 
| 1480 | 
            +
                        ],
         | 
| 1481 | 
            +
                        [
         | 
| 1482 | 
            +
                            { speaker: 1, text: "Space exploration is entering an exciting new era with private companies leading the charge." },
         | 
| 1483 | 
            +
                            { speaker: 2, text: "The commercialization of space has definitely accelerated innovation in the field." },
         | 
| 1484 | 
            +
                            { speaker: 1, text: "Do you think we'll see humans on Mars in our lifetime?" },
         | 
| 1485 | 
            +
                            { speaker: 2, text: "I'm optimistic. The technology is advancing rapidly, and there's strong motivation from both public and private sectors." }
         | 
| 1486 | 
            +
                        ],
         | 
| 1487 | 
            +
                        [
         | 
| 1488 | 
            +
                            { speaker: 1, text: "Today's topic is sustainable fashion. How can consumers make more ethical choices?" },
         | 
| 1489 | 
            +
                            { speaker: 2, text: "It starts with buying less and choosing quality items that last longer." },
         | 
| 1490 | 
            +
                            { speaker: 1, text: "What about the responsibility of fashion brands themselves?" },
         | 
| 1491 | 
            +
                            { speaker: 2, text: "They need to be transparent about their supply chains and commit to reducing their environmental impact." }
         | 
| 1492 | 
            +
                        ],
         | 
| 1493 | 
            +
                        [
         | 
| 1494 | 
            +
                            { speaker: 1, text: "Let's discuss the evolution of social media and its impact on society." },
         | 
| 1495 | 
            +
                            { speaker: 2, text: "It's transformed how we connect, but also created new challenges like misinformation and privacy concerns." },
         | 
| 1496 | 
            +
                            { speaker: 1, text: "Do you think regulation is the answer?" },
         | 
| 1497 | 
            +
                            { speaker: 2, text: "Partly, but digital literacy education is equally important so people can navigate these platforms responsibly." }
         | 
| 1498 | 
            +
                        ],
         | 
| 1499 | 
            +
                        [
         | 
| 1500 | 
            +
                            { speaker: 1, text: "The field of genomics has seen remarkable progress. What excites you most about it?" },
         | 
| 1501 | 
            +
                            { speaker: 2, text: "Personalized medicine is fascinating - the idea that treatments can be tailored to an individual's genetic makeup." },
         | 
| 1502 | 
            +
                            { speaker: 1, text: "What about the ethical considerations?" },
         | 
| 1503 | 
            +
                            { speaker: 2, text: "Those are crucial. We need robust frameworks to ensure these technologies are used responsibly." }
         | 
| 1504 | 
            +
                        ],
         | 
| 1505 | 
            +
                        [
         | 
| 1506 | 
            +
                            { speaker: 1, text: "Urban planning is facing new challenges in the 21st century. What trends are you seeing?" },
         | 
| 1507 | 
            +
                            { speaker: 2, text: "There's a growing focus on creating walkable, mixed-use neighborhoods that reduce car dependency." },
         | 
| 1508 | 
            +
                            { speaker: 1, text: "How are cities adapting to climate change?" },
         | 
| 1509 | 
            +
                            { speaker: 2, text: "Many are implementing green infrastructure like parks and permeable surfaces to manage flooding and reduce heat islands." }
         | 
| 1510 | 
            +
                        ],
         | 
| 1511 | 
            +
                        [
         | 
| 1512 | 
            +
                            { speaker: 1, text: "The gaming industry has grown enormously in recent years. What's driving this expansion?" },
         | 
| 1513 | 
            +
                            { speaker: 2, text: "Gaming has become much more accessible across different platforms, and the pandemic certainly accelerated adoption." },
         | 
| 1514 | 
            +
                            { speaker: 1, text: "What do you think about the rise of esports?" },
         | 
| 1515 | 
            +
                            { speaker: 2, text: "It's fascinating to see competitive gaming achieve mainstream recognition and create new career opportunities." }
         | 
| 1516 | 
            +
                        ],
         | 
| 1517 | 
            +
                        [
         | 
| 1518 | 
            +
                            { speaker: 1, text: "Let's talk about the future of transportation. How will we get around in 20 years?" },
         | 
| 1519 | 
            +
                            { speaker: 2, text: "Electric vehicles will be dominant, and autonomous driving technology will be much more widespread." },
         | 
| 1520 | 
            +
                            { speaker: 1, text: "What about public transit and alternative modes?" },
         | 
| 1521 | 
            +
                            { speaker: 2, text: "I think we'll see more integrated systems where bikes, scooters, and public transit work seamlessly together." }
         | 
| 1522 | 
            +
                        ]
         | 
| 1523 | 
            +
                    ];
         | 
| 1524 | 
            +
                    
         | 
| 1525 | 
            +
                    // Initialize with 2 empty lines
         | 
| 1526 | 
            +
                    function initializePodcastLines() {
         | 
| 1527 | 
            +
                        podcastLinesContainer.innerHTML = '';
         | 
| 1528 | 
            +
                        addPodcastLine(1);
         | 
| 1529 | 
            +
                        addPodcastLine(2);
         | 
| 1530 | 
            +
                    }
         | 
| 1531 | 
            +
                    
         | 
| 1532 | 
            +
                    // Add a new podcast line
         | 
| 1533 | 
            +
                    function addPodcastLine(speakerNum = null) {
         | 
| 1534 | 
            +
                        const lineCount = podcastLinesContainer.querySelectorAll('.podcast-line').length;
         | 
| 1535 | 
            +
                        
         | 
| 1536 | 
            +
                        // If speaker number isn't specified, alternate between 1 and 2
         | 
| 1537 | 
            +
                        if (speakerNum === null) {
         | 
| 1538 | 
            +
                            speakerNum = (lineCount % 2) + 1;
         | 
| 1539 | 
            +
                        }
         | 
| 1540 | 
            +
                        
         | 
| 1541 | 
            +
                        const lineElement = document.createElement('div');
         | 
| 1542 | 
            +
                        lineElement.className = 'podcast-line';
         | 
| 1543 | 
            +
                        
         | 
| 1544 | 
            +
                        lineElement.innerHTML = `
         | 
| 1545 | 
            +
                            <div class="speaker-label speaker-${speakerNum}">Speaker ${speakerNum}</div>
         | 
| 1546 | 
            +
                            <input type="text" class="line-input" placeholder="Enter dialog...">
         | 
| 1547 | 
            +
                            <button type="button" class="remove-line-btn" tabindex="-1">
         | 
| 1548 | 
            +
                                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" 
         | 
| 1549 | 
            +
                                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
         | 
| 1550 | 
            +
                                    <line x1="18" y1="6" x2="6" y2="18"></line>
         | 
| 1551 | 
            +
                                    <line x1="6" y1="6" x2="18" y2="18"></line>
         | 
| 1552 | 
            +
                                </svg>
         | 
| 1553 | 
            +
                            </button>
         | 
| 1554 | 
            +
                        `;
         | 
| 1555 | 
            +
                        
         | 
| 1556 | 
            +
                        podcastLinesContainer.appendChild(lineElement);
         | 
| 1557 | 
            +
                        
         | 
| 1558 | 
            +
                        // Add event listener to remove button
         | 
| 1559 | 
            +
                        const removeBtn = lineElement.querySelector('.remove-line-btn');
         | 
| 1560 | 
            +
                        removeBtn.addEventListener('click', function() {
         | 
| 1561 | 
            +
                            // Don't allow removing if there are only 2 lines
         | 
| 1562 | 
            +
                            if (podcastLinesContainer.querySelectorAll('.podcast-line').length > 2) {
         | 
| 1563 | 
            +
                                lineElement.remove();
         | 
| 1564 | 
            +
                            } else {
         | 
| 1565 | 
            +
                                openToast("At least 2 lines are required", "warning");
         | 
| 1566 | 
            +
                            }
         | 
| 1567 | 
            +
                        });
         | 
| 1568 | 
            +
                        
         | 
| 1569 | 
            +
                        // Add event listener for keyboard navigation in the input field
         | 
| 1570 | 
            +
                        const inputField = lineElement.querySelector('.line-input');
         | 
| 1571 | 
            +
                        inputField.addEventListener('keydown', function(e) {
         | 
| 1572 | 
            +
                            // Alt+Enter or Ctrl+Enter to add new line
         | 
| 1573 | 
            +
                            if (e.key === 'Enter' && (e.altKey || e.ctrlKey)) {
         | 
| 1574 | 
            +
                                e.preventDefault();
         | 
| 1575 | 
            +
                                addPodcastLine();
         | 
| 1576 | 
            +
                                
         | 
| 1577 | 
            +
                                // Focus the new line's input field
         | 
| 1578 | 
            +
                                setTimeout(() => {
         | 
| 1579 | 
            +
                                    const inputs = podcastLinesContainer.querySelectorAll('.line-input');
         | 
| 1580 | 
            +
                                    inputs[inputs.length - 1].focus();
         | 
| 1581 | 
            +
                                }, 10);
         | 
| 1582 | 
            +
                            }
         | 
| 1583 | 
            +
                        });
         | 
| 1584 | 
            +
                        
         | 
| 1585 | 
            +
                        return lineElement;
         | 
| 1586 | 
            +
                    }
         | 
| 1587 | 
            +
                    
         | 
| 1588 | 
            +
                    // Load a random script
         | 
| 1589 | 
            +
                    function loadRandomScript() {
         | 
| 1590 | 
            +
                        // Clear existing lines
         | 
| 1591 | 
            +
                        podcastLinesContainer.innerHTML = '';
         | 
| 1592 | 
            +
                        
         | 
| 1593 | 
            +
                        // Select a random script
         | 
| 1594 | 
            +
                        const randomScript = randomScripts[Math.floor(Math.random() * randomScripts.length)];
         | 
| 1595 | 
            +
                        
         | 
| 1596 | 
            +
                        // Add each line from the script
         | 
| 1597 | 
            +
                        randomScript.forEach(line => {
         | 
| 1598 | 
            +
                            const lineElement = addPodcastLine(line.speaker);
         | 
| 1599 | 
            +
                            lineElement.querySelector('.line-input').value = line.text;
         | 
| 1600 | 
            +
                        });
         | 
| 1601 | 
            +
                    }
         | 
| 1602 | 
            +
                    
         | 
| 1603 | 
            +
                    // Generate podcast (mock functionality)
         | 
| 1604 | 
            +
                    function generatePodcast() {
         | 
| 1605 | 
            +
                        // Get all lines
         | 
| 1606 | 
            +
                        const lines = [];
         | 
| 1607 | 
            +
                        podcastLinesContainer.querySelectorAll('.podcast-line').forEach(line => {
         | 
| 1608 | 
            +
                            const speaker_id = line.querySelector('.speaker-label').textContent.includes('1') ? 0 : 1;
         | 
| 1609 | 
            +
                            const text = line.querySelector('.line-input').value.trim();
         | 
| 1610 | 
            +
                            
         | 
| 1611 | 
            +
                            if (text) {
         | 
| 1612 | 
            +
                                lines.push({ speaker_id, text });
         | 
| 1613 | 
            +
                            }
         | 
| 1614 | 
            +
                        });
         | 
| 1615 | 
            +
                        
         | 
| 1616 | 
            +
                        // Validate that we have at least 2 lines with content
         | 
| 1617 | 
            +
                        if (lines.length < 2) {
         | 
| 1618 | 
            +
                            openToast("Please enter at least 2 lines of dialog", "warning");
         | 
| 1619 | 
            +
                            return;
         | 
| 1620 | 
            +
                        }
         | 
| 1621 | 
            +
                        
         | 
| 1622 | 
            +
                        // Reset vote buttons and hide results
         | 
| 1623 | 
            +
                        podcastVoteButtons.forEach(btn => {
         | 
| 1624 | 
            +
                            btn.disabled = true;
         | 
| 1625 | 
            +
                            btn.classList.remove('selected');
         | 
| 1626 | 
            +
                            btn.querySelector('.vote-loader').style.display = 'none';
         | 
| 1627 | 
            +
                        });
         | 
| 1628 | 
            +
                        
         | 
| 1629 | 
            +
                        // Clear model name displays
         | 
| 1630 | 
            +
                        const modelNameDisplays = podcastPlayerContainer.querySelectorAll('.model-name-display');
         | 
| 1631 | 
            +
                        modelNameDisplays.forEach(display => {
         | 
| 1632 | 
            +
                            display.textContent = '';
         | 
| 1633 | 
            +
                        });
         | 
| 1634 | 
            +
             | 
| 1635 | 
            +
                        podcastVoteResults.style.display = 'none';
         | 
| 1636 | 
            +
                        podcastNextRoundContainer.style.display = 'none';
         | 
| 1637 | 
            +
                        
         | 
| 1638 | 
            +
                        // Reset the flag for both samples played
         | 
| 1639 | 
            +
                        bothPodcastSamplesPlayed = false;
         | 
| 1640 | 
            +
                        
         | 
| 1641 | 
            +
                        // Show loading animation
         | 
| 1642 | 
            +
                        podcastLoadingContainer.style.display = 'flex';
         | 
| 1643 | 
            +
                        podcastPlayerContainer.style.display = 'none';
         | 
| 1644 | 
            +
                        
         | 
| 1645 | 
            +
                        // Call API to generate podcast
         | 
| 1646 | 
            +
                        fetch('/api/conversational/generate', {
         | 
| 1647 | 
            +
                            method: 'POST',
         | 
| 1648 | 
            +
                            headers: {
         | 
| 1649 | 
            +
                                'Content-Type': 'application/json',
         | 
| 1650 | 
            +
                            },
         | 
| 1651 | 
            +
                            body: JSON.stringify({ script: lines }),
         | 
| 1652 | 
            +
                        })
         | 
| 1653 | 
            +
                        .then(response => {
         | 
| 1654 | 
            +
                            if (!response.ok) {
         | 
| 1655 | 
            +
                                return response.json().then(err => {
         | 
| 1656 | 
            +
                                    throw new Error(err.error || 'Failed to generate podcast');
         | 
| 1657 | 
            +
                                });
         | 
| 1658 | 
            +
                            }
         | 
| 1659 | 
            +
                            return response.json();
         | 
| 1660 | 
            +
                        })
         | 
| 1661 | 
            +
                        .then(data => {
         | 
| 1662 | 
            +
                            currentPodcastSessionId = data.session_id;
         | 
| 1663 | 
            +
                            
         | 
| 1664 | 
            +
                            // Hide loading
         | 
| 1665 | 
            +
                            podcastLoadingContainer.style.display = 'none';
         | 
| 1666 | 
            +
                            
         | 
| 1667 | 
            +
                            // Show player
         | 
| 1668 | 
            +
                            podcastPlayerContainer.style.display = 'block';
         | 
| 1669 | 
            +
                            
         | 
| 1670 | 
            +
                            // Initialize WavePlayers if not already done
         | 
| 1671 | 
            +
                            if (!podcastWavePlayers.a) {
         | 
| 1672 | 
            +
                                podcastWavePlayers.a = new WavePlayer(podcastWavePlayerA, {
         | 
| 1673 | 
            +
                                    // Add mobile-friendly options but hide native controls
         | 
| 1674 | 
            +
                                    backend: 'MediaElement',
         | 
| 1675 | 
            +
                                    mediaControls: false // Hide native audio controls
         | 
| 1676 | 
            +
                                });
         | 
| 1677 | 
            +
                                podcastWavePlayers.b = new WavePlayer(podcastWavePlayerB, {
         | 
| 1678 | 
            +
                                    // Add mobile-friendly options but hide native controls
         | 
| 1679 | 
            +
                                    backend: 'MediaElement',
         | 
| 1680 | 
            +
                                    mediaControls: false // Hide native audio controls
         | 
| 1681 | 
            +
                                });
         | 
| 1682 | 
            +
                                
         | 
| 1683 | 
            +
                                // Load audio in waveplayers
         | 
| 1684 | 
            +
                                podcastWavePlayers.a.loadAudio(data.audio_a);
         | 
| 1685 | 
            +
                                podcastWavePlayers.b.loadAudio(data.audio_b);
         | 
| 1686 | 
            +
                                
         | 
| 1687 | 
            +
                                // Force hide loading indicators after 5 seconds as a fallback
         | 
| 1688 | 
            +
                                setTimeout(() => {
         | 
| 1689 | 
            +
                                    if (podcastWavePlayers.a && podcastWavePlayers.a.hideLoading) {
         | 
| 1690 | 
            +
                                        podcastWavePlayers.a.hideLoading();
         | 
| 1691 | 
            +
                                    }
         | 
| 1692 | 
            +
                                    if (podcastWavePlayers.b && podcastWavePlayers.b.hideLoading) {
         | 
| 1693 | 
            +
                                        podcastWavePlayers.b.hideLoading();
         | 
| 1694 | 
            +
                                    }
         | 
| 1695 | 
            +
                                    console.log('Forced hiding of podcast loading indicators (safety timeout - existing players)');
         | 
| 1696 | 
            +
                                }, 5000);
         | 
| 1697 | 
            +
                            } else {
         | 
| 1698 | 
            +
                                // Reset and reload for existing players
         | 
| 1699 | 
            +
                                try {
         | 
| 1700 | 
            +
                                    podcastWavePlayers.a.wavesurfer.empty();
         | 
| 1701 | 
            +
                                    podcastWavePlayers.b.wavesurfer.empty();
         | 
| 1702 | 
            +
                                    
         | 
| 1703 | 
            +
                                    // Make sure loading indicators are reset
         | 
| 1704 | 
            +
                                    podcastWavePlayers.a.hideLoading();
         | 
| 1705 | 
            +
                                    podcastWavePlayers.b.hideLoading();
         | 
| 1706 | 
            +
                                    
         | 
| 1707 | 
            +
                                    podcastWavePlayers.a.loadAudio(data.audio_a);
         | 
| 1708 | 
            +
                                    podcastWavePlayers.b.loadAudio(data.audio_b);
         | 
| 1709 | 
            +
                                    
         | 
| 1710 | 
            +
                                    // Force hide loading indicators after 5 seconds as a fallback
         | 
| 1711 | 
            +
                                    setTimeout(() => {
         | 
| 1712 | 
            +
                                        if (podcastWavePlayers.a && podcastWavePlayers.a.hideLoading) {
         | 
| 1713 | 
            +
                                            podcastWavePlayers.a.hideLoading();
         | 
| 1714 | 
            +
                                        }
         | 
| 1715 | 
            +
                                        if (podcastWavePlayers.b && podcastWavePlayers.b.hideLoading) {
         | 
| 1716 | 
            +
                                            podcastWavePlayers.b.hideLoading();
         | 
| 1717 | 
            +
                                        }
         | 
| 1718 | 
            +
                                        console.log('Forced hiding of podcast loading indicators (safety timeout - existing players)');
         | 
| 1719 | 
            +
                                    }, 5000);
         | 
| 1720 | 
            +
                                } catch (err) {
         | 
| 1721 | 
            +
                                    console.error('Error resetting podcast waveplayers:', err);
         | 
| 1722 | 
            +
                                    
         | 
| 1723 | 
            +
                                    // Recreate the players if there was an error
         | 
| 1724 | 
            +
                                    podcastWavePlayers.a = new WavePlayer(podcastWavePlayerA, {
         | 
| 1725 | 
            +
                                        backend: 'MediaElement',
         | 
| 1726 | 
            +
                                        mediaControls: false
         | 
| 1727 | 
            +
                                    });
         | 
| 1728 | 
            +
                                    podcastWavePlayers.b = new WavePlayer(podcastWavePlayerB, {
         | 
| 1729 | 
            +
                                        backend: 'MediaElement',
         | 
| 1730 | 
            +
                                        mediaControls: false
         | 
| 1731 | 
            +
                                    });
         | 
| 1732 | 
            +
                                    
         | 
| 1733 | 
            +
                                    podcastWavePlayers.a.loadAudio(data.audio_a);
         | 
| 1734 | 
            +
                                    podcastWavePlayers.b.loadAudio(data.audio_b);
         | 
| 1735 | 
            +
             | 
| 1736 | 
            +
                                    // Force hide loading indicators after 5 seconds as a fallback
         | 
| 1737 | 
            +
                                    setTimeout(() => {
         | 
| 1738 | 
            +
                                        if (podcastWavePlayers.a && podcastWavePlayers.a.hideLoading) {
         | 
| 1739 | 
            +
                                            podcastWavePlayers.a.hideLoading();
         | 
| 1740 | 
            +
                                        }
         | 
| 1741 | 
            +
                                        if (podcastWavePlayers.b && podcastWavePlayers.b.hideLoading) {
         | 
| 1742 | 
            +
                                            podcastWavePlayers.b.hideLoading();
         | 
| 1743 | 
            +
                                        }
         | 
| 1744 | 
            +
                                        console.log('Forced hiding of podcast loading indicators (fallback case)');
         | 
| 1745 | 
            +
                                    }, 5000);
         | 
| 1746 | 
            +
                                }
         | 
| 1747 | 
            +
                            }
         | 
| 1748 | 
            +
                            
         | 
| 1749 | 
            +
                            // Setup automatic sequential playback
         | 
| 1750 | 
            +
                            podcastWavePlayers.a.wavesurfer.once('ready', function() {
         | 
| 1751 | 
            +
                                podcastWavePlayers.a.play();
         | 
| 1752 | 
            +
                                
         | 
| 1753 | 
            +
                                // When audio A ends, play audio B
         | 
| 1754 | 
            +
                                podcastWavePlayers.a.wavesurfer.once('finish', function() {
         | 
| 1755 | 
            +
                                    // Wait a short moment before playing B
         | 
| 1756 | 
            +
                                    setTimeout(() => {
         | 
| 1757 | 
            +
                                        podcastWavePlayers.b.play();
         | 
| 1758 | 
            +
                                        
         | 
| 1759 | 
            +
                                        // When audio B ends, enable voting
         | 
| 1760 | 
            +
                                        podcastWavePlayers.b.wavesurfer.once('finish', function() {
         | 
| 1761 | 
            +
                                            bothPodcastSamplesPlayed = true;
         | 
| 1762 | 
            +
                                            podcastVoteButtons.forEach(btn => {
         | 
| 1763 | 
            +
                                                btn.disabled = false;
         | 
| 1764 | 
            +
                                            });
         | 
| 1765 | 
            +
                                        });
         | 
| 1766 | 
            +
                                    }, 500);
         | 
| 1767 | 
            +
                                });
         | 
| 1768 | 
            +
                            });
         | 
| 1769 | 
            +
                        })
         | 
| 1770 | 
            +
                        .catch(error => {
         | 
| 1771 | 
            +
                            podcastLoadingContainer.style.display = 'none';
         | 
| 1772 | 
            +
                            openToast(error.message, "error");
         | 
| 1773 | 
            +
                            console.error('Error:', error);
         | 
| 1774 | 
            +
                        });
         | 
| 1775 | 
            +
                    }
         | 
| 1776 | 
            +
                    
         | 
| 1777 | 
            +
                    // Handle vote for a podcast model
         | 
| 1778 | 
            +
                    function handlePodcastVote(model) {
         | 
| 1779 | 
            +
                        // Disable both vote buttons
         | 
| 1780 | 
            +
                        podcastVoteButtons.forEach(btn => {
         | 
| 1781 | 
            +
                            btn.disabled = true;
         | 
| 1782 | 
            +
                            if (btn.dataset.model === model) {
         | 
| 1783 | 
            +
                                btn.querySelector('.vote-loader').style.display = 'flex';
         | 
| 1784 | 
            +
                            }
         | 
| 1785 | 
            +
                        });
         | 
| 1786 | 
            +
                        
         | 
| 1787 | 
            +
                        // Send vote to server
         | 
| 1788 | 
            +
                        fetch('/api/conversational/vote', {
         | 
| 1789 | 
            +
                            method: 'POST',
         | 
| 1790 | 
            +
                            headers: {
         | 
| 1791 | 
            +
                                'Content-Type': 'application/json',
         | 
| 1792 | 
            +
                            },
         | 
| 1793 | 
            +
                            body: JSON.stringify({
         | 
| 1794 | 
            +
                                session_id: currentPodcastSessionId,
         | 
| 1795 | 
            +
                                chosen_model: model
         | 
| 1796 | 
            +
                            }),
         | 
| 1797 | 
            +
                        })
         | 
| 1798 | 
            +
                        .then(response => {
         | 
| 1799 | 
            +
                            if (!response.ok) {
         | 
| 1800 | 
            +
                                return response.json().then(err => {
         | 
| 1801 | 
            +
                                    throw new Error(err.error || 'Failed to submit vote');
         | 
| 1802 | 
            +
                                });
         | 
| 1803 | 
            +
                            }
         | 
| 1804 | 
            +
                            return response.json();
         | 
| 1805 | 
            +
                        })
         | 
| 1806 | 
            +
                        .then(data => {
         | 
| 1807 | 
            +
                            // Hide loaders
         | 
| 1808 | 
            +
                            podcastVoteButtons.forEach(btn => {
         | 
| 1809 | 
            +
                                btn.querySelector('.vote-loader').style.display = 'none';
         | 
| 1810 | 
            +
                                
         | 
| 1811 | 
            +
                                // Highlight the selected button
         | 
| 1812 | 
            +
                                if (btn.dataset.model === model) {
         | 
| 1813 | 
            +
                                    btn.classList.add('selected');
         | 
| 1814 | 
            +
                                }
         | 
| 1815 | 
            +
                            });
         | 
| 1816 | 
            +
                            
         | 
| 1817 | 
            +
                            // Store model names from vote response
         | 
| 1818 | 
            +
                            podcastModelNames.a = data.names.a;
         | 
| 1819 | 
            +
                            podcastModelNames.b = data.names.b;
         | 
| 1820 | 
            +
                            
         | 
| 1821 | 
            +
                            // Show model names after voting
         | 
| 1822 | 
            +
                            const modelNameDisplays = podcastPlayerContainer.querySelectorAll('.model-name-display');
         | 
| 1823 | 
            +
                            modelNameDisplays[0].textContent = data.names.a ? `(${data.names.a})` : '';
         | 
| 1824 | 
            +
                            modelNameDisplays[1].textContent = data.names.b ? `(${data.names.b})` : '';
         | 
| 1825 | 
            +
                            
         | 
| 1826 | 
            +
                            // Show vote results
         | 
| 1827 | 
            +
                            chosenModelNameElement.textContent = data.chosen_model.name;
         | 
| 1828 | 
            +
                            rejectedModelNameElement.textContent = data.rejected_model.name;
         | 
| 1829 | 
            +
                            podcastVoteResults.style.display = 'block';
         | 
| 1830 | 
            +
                            
         | 
| 1831 | 
            +
                            // Show next round button
         | 
| 1832 | 
            +
                            podcastNextRoundContainer.style.display = 'block';
         | 
| 1833 | 
            +
                            
         | 
| 1834 | 
            +
                            // Show success toast
         | 
| 1835 | 
            +
                            openToast("Vote recorded successfully!", "success");
         | 
| 1836 | 
            +
                        })
         | 
| 1837 | 
            +
                        .catch(error => {
         | 
| 1838 | 
            +
                            // Re-enable vote buttons
         | 
| 1839 | 
            +
                            podcastVoteButtons.forEach(btn => {
         | 
| 1840 | 
            +
                                btn.disabled = false;
         | 
| 1841 | 
            +
                                btn.querySelector('.vote-loader').style.display = 'none';
         | 
| 1842 | 
            +
                            });
         | 
| 1843 | 
            +
                            
         | 
| 1844 | 
            +
                            openToast(error.message, "error");
         | 
| 1845 | 
            +
                            console.error('Error:', error);
         | 
| 1846 | 
            +
                        });
         | 
| 1847 | 
            +
                    }
         | 
| 1848 | 
            +
                    
         | 
| 1849 | 
            +
                    // Reset podcast UI to initial state
         | 
| 1850 | 
            +
                    function resetPodcastState() {
         | 
| 1851 | 
            +
                        // Hide players, results, and next round button
         | 
| 1852 | 
            +
                        podcastPlayerContainer.style.display = 'none';
         | 
| 1853 | 
            +
                        podcastVoteResults.style.display = 'none';
         | 
| 1854 | 
            +
                        podcastNextRoundContainer.style.display = 'none';
         | 
| 1855 | 
            +
                        
         | 
| 1856 | 
            +
                        // Reset vote buttons
         | 
| 1857 | 
            +
                        podcastVoteButtons.forEach(btn => {
         | 
| 1858 | 
            +
                            btn.disabled = true;
         | 
| 1859 | 
            +
                            btn.classList.remove('selected');
         | 
| 1860 | 
            +
                            btn.querySelector('.vote-loader').style.display = 'none';
         | 
| 1861 | 
            +
                        });
         | 
| 1862 | 
            +
                        
         | 
| 1863 | 
            +
                        // Clear model name displays
         | 
| 1864 | 
            +
                        const modelNameDisplays = podcastPlayerContainer.querySelectorAll('.model-name-display');
         | 
| 1865 | 
            +
                        modelNameDisplays.forEach(display => {
         | 
| 1866 | 
            +
                            display.textContent = '';
         | 
| 1867 | 
            +
                        });
         | 
| 1868 | 
            +
                        
         | 
| 1869 | 
            +
                        // Stop any playing audio
         | 
| 1870 | 
            +
                        if (podcastWavePlayers.a) podcastWavePlayers.a.stop();
         | 
| 1871 | 
            +
                        if (podcastWavePlayers.b) podcastWavePlayers.b.stop();
         | 
| 1872 | 
            +
                        
         | 
| 1873 | 
            +
                        // Reset session
         | 
| 1874 | 
            +
                        currentPodcastSessionId = null;
         | 
| 1875 | 
            +
                        
         | 
| 1876 | 
            +
                        // Reset the flag for both samples played
         | 
| 1877 | 
            +
                        bothPodcastSamplesPlayed = false;
         | 
| 1878 | 
            +
                    }
         | 
| 1879 | 
            +
                    
         | 
| 1880 | 
            +
                    // Add keyboard shortcut listeners for podcast voting
         | 
| 1881 | 
            +
                    document.addEventListener('keydown', function(e) {
         | 
| 1882 | 
            +
                        // Check if we're in the podcast tab and it's active
         | 
| 1883 | 
            +
                        const podcastTab = document.getElementById('conversational-tab');
         | 
| 1884 | 
            +
                        if (!podcastTab.classList.contains('active')) return;
         | 
| 1885 | 
            +
                        
         | 
| 1886 | 
            +
                        // Only process if input fields are not focused
         | 
| 1887 | 
            +
                        if (document.activeElement.tagName === 'INPUT' || 
         | 
| 1888 | 
            +
                            document.activeElement.tagName === 'TEXTAREA') {
         | 
| 1889 | 
            +
                            return;
         | 
| 1890 | 
            +
                        }
         | 
| 1891 | 
            +
                        
         | 
| 1892 | 
            +
                        if (e.key.toLowerCase() === 'a') {
         | 
| 1893 | 
            +
                            if (bothPodcastSamplesPlayed && !podcastVoteButtons[0].disabled) {
         | 
| 1894 | 
            +
                                handlePodcastVote('a');
         | 
| 1895 | 
            +
                            } else if (podcastPlayerContainer.style.display !== 'none' && !bothPodcastSamplesPlayed) {
         | 
| 1896 | 
            +
                                openToast("Please listen to both audio samples before voting", "info");
         | 
| 1897 | 
            +
                            }
         | 
| 1898 | 
            +
                        } else if (e.key.toLowerCase() === 'b') {
         | 
| 1899 | 
            +
                            if (bothPodcastSamplesPlayed && !podcastVoteButtons[1].disabled) {
         | 
| 1900 | 
            +
                                handlePodcastVote('b');
         | 
| 1901 | 
            +
                            } else if (podcastPlayerContainer.style.display !== 'none' && !bothPodcastSamplesPlayed) {
         | 
| 1902 | 
            +
                                openToast("Please listen to both audio samples before voting", "info");
         | 
| 1903 | 
            +
                            }
         | 
| 1904 | 
            +
                        } else if (e.key.toLowerCase() === 'n') {
         | 
| 1905 | 
            +
                            if (podcastNextRoundContainer.style.display === 'block') {
         | 
| 1906 | 
            +
                                if (!e.ctrlKey && !e.metaKey) {
         | 
| 1907 | 
            +
                                    e.preventDefault();
         | 
| 1908 | 
            +
                                }
         | 
| 1909 | 
            +
                                resetPodcastState();
         | 
| 1910 | 
            +
                            }
         | 
| 1911 | 
            +
                        } else if (e.key === ' ') {
         | 
| 1912 | 
            +
                            // Space to play/pause current audio
         | 
| 1913 | 
            +
                            if (podcastPlayerContainer.style.display !== 'none') {
         | 
| 1914 | 
            +
                                e.preventDefault();
         | 
| 1915 | 
            +
                                // If A is playing, toggle A, else if B is playing, toggle B, else play A
         | 
| 1916 | 
            +
                                if (podcastWavePlayers.a && podcastWavePlayers.a.isPlaying) {
         | 
| 1917 | 
            +
                                    podcastWavePlayers.a.togglePlayPause();
         | 
| 1918 | 
            +
                                } else if (podcastWavePlayers.b && podcastWavePlayers.b.isPlaying) {
         | 
| 1919 | 
            +
                                    podcastWavePlayers.b.togglePlayPause();
         | 
| 1920 | 
            +
                                } else if (podcastWavePlayers.a) {
         | 
| 1921 | 
            +
                                    podcastWavePlayers.a.play();
         | 
| 1922 | 
            +
                                }
         | 
| 1923 | 
            +
                            }
         | 
| 1924 | 
            +
                        }
         | 
| 1925 | 
            +
                    });
         | 
| 1926 | 
            +
                    
         | 
| 1927 | 
            +
                    // Event listeners
         | 
| 1928 | 
            +
                    addLineBtn.addEventListener('click', function() {
         | 
| 1929 | 
            +
                        addPodcastLine();
         | 
| 1930 | 
            +
                    });
         | 
| 1931 | 
            +
                    
         | 
| 1932 | 
            +
                    randomScriptBtn.addEventListener('click', function() {
         | 
| 1933 | 
            +
                        loadRandomScript();
         | 
| 1934 | 
            +
                    });
         | 
| 1935 | 
            +
                    
         | 
| 1936 | 
            +
                    podcastSynthBtn.addEventListener('click', function() {
         | 
| 1937 | 
            +
                        generatePodcast();
         | 
| 1938 | 
            +
                    });
         | 
| 1939 | 
            +
                    
         | 
| 1940 | 
            +
                    // Add event listeners to vote buttons
         | 
| 1941 | 
            +
                    podcastVoteButtons.forEach(btn => {
         | 
| 1942 | 
            +
                        btn.addEventListener('click', function() {
         | 
| 1943 | 
            +
                            if (bothPodcastSamplesPlayed) {
         | 
| 1944 | 
            +
                                const model = this.dataset.model;
         | 
| 1945 | 
            +
                                handlePodcastVote(model);
         | 
| 1946 | 
            +
                            } else {
         | 
| 1947 | 
            +
                                openToast("Please listen to both audio samples before voting", "info");
         | 
| 1948 | 
            +
                            }
         | 
| 1949 | 
            +
                        });
         | 
| 1950 | 
            +
                    });
         | 
| 1951 | 
            +
                    
         | 
| 1952 | 
            +
                    // Add event listener for next round button
         | 
| 1953 | 
            +
                    podcastNextRoundBtn.addEventListener('click', resetPodcastState);
         | 
| 1954 | 
            +
                    
         | 
| 1955 | 
            +
                    // Initialize with 2 empty lines
         | 
| 1956 | 
            +
                    initializePodcastLines();
         | 
| 1957 | 
            +
                });
         | 
| 1958 | 
            +
            </script>
         | 
| 1959 | 
            +
            {% endblock %}
         | 
    	
        templates/base.html
    ADDED
    
    | @@ -0,0 +1,1446 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            <!DOCTYPE html>
         | 
| 2 | 
            +
            <html lang="en">
         | 
| 3 | 
            +
             | 
| 4 | 
            +
            <head>
         | 
| 5 | 
            +
                <meta charset="UTF-8">
         | 
| 6 | 
            +
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
         | 
| 7 | 
            +
                <title>{% block title %}TTS Arena{% endblock %}</title>
         | 
| 8 | 
            +
                <link rel="preconnect" href="https://fonts.googleapis.com">
         | 
| 9 | 
            +
                <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
         | 
| 10 | 
            +
                <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
         | 
| 11 | 
            +
                {% block extra_head %}{% endblock %}
         | 
| 12 | 
            +
                <style>
         | 
| 13 | 
            +
                    :root {
         | 
| 14 | 
            +
                        --primary-color: #5046e5;
         | 
| 15 | 
            +
                        --secondary-color: #f0f0f0;
         | 
| 16 | 
            +
                        --text-color: #333;
         | 
| 17 | 
            +
                        --light-gray: #f5f5f5;
         | 
| 18 | 
            +
                        --border-color: #e0e0e0;
         | 
| 19 | 
            +
                        --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
         | 
| 20 | 
            +
                        --radius: 8px;
         | 
| 21 | 
            +
                        --success-color: #10b981;
         | 
| 22 | 
            +
                        --info-color: #3b82f6;
         | 
| 23 | 
            +
                        --warning-color: #f59e0b;
         | 
| 24 | 
            +
                        --error-color: #ef4444;
         | 
| 25 | 
            +
                    }
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                    * {
         | 
| 28 | 
            +
                        margin: 0;
         | 
| 29 | 
            +
                        padding: 0;
         | 
| 30 | 
            +
                        box-sizing: border-box;
         | 
| 31 | 
            +
                        font-family: 'Inter', sans-serif;
         | 
| 32 | 
            +
                    }
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                    body {
         | 
| 35 | 
            +
                        color: var(--text-color);
         | 
| 36 | 
            +
                        display: flex;
         | 
| 37 | 
            +
                        min-height: 100vh;
         | 
| 38 | 
            +
                        height: 100vh;
         | 
| 39 | 
            +
                        overflow: hidden;
         | 
| 40 | 
            +
                    }
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                    a {
         | 
| 43 | 
            +
                        color: var(--primary-color);
         | 
| 44 | 
            +
                    }
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                    .sidebar {
         | 
| 47 | 
            +
                        width: 240px;
         | 
| 48 | 
            +
                        background-color: var(--light-gray);
         | 
| 49 | 
            +
                        padding: 24px 16px;
         | 
| 50 | 
            +
                        border-right: 1px solid var(--border-color);
         | 
| 51 | 
            +
                        display: flex;
         | 
| 52 | 
            +
                        flex-direction: column;
         | 
| 53 | 
            +
                        height: 100vh;
         | 
| 54 | 
            +
                        z-index: 1000;
         | 
| 55 | 
            +
                        transition: transform 0.3s ease-in-out;
         | 
| 56 | 
            +
                        flex-shrink: 0;
         | 
| 57 | 
            +
                    }
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                    .logo {
         | 
| 60 | 
            +
                        font-size: 24px;
         | 
| 61 | 
            +
                        font-weight: 700;
         | 
| 62 | 
            +
                        margin-bottom: 32px;
         | 
| 63 | 
            +
                        color: var(--primary-color);
         | 
| 64 | 
            +
                    }
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                    .nav-item {
         | 
| 67 | 
            +
                        display: flex;
         | 
| 68 | 
            +
                        align-items: center;
         | 
| 69 | 
            +
                        padding: 12px 16px;
         | 
| 70 | 
            +
                        margin-bottom: 8px;
         | 
| 71 | 
            +
                        border-radius: var(--radius);
         | 
| 72 | 
            +
                        cursor: pointer;
         | 
| 73 | 
            +
                        transition: background-color 0.2s;
         | 
| 74 | 
            +
                        color: var(--text-color);
         | 
| 75 | 
            +
                        text-decoration: none;
         | 
| 76 | 
            +
                    }
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                    .nav-item.active {
         | 
| 79 | 
            +
                        background-color: rgba(80, 70, 229, 0.1);
         | 
| 80 | 
            +
                        color: var(--primary-color);
         | 
| 81 | 
            +
                        font-weight: 500;
         | 
| 82 | 
            +
                    }
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                    .nav-item:hover:not(.active) {
         | 
| 85 | 
            +
                        background-color: rgba(0, 0, 0, 0.05);
         | 
| 86 | 
            +
                    }
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                    .nav-item svg {
         | 
| 89 | 
            +
                        margin-right: 12px;
         | 
| 90 | 
            +
                    }
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                    .main-content {
         | 
| 93 | 
            +
                        flex: 1;
         | 
| 94 | 
            +
                        padding: 32px;
         | 
| 95 | 
            +
                        width: 100%;
         | 
| 96 | 
            +
                        margin: 0 auto;
         | 
| 97 | 
            +
                        overflow-y: auto;
         | 
| 98 | 
            +
                        height: 100vh;
         | 
| 99 | 
            +
                    }
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                    .main-content-inner {
         | 
| 102 | 
            +
                        max-width: 1200px;
         | 
| 103 | 
            +
                        width: 100%;
         | 
| 104 | 
            +
                        margin: 0 auto;
         | 
| 105 | 
            +
                    }
         | 
| 106 | 
            +
             | 
| 107 | 
            +
                    .tabs {
         | 
| 108 | 
            +
                        display: flex;
         | 
| 109 | 
            +
                        border-bottom: 1px solid var(--border-color);
         | 
| 110 | 
            +
                        margin-bottom: 24px;
         | 
| 111 | 
            +
                    }
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                    .tab {
         | 
| 114 | 
            +
                        padding: 12px 24px;
         | 
| 115 | 
            +
                        cursor: pointer;
         | 
| 116 | 
            +
                        position: relative;
         | 
| 117 | 
            +
                        font-weight: 500;
         | 
| 118 | 
            +
                    }
         | 
| 119 | 
            +
             | 
| 120 | 
            +
                    .tab.active {
         | 
| 121 | 
            +
                        color: var(--primary-color);
         | 
| 122 | 
            +
                    }
         | 
| 123 | 
            +
             | 
| 124 | 
            +
                    .tab.active::after {
         | 
| 125 | 
            +
                        content: '';
         | 
| 126 | 
            +
                        position: absolute;
         | 
| 127 | 
            +
                        bottom: -1px;
         | 
| 128 | 
            +
                        left: 0;
         | 
| 129 | 
            +
                        width: 100%;
         | 
| 130 | 
            +
                        height: 2px;
         | 
| 131 | 
            +
                        background-color: var(--primary-color);
         | 
| 132 | 
            +
                    }
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                    .input-container {
         | 
| 135 | 
            +
                        display: flex;
         | 
| 136 | 
            +
                        margin-bottom: 24px;
         | 
| 137 | 
            +
                        align-items: center;
         | 
| 138 | 
            +
                    }
         | 
| 139 | 
            +
             | 
| 140 | 
            +
                    .text-input {
         | 
| 141 | 
            +
                        flex: 1;
         | 
| 142 | 
            +
                        padding: 12px 16px;
         | 
| 143 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 144 | 
            +
                        border-radius: var(--radius);
         | 
| 145 | 
            +
                        font-family: 'Inter', sans-serif;
         | 
| 146 | 
            +
                        font-size: 1em;
         | 
| 147 | 
            +
                        outline: none;
         | 
| 148 | 
            +
                        transition: border-color 0.2s;
         | 
| 149 | 
            +
                    }
         | 
| 150 | 
            +
             | 
| 151 | 
            +
                    .text-input:focus {
         | 
| 152 | 
            +
                        border-color: var(--primary-color);
         | 
| 153 | 
            +
                    }
         | 
| 154 | 
            +
             | 
| 155 | 
            +
                    .btn {
         | 
| 156 | 
            +
                        background-color: var(--primary-color);
         | 
| 157 | 
            +
                        color: white;
         | 
| 158 | 
            +
                        border: none;
         | 
| 159 | 
            +
                        border-radius: var(--radius);
         | 
| 160 | 
            +
                        padding: 12px 24px;
         | 
| 161 | 
            +
                        margin-left: 12px;
         | 
| 162 | 
            +
                        cursor: pointer;
         | 
| 163 | 
            +
                        font-weight: 500;
         | 
| 164 | 
            +
                        transition: background-color 0.2s;
         | 
| 165 | 
            +
                    }
         | 
| 166 | 
            +
             | 
| 167 | 
            +
                    .btn:hover {
         | 
| 168 | 
            +
                        background-color: #4038c7;
         | 
| 169 | 
            +
                    }
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                    .icon-btn {
         | 
| 172 | 
            +
                        background-color: white;
         | 
| 173 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 174 | 
            +
                        border-radius: var(--radius);
         | 
| 175 | 
            +
                        width: 42px;
         | 
| 176 | 
            +
                        height: 42px;
         | 
| 177 | 
            +
                        display: flex;
         | 
| 178 | 
            +
                        align-items: center;
         | 
| 179 | 
            +
                        justify-content: center;
         | 
| 180 | 
            +
                        margin-left: 12px;
         | 
| 181 | 
            +
                        cursor: pointer;
         | 
| 182 | 
            +
                        transition: background-color 0.2s;
         | 
| 183 | 
            +
                    }
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                    .icon-btn:hover {
         | 
| 186 | 
            +
                        background-color: var(--light-gray);
         | 
| 187 | 
            +
                    }
         | 
| 188 | 
            +
             | 
| 189 | 
            +
                    .players-container {
         | 
| 190 | 
            +
                        display: flex;
         | 
| 191 | 
            +
                        flex-direction: column;
         | 
| 192 | 
            +
                    }
         | 
| 193 | 
            +
             | 
| 194 | 
            +
                    .players-row {
         | 
| 195 | 
            +
                        display: flex;
         | 
| 196 | 
            +
                        gap: 24px;
         | 
| 197 | 
            +
                        margin-bottom: 24px;
         | 
| 198 | 
            +
                    }
         | 
| 199 | 
            +
             | 
| 200 | 
            +
                    .player {
         | 
| 201 | 
            +
                        flex: 1;
         | 
| 202 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 203 | 
            +
                        border-radius: var(--radius);
         | 
| 204 | 
            +
                        padding: 16px;
         | 
| 205 | 
            +
                        box-shadow: var(--shadow);
         | 
| 206 | 
            +
                    }
         | 
| 207 | 
            +
             | 
| 208 | 
            +
                    .player-label {
         | 
| 209 | 
            +
                        font-weight: 600;
         | 
| 210 | 
            +
                        margin-bottom: 12px;
         | 
| 211 | 
            +
                    }
         | 
| 212 | 
            +
             | 
| 213 | 
            +
                    .audio-player {
         | 
| 214 | 
            +
                        width: 100%;
         | 
| 215 | 
            +
                        margin-bottom: 16px;
         | 
| 216 | 
            +
                    }
         | 
| 217 | 
            +
             | 
| 218 | 
            +
                    .vote-btn {
         | 
| 219 | 
            +
                        width: 100%;
         | 
| 220 | 
            +
                        padding: 12px;
         | 
| 221 | 
            +
                        background-color: white;
         | 
| 222 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 223 | 
            +
                        border-radius: var(--radius);
         | 
| 224 | 
            +
                        font-weight: 500;
         | 
| 225 | 
            +
                        cursor: pointer;
         | 
| 226 | 
            +
                        transition: all 0.2s;
         | 
| 227 | 
            +
                        position: relative;
         | 
| 228 | 
            +
                    }
         | 
| 229 | 
            +
             | 
| 230 | 
            +
                    .vote-btn:hover {
         | 
| 231 | 
            +
                        background-color: var(--light-gray);
         | 
| 232 | 
            +
                        border-color: #ccc;
         | 
| 233 | 
            +
                    }
         | 
| 234 | 
            +
             | 
| 235 | 
            +
                    .vote-btn.selected {
         | 
| 236 | 
            +
                        background-color: var(--primary-color);
         | 
| 237 | 
            +
                        color: white;
         | 
| 238 | 
            +
                        border-color: var(--primary-color);
         | 
| 239 | 
            +
                    }
         | 
| 240 | 
            +
             | 
| 241 | 
            +
                    .shortcut-key {
         | 
| 242 | 
            +
                        position: absolute;
         | 
| 243 | 
            +
                        right: 12px;
         | 
| 244 | 
            +
                        top: 50%;
         | 
| 245 | 
            +
                        transform: translateY(-50%);
         | 
| 246 | 
            +
                        background-color: var(--light-gray);
         | 
| 247 | 
            +
                        color: var(--text-color);
         | 
| 248 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 249 | 
            +
                        border-radius: 4px;
         | 
| 250 | 
            +
                        padding: 2px 6px;
         | 
| 251 | 
            +
                        font-size: 12px;
         | 
| 252 | 
            +
                        font-weight: 600;
         | 
| 253 | 
            +
                    }
         | 
| 254 | 
            +
             | 
| 255 | 
            +
                    .vote-btn.selected .shortcut-key {
         | 
| 256 | 
            +
                        background-color: rgba(255, 255, 255, 0.2);
         | 
| 257 | 
            +
                        color: white;
         | 
| 258 | 
            +
                        border-color: transparent;
         | 
| 259 | 
            +
                    }
         | 
| 260 | 
            +
             | 
| 261 | 
            +
                    .user-auth {
         | 
| 262 | 
            +
                        margin-top: auto;
         | 
| 263 | 
            +
                        display: flex;
         | 
| 264 | 
            +
                        align-items: center;
         | 
| 265 | 
            +
                        padding: 12px 16px;
         | 
| 266 | 
            +
                        border-top: 1px solid var(--border-color);
         | 
| 267 | 
            +
                        cursor: pointer;
         | 
| 268 | 
            +
                        position: relative;
         | 
| 269 | 
            +
                    }
         | 
| 270 | 
            +
             | 
| 271 | 
            +
                    .user-avatar {
         | 
| 272 | 
            +
                        width: 32px;
         | 
| 273 | 
            +
                        height: 32px;
         | 
| 274 | 
            +
                        border-radius: 50%;
         | 
| 275 | 
            +
                        background-color: var(--primary-color);
         | 
| 276 | 
            +
                        display: flex;
         | 
| 277 | 
            +
                        align-items: center;
         | 
| 278 | 
            +
                        justify-content: center;
         | 
| 279 | 
            +
                        color: white;
         | 
| 280 | 
            +
                        font-weight: 600;
         | 
| 281 | 
            +
                        margin-right: 12px;
         | 
| 282 | 
            +
                    }
         | 
| 283 | 
            +
             | 
| 284 | 
            +
                    .user-name {
         | 
| 285 | 
            +
                        font-weight: 500;
         | 
| 286 | 
            +
                        flex: 1;
         | 
| 287 | 
            +
                    }
         | 
| 288 | 
            +
             | 
| 289 | 
            +
                    .user-dropdown {
         | 
| 290 | 
            +
                        position: absolute;
         | 
| 291 | 
            +
                        bottom: 100%;
         | 
| 292 | 
            +
                        left: 0;
         | 
| 293 | 
            +
                        right: 0;
         | 
| 294 | 
            +
                        margin: 0 16px;
         | 
| 295 | 
            +
                        background-color: white;
         | 
| 296 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 297 | 
            +
                        border-radius: var(--radius);
         | 
| 298 | 
            +
                        box-shadow: var(--shadow);
         | 
| 299 | 
            +
                        z-index: 1000;
         | 
| 300 | 
            +
                        display: none;
         | 
| 301 | 
            +
                        overflow: hidden;
         | 
| 302 | 
            +
                        margin-bottom: 8px;
         | 
| 303 | 
            +
                    }
         | 
| 304 | 
            +
             | 
| 305 | 
            +
                    .user-dropdown.active {
         | 
| 306 | 
            +
                        display: block;
         | 
| 307 | 
            +
                    }
         | 
| 308 | 
            +
             | 
| 309 | 
            +
                    .dropdown-item {
         | 
| 310 | 
            +
                        padding: 12px 16px;
         | 
| 311 | 
            +
                        display: flex;
         | 
| 312 | 
            +
                        align-items: center;
         | 
| 313 | 
            +
                        transition: background-color 0.2s;
         | 
| 314 | 
            +
                        text-decoration: none;
         | 
| 315 | 
            +
                        color: var(--text-color);
         | 
| 316 | 
            +
                    }
         | 
| 317 | 
            +
             | 
| 318 | 
            +
                    .dropdown-item:hover {
         | 
| 319 | 
            +
                        background-color: var(--light-gray);
         | 
| 320 | 
            +
                    }
         | 
| 321 | 
            +
             | 
| 322 | 
            +
                    .dropdown-item svg {
         | 
| 323 | 
            +
                        margin-right: 12px;
         | 
| 324 | 
            +
                    }
         | 
| 325 | 
            +
             | 
| 326 | 
            +
                    .dropdown-divider {
         | 
| 327 | 
            +
                        height: 1px;
         | 
| 328 | 
            +
                        background-color: var(--border-color);
         | 
| 329 | 
            +
                        margin: 4px 0;
         | 
| 330 | 
            +
                    }
         | 
| 331 | 
            +
             | 
| 332 | 
            +
                    .user-auth-arrow {
         | 
| 333 | 
            +
                        transition: transform 0.2s;
         | 
| 334 | 
            +
                    }
         | 
| 335 | 
            +
             | 
| 336 | 
            +
                    .user-auth.active .user-auth-arrow {
         | 
| 337 | 
            +
                        transform: rotate(180deg);
         | 
| 338 | 
            +
                    }
         | 
| 339 | 
            +
             | 
| 340 | 
            +
                    .login-link {
         | 
| 341 | 
            +
                        display: flex;
         | 
| 342 | 
            +
                        align-items: center;
         | 
| 343 | 
            +
                        padding: 12px 16px;
         | 
| 344 | 
            +
                        border-top: 1px solid var(--border-color);
         | 
| 345 | 
            +
                        text-decoration: none;
         | 
| 346 | 
            +
                        color: var(--text-color);
         | 
| 347 | 
            +
                    }
         | 
| 348 | 
            +
             | 
| 349 | 
            +
                    .login-link:hover {
         | 
| 350 | 
            +
                        background-color: var(--light-gray);
         | 
| 351 | 
            +
                    }
         | 
| 352 | 
            +
             | 
| 353 | 
            +
                    .login-link img {
         | 
| 354 | 
            +
                        width: 24px;
         | 
| 355 | 
            +
                        height: 24px;
         | 
| 356 | 
            +
                        margin-right: 12px;
         | 
| 357 | 
            +
                    }
         | 
| 358 | 
            +
             | 
| 359 | 
            +
                    .discord-link {
         | 
| 360 | 
            +
                        display: flex;
         | 
| 361 | 
            +
                        align-items: center;
         | 
| 362 | 
            +
                        padding: 12px 16px;
         | 
| 363 | 
            +
                        border-top: 1px solid var(--border-color);
         | 
| 364 | 
            +
                        text-decoration: none;
         | 
| 365 | 
            +
                        color: var(--text-color);
         | 
| 366 | 
            +
                    }
         | 
| 367 | 
            +
             | 
| 368 | 
            +
                    .discord-link:hover {
         | 
| 369 | 
            +
                        background-color: var(--light-gray);
         | 
| 370 | 
            +
                        color: #5865F2;
         | 
| 371 | 
            +
                    }
         | 
| 372 | 
            +
             | 
| 373 | 
            +
                    .discord-link svg {
         | 
| 374 | 
            +
                        margin-right: 12px;
         | 
| 375 | 
            +
                    }
         | 
| 376 | 
            +
             | 
| 377 | 
            +
                    .sidebar-footer {
         | 
| 378 | 
            +
                        margin-top: auto;
         | 
| 379 | 
            +
                        display: flex;
         | 
| 380 | 
            +
                        flex-direction: column;
         | 
| 381 | 
            +
                    }
         | 
| 382 | 
            +
             | 
| 383 | 
            +
                    .mobile-header {
         | 
| 384 | 
            +
                        display: none;
         | 
| 385 | 
            +
                        align-items: center;
         | 
| 386 | 
            +
                        justify-content: space-between;
         | 
| 387 | 
            +
                        padding: 16px;
         | 
| 388 | 
            +
                        border-bottom: 1px solid var(--border-color);
         | 
| 389 | 
            +
                    }
         | 
| 390 | 
            +
             | 
| 391 | 
            +
                    .hamburger-menu {
         | 
| 392 | 
            +
                        width: 24px;
         | 
| 393 | 
            +
                        height: 24px;
         | 
| 394 | 
            +
                        cursor: pointer;
         | 
| 395 | 
            +
                    }
         | 
| 396 | 
            +
             | 
| 397 | 
            +
                    .current-page {
         | 
| 398 | 
            +
                        font-weight: 600;
         | 
| 399 | 
            +
                        font-size: 18px;
         | 
| 400 | 
            +
                    }
         | 
| 401 | 
            +
             | 
| 402 | 
            +
                    .backdrop {
         | 
| 403 | 
            +
                        display: none;
         | 
| 404 | 
            +
                        position: fixed;
         | 
| 405 | 
            +
                        top: 0;
         | 
| 406 | 
            +
                        left: 0;
         | 
| 407 | 
            +
                        width: 100%;
         | 
| 408 | 
            +
                        height: 100%;
         | 
| 409 | 
            +
                        background-color: rgba(0, 0, 0, 0.5);
         | 
| 410 | 
            +
                        -webkit-backdrop-filter: blur(3px);
         | 
| 411 | 
            +
                        backdrop-filter: blur(3px);
         | 
| 412 | 
            +
                        z-index: 999;
         | 
| 413 | 
            +
                        opacity: 0;
         | 
| 414 | 
            +
                        transition: opacity 0.3s ease-in-out;
         | 
| 415 | 
            +
                    }
         | 
| 416 | 
            +
             | 
| 417 | 
            +
                    .backdrop.active {
         | 
| 418 | 
            +
                        display: block;
         | 
| 419 | 
            +
                        opacity: 1;
         | 
| 420 | 
            +
                    }
         | 
| 421 | 
            +
             | 
| 422 | 
            +
                    .close-sidebar {
         | 
| 423 | 
            +
                        position: absolute;
         | 
| 424 | 
            +
                        top: 16px;
         | 
| 425 | 
            +
                        right: 16px;
         | 
| 426 | 
            +
                        width: 24px;
         | 
| 427 | 
            +
                        height: 24px;
         | 
| 428 | 
            +
                        cursor: pointer;
         | 
| 429 | 
            +
                        display: none;
         | 
| 430 | 
            +
                    }
         | 
| 431 | 
            +
             | 
| 432 | 
            +
                    /* Toast styles */
         | 
| 433 | 
            +
                    .toast-container {
         | 
| 434 | 
            +
                        position: fixed;
         | 
| 435 | 
            +
                        bottom: 24px;
         | 
| 436 | 
            +
                        right: 24px;
         | 
| 437 | 
            +
                        z-index: 9999;
         | 
| 438 | 
            +
                        display: flex;
         | 
| 439 | 
            +
                        flex-direction: column;
         | 
| 440 | 
            +
                        gap: 8px;
         | 
| 441 | 
            +
                        max-width: 350px;
         | 
| 442 | 
            +
                    }
         | 
| 443 | 
            +
             | 
| 444 | 
            +
                    .toast {
         | 
| 445 | 
            +
                        display: flex;
         | 
| 446 | 
            +
                        align-items: center;
         | 
| 447 | 
            +
                        padding: 12px 16px;
         | 
| 448 | 
            +
                        border-radius: 8px;
         | 
| 449 | 
            +
                        background-color: white;
         | 
| 450 | 
            +
                        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
         | 
| 451 | 
            +
                        animation: slideIn 0.3s ease-out forwards;
         | 
| 452 | 
            +
                        position: relative;
         | 
| 453 | 
            +
                        overflow: hidden;
         | 
| 454 | 
            +
                    }
         | 
| 455 | 
            +
             | 
| 456 | 
            +
                    .toast.slide-out {
         | 
| 457 | 
            +
                        animation: slideOut 0.3s ease-in forwards;
         | 
| 458 | 
            +
                    }
         | 
| 459 | 
            +
             | 
| 460 | 
            +
                    .toast-icon {
         | 
| 461 | 
            +
                        margin-right: 10px;
         | 
| 462 | 
            +
                        flex-shrink: 0;
         | 
| 463 | 
            +
                        display: flex;
         | 
| 464 | 
            +
                        align-items: center;
         | 
| 465 | 
            +
                        justify-content: center;
         | 
| 466 | 
            +
                    }
         | 
| 467 | 
            +
             | 
| 468 | 
            +
                    .toast-content {
         | 
| 469 | 
            +
                        flex: 1;
         | 
| 470 | 
            +
                        font-size: 14px;
         | 
| 471 | 
            +
                        font-weight: 500;
         | 
| 472 | 
            +
                        line-height: 1.4;
         | 
| 473 | 
            +
                    }
         | 
| 474 | 
            +
             | 
| 475 | 
            +
                    .toast-close {
         | 
| 476 | 
            +
                        margin-left: 10px;
         | 
| 477 | 
            +
                        cursor: pointer;
         | 
| 478 | 
            +
                        opacity: 0.5;
         | 
| 479 | 
            +
                        transition: opacity 0.2s;
         | 
| 480 | 
            +
                        flex-shrink: 0;
         | 
| 481 | 
            +
                        border-radius: 50%;
         | 
| 482 | 
            +
                        width: 20px;
         | 
| 483 | 
            +
                        height: 20px;
         | 
| 484 | 
            +
                        display: flex;
         | 
| 485 | 
            +
                        align-items: center;
         | 
| 486 | 
            +
                        justify-content: center;
         | 
| 487 | 
            +
                    }
         | 
| 488 | 
            +
             | 
| 489 | 
            +
                    .toast-close:hover {
         | 
| 490 | 
            +
                        opacity: 1;
         | 
| 491 | 
            +
                        background-color: rgba(0, 0, 0, 0.05);
         | 
| 492 | 
            +
                    }
         | 
| 493 | 
            +
             | 
| 494 | 
            +
                    .toast-progress {
         | 
| 495 | 
            +
                        position: absolute;
         | 
| 496 | 
            +
                        bottom: 0;
         | 
| 497 | 
            +
                        left: 0;
         | 
| 498 | 
            +
                        height: 2px;
         | 
| 499 | 
            +
                        width: 100%;
         | 
| 500 | 
            +
                        transform-origin: left;
         | 
| 501 | 
            +
                    }
         | 
| 502 | 
            +
             | 
| 503 | 
            +
                    .toast.info {
         | 
| 504 | 
            +
                        border-left-color: var(--info-color);
         | 
| 505 | 
            +
                    }
         | 
| 506 | 
            +
             | 
| 507 | 
            +
                    .toast.info .toast-icon {
         | 
| 508 | 
            +
                        color: var(--info-color);
         | 
| 509 | 
            +
                    }
         | 
| 510 | 
            +
             | 
| 511 | 
            +
                    .toast.info .toast-progress {
         | 
| 512 | 
            +
                        background-color: var(--info-color);
         | 
| 513 | 
            +
                    }
         | 
| 514 | 
            +
             | 
| 515 | 
            +
                    .toast.success .toast-icon {
         | 
| 516 | 
            +
                        color: var(--success-color);
         | 
| 517 | 
            +
                    }
         | 
| 518 | 
            +
             | 
| 519 | 
            +
                    .toast.success .toast-progress {
         | 
| 520 | 
            +
                        background-color: var(--success-color);
         | 
| 521 | 
            +
                    }
         | 
| 522 | 
            +
             | 
| 523 | 
            +
                    .toast.warning .toast-icon {
         | 
| 524 | 
            +
                        color: var(--warning-color);
         | 
| 525 | 
            +
                    }
         | 
| 526 | 
            +
             | 
| 527 | 
            +
                    .toast.warning .toast-progress {
         | 
| 528 | 
            +
                        background-color: var(--warning-color);
         | 
| 529 | 
            +
                    }
         | 
| 530 | 
            +
             | 
| 531 | 
            +
                    .toast.error .toast-icon {
         | 
| 532 | 
            +
                        color: var(--error-color);
         | 
| 533 | 
            +
                    }
         | 
| 534 | 
            +
             | 
| 535 | 
            +
                    .toast.error .toast-progress {
         | 
| 536 | 
            +
                        background-color: var(--error-color);
         | 
| 537 | 
            +
                    }
         | 
| 538 | 
            +
             | 
| 539 | 
            +
                    @keyframes slideIn {
         | 
| 540 | 
            +
                        from {
         | 
| 541 | 
            +
                            transform: translateX(100%);
         | 
| 542 | 
            +
                            opacity: 0;
         | 
| 543 | 
            +
                        }
         | 
| 544 | 
            +
             | 
| 545 | 
            +
                        to {
         | 
| 546 | 
            +
                            transform: translateX(0);
         | 
| 547 | 
            +
                            opacity: 1;
         | 
| 548 | 
            +
                        }
         | 
| 549 | 
            +
                    }
         | 
| 550 | 
            +
             | 
| 551 | 
            +
                    @keyframes slideOut {
         | 
| 552 | 
            +
                        from {
         | 
| 553 | 
            +
                            transform: translateX(0);
         | 
| 554 | 
            +
                            opacity: 1;
         | 
| 555 | 
            +
                        }
         | 
| 556 | 
            +
             | 
| 557 | 
            +
                        to {
         | 
| 558 | 
            +
                            transform: translateX(100%);
         | 
| 559 | 
            +
                            opacity: 0;
         | 
| 560 | 
            +
                        }
         | 
| 561 | 
            +
                    }
         | 
| 562 | 
            +
             | 
| 563 | 
            +
                    @keyframes shrink {
         | 
| 564 | 
            +
                        from {
         | 
| 565 | 
            +
                            transform: scaleX(1);
         | 
| 566 | 
            +
                        }
         | 
| 567 | 
            +
                        to {
         | 
| 568 | 
            +
                            transform: scaleX(0);
         | 
| 569 | 
            +
                        }
         | 
| 570 | 
            +
                    }
         | 
| 571 | 
            +
             | 
| 572 | 
            +
                    @media (max-width: 768px) {
         | 
| 573 | 
            +
                        body {
         | 
| 574 | 
            +
                            flex-direction: column;
         | 
| 575 | 
            +
                        }
         | 
| 576 | 
            +
             | 
| 577 | 
            +
                        .mobile-header {
         | 
| 578 | 
            +
                            display: flex;
         | 
| 579 | 
            +
                            flex-shrink: 0;
         | 
| 580 | 
            +
                        }
         | 
| 581 | 
            +
             | 
| 582 | 
            +
                        .sidebar {
         | 
| 583 | 
            +
                            position: fixed;
         | 
| 584 | 
            +
                            top: 0;
         | 
| 585 | 
            +
                            left: 0;
         | 
| 586 | 
            +
                            width: 280px;
         | 
| 587 | 
            +
                            border-right: 1px solid var(--border-color);
         | 
| 588 | 
            +
                            padding: 24px 16px;
         | 
| 589 | 
            +
                            height: 100vh;
         | 
| 590 | 
            +
                            transform: translateX(-100%);
         | 
| 591 | 
            +
                        }
         | 
| 592 | 
            +
             | 
| 593 | 
            +
                        .sidebar.active {
         | 
| 594 | 
            +
                            transform: translateX(0);
         | 
| 595 | 
            +
                        }
         | 
| 596 | 
            +
             | 
| 597 | 
            +
                        .close-sidebar {
         | 
| 598 | 
            +
                            display: block;
         | 
| 599 | 
            +
                        }
         | 
| 600 | 
            +
             | 
| 601 | 
            +
                        .logo {
         | 
| 602 | 
            +
                            display: block;
         | 
| 603 | 
            +
                        }
         | 
| 604 | 
            +
             | 
| 605 | 
            +
                        .players-container {
         | 
| 606 | 
            +
                            flex-direction: column;
         | 
| 607 | 
            +
                        }
         | 
| 608 | 
            +
             | 
| 609 | 
            +
                        .main-content {
         | 
| 610 | 
            +
                            height: calc(100vh - 57px);
         | 
| 611 | 
            +
                            overflow-y: auto;
         | 
| 612 | 
            +
                        }
         | 
| 613 | 
            +
             | 
| 614 | 
            +
                        .toast-container {
         | 
| 615 | 
            +
                            bottom: auto;
         | 
| 616 | 
            +
                            top: 16px;
         | 
| 617 | 
            +
                            right: 16px;
         | 
| 618 | 
            +
                            left: 16px;
         | 
| 619 | 
            +
                            max-width: none;
         | 
| 620 | 
            +
                        }
         | 
| 621 | 
            +
                        
         | 
| 622 | 
            +
                        @keyframes slideIn {
         | 
| 623 | 
            +
                            from {
         | 
| 624 | 
            +
                                transform: translateY(-100%);
         | 
| 625 | 
            +
                                opacity: 0;
         | 
| 626 | 
            +
                            }
         | 
| 627 | 
            +
                        
         | 
| 628 | 
            +
                            to {
         | 
| 629 | 
            +
                                transform: translateY(0);
         | 
| 630 | 
            +
                                opacity: 1;
         | 
| 631 | 
            +
                            }
         | 
| 632 | 
            +
                        }
         | 
| 633 | 
            +
                        
         | 
| 634 | 
            +
                        @keyframes slideOut {
         | 
| 635 | 
            +
                            from {
         | 
| 636 | 
            +
                                transform: translateY(0);
         | 
| 637 | 
            +
                                opacity: 1;
         | 
| 638 | 
            +
                            }
         | 
| 639 | 
            +
                        
         | 
| 640 | 
            +
                            to {
         | 
| 641 | 
            +
                                transform: translateY(-100%);
         | 
| 642 | 
            +
                                opacity: 0;
         | 
| 643 | 
            +
                            }
         | 
| 644 | 
            +
                        }
         | 
| 645 | 
            +
                    }
         | 
| 646 | 
            +
             | 
| 647 | 
            +
                    ::-webkit-scrollbar {
         | 
| 648 | 
            +
                        width: 8px;
         | 
| 649 | 
            +
                        height: 8px;
         | 
| 650 | 
            +
                    }
         | 
| 651 | 
            +
             | 
| 652 | 
            +
                    ::-webkit-scrollbar-track {
         | 
| 653 | 
            +
                        background: var(--light-gray);
         | 
| 654 | 
            +
                        border-radius: 4px;
         | 
| 655 | 
            +
                    }
         | 
| 656 | 
            +
             | 
| 657 | 
            +
                    ::-webkit-scrollbar-thumb {
         | 
| 658 | 
            +
                        background: rgba(120, 120, 120, 0.5);
         | 
| 659 | 
            +
                        border-radius: 4px;
         | 
| 660 | 
            +
                        transition: background 0.2s ease;
         | 
| 661 | 
            +
                    }
         | 
| 662 | 
            +
             | 
| 663 | 
            +
                    ::-webkit-scrollbar-thumb:hover {
         | 
| 664 | 
            +
                        background: rgba(100, 100, 100, 0.7);
         | 
| 665 | 
            +
                    }
         | 
| 666 | 
            +
             | 
| 667 | 
            +
                    /* Firefox scrollbar */
         | 
| 668 | 
            +
                    * {
         | 
| 669 | 
            +
                        scrollbar-width: thin;
         | 
| 670 | 
            +
                        scrollbar-color: rgba(120, 120, 120, 0.5) var(--light-gray);
         | 
| 671 | 
            +
                    }
         | 
| 672 | 
            +
             | 
| 673 | 
            +
                    /* For Edge and other browsers */
         | 
| 674 | 
            +
                    ::-webkit-scrollbar-corner {
         | 
| 675 | 
            +
                        background: var(--light-gray);
         | 
| 676 | 
            +
                    }
         | 
| 677 | 
            +
             | 
| 678 | 
            +
                    /* Ensure smooth scrolling */
         | 
| 679 | 
            +
                    html {
         | 
| 680 | 
            +
                        scroll-behavior: smooth;
         | 
| 681 | 
            +
                    }
         | 
| 682 | 
            +
             | 
| 683 | 
            +
                    /* Dark mode styles */
         | 
| 684 | 
            +
                    @media (prefers-color-scheme: dark) {
         | 
| 685 | 
            +
                        :root {
         | 
| 686 | 
            +
                            --primary-color: #6c63ff;
         | 
| 687 | 
            +
                            --secondary-color: #2d2b38;
         | 
| 688 | 
            +
                            --text-color: #e0e0e0;
         | 
| 689 | 
            +
                            --light-gray: #1e1e24;
         | 
| 690 | 
            +
                            --border-color: #3a3a45;
         | 
| 691 | 
            +
                            --shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
         | 
| 692 | 
            +
                            --success-color: #10b981;
         | 
| 693 | 
            +
                            --info-color: #60a5fa;
         | 
| 694 | 
            +
                            --warning-color: #f59e0b;
         | 
| 695 | 
            +
                            --error-color: #ef4444;
         | 
| 696 | 
            +
                        }
         | 
| 697 | 
            +
             | 
| 698 | 
            +
                        body {
         | 
| 699 | 
            +
                            background-color: #121218;
         | 
| 700 | 
            +
                            color: var(--text-color);
         | 
| 701 | 
            +
                        }
         | 
| 702 | 
            +
             | 
| 703 | 
            +
                        .sidebar {
         | 
| 704 | 
            +
                            background-color: var(--light-gray);
         | 
| 705 | 
            +
                            border-right-color: var(--border-color);
         | 
| 706 | 
            +
                        }
         | 
| 707 | 
            +
             | 
| 708 | 
            +
                        .nav-item.active {
         | 
| 709 | 
            +
                            background-color: rgba(108, 99, 255, 0.2);
         | 
| 710 | 
            +
                        }
         | 
| 711 | 
            +
             | 
| 712 | 
            +
                        .nav-item:hover:not(.active) {
         | 
| 713 | 
            +
                            background-color: rgba(255, 255, 255, 0.05);
         | 
| 714 | 
            +
                        }
         | 
| 715 | 
            +
             | 
| 716 | 
            +
                        .text-input,
         | 
| 717 | 
            +
                        .select-input,
         | 
| 718 | 
            +
                        .textarea {
         | 
| 719 | 
            +
                            background-color: var(--light-gray);
         | 
| 720 | 
            +
                            color: var(--text-color);
         | 
| 721 | 
            +
                            border-color: var(--border-color);
         | 
| 722 | 
            +
                        }
         | 
| 723 | 
            +
             | 
| 724 | 
            +
                        .card {
         | 
| 725 | 
            +
                            background-color: var(--light-gray);
         | 
| 726 | 
            +
                            border-color: var(--border-color);
         | 
| 727 | 
            +
                        }
         | 
| 728 | 
            +
             | 
| 729 | 
            +
                        .tab.active::after {
         | 
| 730 | 
            +
                            background-color: var(--primary-color);
         | 
| 731 | 
            +
                        }
         | 
| 732 | 
            +
             | 
| 733 | 
            +
                        /* Fix vote buttons in dark mode */
         | 
| 734 | 
            +
                        .vote-btn {
         | 
| 735 | 
            +
                            background-color: var(--light-gray);
         | 
| 736 | 
            +
                            color: var(--text-color);
         | 
| 737 | 
            +
                            border-color: var(--border-color);
         | 
| 738 | 
            +
                            border-radius: var(--radius);
         | 
| 739 | 
            +
                        }
         | 
| 740 | 
            +
             | 
| 741 | 
            +
                        .vote-btn:hover {
         | 
| 742 | 
            +
                            background-color: rgba(255, 255, 255, 0.1);
         | 
| 743 | 
            +
                            border-color: var(--border-color);
         | 
| 744 | 
            +
                        }
         | 
| 745 | 
            +
             | 
| 746 | 
            +
                        .vote-btn.selected {
         | 
| 747 | 
            +
                            background-color: var(--primary-color);
         | 
| 748 | 
            +
                            color: white;
         | 
| 749 | 
            +
                            border-color: var(--primary-color);
         | 
| 750 | 
            +
                        }
         | 
| 751 | 
            +
             | 
| 752 | 
            +
                        .shortcut-key {
         | 
| 753 | 
            +
                            background-color: rgba(255, 255, 255, 0.1);
         | 
| 754 | 
            +
                            color: var(--text-color);
         | 
| 755 | 
            +
                            border-color: var(--border-color);
         | 
| 756 | 
            +
                        }
         | 
| 757 | 
            +
             | 
| 758 | 
            +
                        .vote-btn.selected .shortcut-key {
         | 
| 759 | 
            +
                            background-color: rgba(255, 255, 255, 0.2);
         | 
| 760 | 
            +
                            color: white;
         | 
| 761 | 
            +
                            border-color: transparent;
         | 
| 762 | 
            +
                        }
         | 
| 763 | 
            +
             | 
| 764 | 
            +
                        /* Fix loading state in dark mode */
         | 
| 765 | 
            +
                        .vote-btn:disabled,
         | 
| 766 | 
            +
                        .vote-btn.loading {
         | 
| 767 | 
            +
                            background-color: var(--light-gray);
         | 
| 768 | 
            +
                            border-radius: var(--radius);
         | 
| 769 | 
            +
                        }
         | 
| 770 | 
            +
             | 
| 771 | 
            +
                        .vote-loader {
         | 
| 772 | 
            +
                            background-color: var(--light-gray);
         | 
| 773 | 
            +
                            border-radius: var(--radius);
         | 
| 774 | 
            +
                        }
         | 
| 775 | 
            +
             | 
| 776 | 
            +
                        .vote-spinner {
         | 
| 777 | 
            +
                            border-color: rgba(108, 99, 255, 0.3);
         | 
| 778 | 
            +
                            border-top-color: var(--primary-color);
         | 
| 779 | 
            +
                        }
         | 
| 780 | 
            +
             | 
| 781 | 
            +
                        .toast {
         | 
| 782 | 
            +
                            background-color: var(--light-gray);
         | 
| 783 | 
            +
                            box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
         | 
| 784 | 
            +
                        }
         | 
| 785 | 
            +
             | 
| 786 | 
            +
                        .toast-close:hover {
         | 
| 787 | 
            +
                            background-color: rgba(255, 255, 255, 0.1);
         | 
| 788 | 
            +
                        }
         | 
| 789 | 
            +
             | 
| 790 | 
            +
                        ::-webkit-scrollbar-track {
         | 
| 791 | 
            +
                            background: var(--secondary-color);
         | 
| 792 | 
            +
                        }
         | 
| 793 | 
            +
             | 
| 794 | 
            +
                        ::-webkit-scrollbar-thumb {
         | 
| 795 | 
            +
                            background: rgba(180, 180, 180, 0.5);
         | 
| 796 | 
            +
                        }
         | 
| 797 | 
            +
             | 
| 798 | 
            +
                        ::-webkit-scrollbar-thumb:hover {
         | 
| 799 | 
            +
                            background: rgba(200, 200, 200, 0.7);
         | 
| 800 | 
            +
                        }
         | 
| 801 | 
            +
             | 
| 802 | 
            +
                        * {
         | 
| 803 | 
            +
                            scrollbar-color: rgba(180, 180, 180, 0.5) var(--secondary-color);
         | 
| 804 | 
            +
                        }
         | 
| 805 | 
            +
             | 
| 806 | 
            +
                        ::-webkit-scrollbar-corner {
         | 
| 807 | 
            +
                            background: var(--secondary-color);
         | 
| 808 | 
            +
                        }
         | 
| 809 | 
            +
             | 
| 810 | 
            +
                        /* Dark mode loading overlay */
         | 
| 811 | 
            +
                        .loading-overlay {
         | 
| 812 | 
            +
                            background-color: rgba(18, 18, 24, 0.8);
         | 
| 813 | 
            +
                        }
         | 
| 814 | 
            +
             | 
| 815 | 
            +
                        /* Dark mode spinner */
         | 
| 816 | 
            +
                        .loader-spinner {
         | 
| 817 | 
            +
                            border-color: rgba(108, 99, 255, 0.2);
         | 
| 818 | 
            +
                            border-top-color: var(--primary-color);
         | 
| 819 | 
            +
                        }
         | 
| 820 | 
            +
             | 
| 821 | 
            +
                        /* Dark mode user dropdown */
         | 
| 822 | 
            +
                        .user-dropdown {
         | 
| 823 | 
            +
                            background-color: var(--light-gray);
         | 
| 824 | 
            +
                            border-color: var(--border-color);
         | 
| 825 | 
            +
                        }
         | 
| 826 | 
            +
             | 
| 827 | 
            +
                        .dropdown-item {
         | 
| 828 | 
            +
                            color: var(--text-color);
         | 
| 829 | 
            +
                        }
         | 
| 830 | 
            +
             | 
| 831 | 
            +
                        .dropdown-item:hover {
         | 
| 832 | 
            +
                            background-color: rgba(108, 99, 255, 0.1);
         | 
| 833 | 
            +
                        }
         | 
| 834 | 
            +
             | 
| 835 | 
            +
                        .dropdown-divider {
         | 
| 836 | 
            +
                            background-color: var(--border-color);
         | 
| 837 | 
            +
                        }
         | 
| 838 | 
            +
             | 
| 839 | 
            +
                        .user-avatar {
         | 
| 840 | 
            +
                            background-color: var(--primary-color);
         | 
| 841 | 
            +
                        }
         | 
| 842 | 
            +
                    }
         | 
| 843 | 
            +
             | 
| 844 | 
            +
                    /* Loading Overlay */
         | 
| 845 | 
            +
                    .loading-overlay {
         | 
| 846 | 
            +
                        position: fixed;
         | 
| 847 | 
            +
                        top: 0;
         | 
| 848 | 
            +
                        left: 0;
         | 
| 849 | 
            +
                        width: 100%;
         | 
| 850 | 
            +
                        height: 100%;
         | 
| 851 | 
            +
                        background-color: rgba(255, 255, 255, 0.8);
         | 
| 852 | 
            +
                        display: flex;
         | 
| 853 | 
            +
                        justify-content: center;
         | 
| 854 | 
            +
                        align-items: center;
         | 
| 855 | 
            +
                        z-index: 9999;
         | 
| 856 | 
            +
                        opacity: 0;
         | 
| 857 | 
            +
                        visibility: hidden;
         | 
| 858 | 
            +
                        transition: opacity 0.3s ease, visibility 0.3s ease;
         | 
| 859 | 
            +
                    }
         | 
| 860 | 
            +
             | 
| 861 | 
            +
                    .loading-overlay.active {
         | 
| 862 | 
            +
                        opacity: 1;
         | 
| 863 | 
            +
                        visibility: visible;
         | 
| 864 | 
            +
                    }
         | 
| 865 | 
            +
             | 
| 866 | 
            +
                    .loader-spinner {
         | 
| 867 | 
            +
                        width: 50px;
         | 
| 868 | 
            +
                        height: 50px;
         | 
| 869 | 
            +
                        border: 3px solid rgba(80, 70, 229, 0.3);
         | 
| 870 | 
            +
                        border-radius: 50%;
         | 
| 871 | 
            +
                        border-top-color: var(--primary-color);
         | 
| 872 | 
            +
                        animation: spin 1s ease-in-out infinite;
         | 
| 873 | 
            +
                    }
         | 
| 874 | 
            +
             | 
| 875 | 
            +
                    @keyframes spin {
         | 
| 876 | 
            +
                        to {
         | 
| 877 | 
            +
                            transform: rotate(360deg);
         | 
| 878 | 
            +
                        }
         | 
| 879 | 
            +
                    }
         | 
| 880 | 
            +
             | 
| 881 | 
            +
                    /* Login tip overlay */
         | 
| 882 | 
            +
                    .login-tip-overlay {
         | 
| 883 | 
            +
                        position: absolute;
         | 
| 884 | 
            +
                        background-color: white;
         | 
| 885 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 886 | 
            +
                        border-radius: var(--radius);
         | 
| 887 | 
            +
                        box-shadow: var(--shadow);
         | 
| 888 | 
            +
                        padding: 16px;
         | 
| 889 | 
            +
                        z-index: 1000;
         | 
| 890 | 
            +
                        width: 280px;
         | 
| 891 | 
            +
                        display: none;
         | 
| 892 | 
            +
                    }
         | 
| 893 | 
            +
             | 
| 894 | 
            +
                    .login-tip-overlay.show {
         | 
| 895 | 
            +
                        display: block;
         | 
| 896 | 
            +
                    }
         | 
| 897 | 
            +
             | 
| 898 | 
            +
                    .login-tip-content {
         | 
| 899 | 
            +
                        font-size: 14px;
         | 
| 900 | 
            +
                        margin-bottom: 12px;
         | 
| 901 | 
            +
                    }
         | 
| 902 | 
            +
             | 
| 903 | 
            +
                    .login-tip-actions {
         | 
| 904 | 
            +
                        display: flex;
         | 
| 905 | 
            +
                        justify-content: space-between;
         | 
| 906 | 
            +
                    }
         | 
| 907 | 
            +
             | 
| 908 | 
            +
                    .login-tip-close {
         | 
| 909 | 
            +
                        font-size: 13px;
         | 
| 910 | 
            +
                        color: var(--text-color);
         | 
| 911 | 
            +
                        opacity: 0.7;
         | 
| 912 | 
            +
                        cursor: pointer;
         | 
| 913 | 
            +
                        background: none;
         | 
| 914 | 
            +
                        border: none;
         | 
| 915 | 
            +
                        padding: 0;
         | 
| 916 | 
            +
                    }
         | 
| 917 | 
            +
             | 
| 918 | 
            +
                    .login-now-btn {
         | 
| 919 | 
            +
                        font-size: 13px;
         | 
| 920 | 
            +
                        background-color: var(--primary-color);
         | 
| 921 | 
            +
                        color: white;
         | 
| 922 | 
            +
                        border: none;
         | 
| 923 | 
            +
                        border-radius: 4px;
         | 
| 924 | 
            +
                        padding: 6px 12px;
         | 
| 925 | 
            +
                        cursor: pointer;
         | 
| 926 | 
            +
                        text-decoration: none;
         | 
| 927 | 
            +
                    }
         | 
| 928 | 
            +
             | 
| 929 | 
            +
                    .login-tip-overlay[data-popper-placement^='top'] .login-tip-caret {
         | 
| 930 | 
            +
                        position: absolute;
         | 
| 931 | 
            +
                        bottom: -8px;
         | 
| 932 | 
            +
                        left: 50%;
         | 
| 933 | 
            +
                        transform: translateX(-50%);
         | 
| 934 | 
            +
                        width: 16px;
         | 
| 935 | 
            +
                        height: 8px;
         | 
| 936 | 
            +
                        overflow: hidden;
         | 
| 937 | 
            +
                    }
         | 
| 938 | 
            +
             | 
| 939 | 
            +
                    .login-tip-overlay[data-popper-placement^='top'] .login-tip-caret:after {
         | 
| 940 | 
            +
                        content: '';
         | 
| 941 | 
            +
                        position: absolute;
         | 
| 942 | 
            +
                        width: 12px;
         | 
| 943 | 
            +
                        height: 12px;
         | 
| 944 | 
            +
                        background: white;
         | 
| 945 | 
            +
                        border-right: 1px solid var(--border-color);
         | 
| 946 | 
            +
                        border-bottom: 1px solid var(--border-color);
         | 
| 947 | 
            +
                        top: -6px;
         | 
| 948 | 
            +
                        left: 2px;
         | 
| 949 | 
            +
                        transform: rotate(45deg);
         | 
| 950 | 
            +
                    }
         | 
| 951 | 
            +
             | 
| 952 | 
            +
                    @media (prefers-color-scheme: dark) {
         | 
| 953 | 
            +
                        .login-tip-overlay {
         | 
| 954 | 
            +
                            background-color: var(--light-gray);
         | 
| 955 | 
            +
                            border-color: var(--border-color);
         | 
| 956 | 
            +
                        }
         | 
| 957 | 
            +
                        
         | 
| 958 | 
            +
                        .login-tip-overlay[data-popper-placement^='top'] .login-tip-caret:after {
         | 
| 959 | 
            +
                            background: var(--light-gray);
         | 
| 960 | 
            +
                            border-color: var(--border-color);
         | 
| 961 | 
            +
                        }
         | 
| 962 | 
            +
                        
         | 
| 963 | 
            +
                        .login-tip-close {
         | 
| 964 | 
            +
                            color: var(--text-color);
         | 
| 965 | 
            +
                        }
         | 
| 966 | 
            +
                    }
         | 
| 967 | 
            +
             | 
| 968 | 
            +
                    /* Mobile login banner */
         | 
| 969 | 
            +
                    .login-banner {
         | 
| 970 | 
            +
                        position: fixed;
         | 
| 971 | 
            +
                        top: 50%;
         | 
| 972 | 
            +
                        left: 50%;
         | 
| 973 | 
            +
                        transform: translate(-50%, -50%);
         | 
| 974 | 
            +
                        width: 85%;
         | 
| 975 | 
            +
                        max-width: 320px;
         | 
| 976 | 
            +
                        background-color: white;
         | 
| 977 | 
            +
                        color: var(--text-color);
         | 
| 978 | 
            +
                        border-radius: var(--radius);
         | 
| 979 | 
            +
                        box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
         | 
| 980 | 
            +
                        padding: 20px;
         | 
| 981 | 
            +
                        display: none;
         | 
| 982 | 
            +
                        z-index: 9998;
         | 
| 983 | 
            +
                        text-align: center;
         | 
| 984 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 985 | 
            +
                    }
         | 
| 986 | 
            +
             | 
| 987 | 
            +
                    .login-banner-content {
         | 
| 988 | 
            +
                        margin-bottom: 16px;
         | 
| 989 | 
            +
                        font-size: 15px;
         | 
| 990 | 
            +
                        font-weight: 500;
         | 
| 991 | 
            +
                    }
         | 
| 992 | 
            +
             | 
| 993 | 
            +
                    .login-banner-actions {
         | 
| 994 | 
            +
                        display: flex;
         | 
| 995 | 
            +
                        flex-direction: row;
         | 
| 996 | 
            +
                        justify-content: space-between;
         | 
| 997 | 
            +
                        gap: 12px;
         | 
| 998 | 
            +
                        align-items: center;
         | 
| 999 | 
            +
                        margin-top: 20px;
         | 
| 1000 | 
            +
                    }
         | 
| 1001 | 
            +
             | 
| 1002 | 
            +
                    .login-banner-close {
         | 
| 1003 | 
            +
                        background: none;
         | 
| 1004 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 1005 | 
            +
                        color: var(--text-color);
         | 
| 1006 | 
            +
                        font-size: 14px;
         | 
| 1007 | 
            +
                        cursor: pointer;
         | 
| 1008 | 
            +
                        padding: 10px 16px;
         | 
| 1009 | 
            +
                        border-radius: var(--radius);
         | 
| 1010 | 
            +
                        flex: 1;
         | 
| 1011 | 
            +
                        font-weight: 500;
         | 
| 1012 | 
            +
                    }
         | 
| 1013 | 
            +
             | 
| 1014 | 
            +
                    .login-banner-btn {
         | 
| 1015 | 
            +
                        background-color: var(--primary-color);
         | 
| 1016 | 
            +
                        color: white;
         | 
| 1017 | 
            +
                        border: none;
         | 
| 1018 | 
            +
                        border-radius: var(--radius);
         | 
| 1019 | 
            +
                        padding: 10px 16px;
         | 
| 1020 | 
            +
                        cursor: pointer;
         | 
| 1021 | 
            +
                        font-weight: 500;
         | 
| 1022 | 
            +
                        text-decoration: none;
         | 
| 1023 | 
            +
                        flex: 1;
         | 
| 1024 | 
            +
                        text-align: center;
         | 
| 1025 | 
            +
                    }
         | 
| 1026 | 
            +
                    
         | 
| 1027 | 
            +
                    @media (prefers-color-scheme: dark) {
         | 
| 1028 | 
            +
                        .login-banner {
         | 
| 1029 | 
            +
                            background-color: var(--light-gray);
         | 
| 1030 | 
            +
                            border-color: var(--border-color);
         | 
| 1031 | 
            +
                        }
         | 
| 1032 | 
            +
                        
         | 
| 1033 | 
            +
                        .login-banner-close {
         | 
| 1034 | 
            +
                            border-color: var(--border-color);
         | 
| 1035 | 
            +
                            background-color: rgba(255, 255, 255, 0.05);
         | 
| 1036 | 
            +
                        }
         | 
| 1037 | 
            +
                    }
         | 
| 1038 | 
            +
                </style>
         | 
| 1039 | 
            +
            </head>
         | 
| 1040 | 
            +
             | 
| 1041 | 
            +
            <body>
         | 
| 1042 | 
            +
                <!-- Loading Overlay -->
         | 
| 1043 | 
            +
                <div id="loading-overlay" class="loading-overlay">
         | 
| 1044 | 
            +
                    <div class="loader-spinner"></div>
         | 
| 1045 | 
            +
                </div>
         | 
| 1046 | 
            +
             | 
| 1047 | 
            +
                <div class="mobile-header">
         | 
| 1048 | 
            +
                    <div class="hamburger-menu" onclick="toggleSidebar()">
         | 
| 1049 | 
            +
                        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
         | 
| 1050 | 
            +
                            <path d="M4 6H20M4 12H20M4 18H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
         | 
| 1051 | 
            +
                        </svg>
         | 
| 1052 | 
            +
                    </div>
         | 
| 1053 | 
            +
                    <div class="current-page">{% block current_page %}Arena{% endblock %}</div>
         | 
| 1054 | 
            +
                </div>
         | 
| 1055 | 
            +
             | 
| 1056 | 
            +
                <div class="backdrop" onclick="toggleSidebar()"></div>
         | 
| 1057 | 
            +
             | 
| 1058 | 
            +
                <div class="sidebar">
         | 
| 1059 | 
            +
                    <div class="close-sidebar" onclick="toggleSidebar()">
         | 
| 1060 | 
            +
                        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
         | 
| 1061 | 
            +
                            <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
         | 
| 1062 | 
            +
                        </svg>
         | 
| 1063 | 
            +
                    </div>
         | 
| 1064 | 
            +
                    <div class="logo">TTS Arena</div>
         | 
| 1065 | 
            +
                    <nav>
         | 
| 1066 | 
            +
                        <a href="{{ url_for('arena') }}" class="nav-item {% if request.path == '/' %}active{% endif %}">
         | 
| 1067 | 
            +
                            <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>
         | 
| 1068 | 
            +
                            Arena
         | 
| 1069 | 
            +
                        </a>
         | 
| 1070 | 
            +
                        <a href="{{ url_for('leaderboard') }}" class="nav-item {% if request.path == '/leaderboard' %}active{% endif %}">
         | 
| 1071 | 
            +
                            <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>
         | 
| 1072 | 
            +
                            Leaderboard
         | 
| 1073 | 
            +
                        </a>
         | 
| 1074 | 
            +
                        <a href="{{ url_for('about') }}" class="nav-item {% if request.path == '/about' %}active{% endif %}">
         | 
| 1075 | 
            +
                            <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>
         | 
| 1076 | 
            +
                            About
         | 
| 1077 | 
            +
                        </a>
         | 
| 1078 | 
            +
                        
         | 
| 1079 | 
            +
                        <!-- Admin Panel Link - Only visible to admin users -->
         | 
| 1080 | 
            +
                        {% if g.is_admin %}
         | 
| 1081 | 
            +
                        <a href="{{ url_for('admin.index') }}" class="nav-item {% if '/admin' in request.path %}active{% endif %}">
         | 
| 1082 | 
            +
                            <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>
         | 
| 1083 | 
            +
                            Admin Panel
         | 
| 1084 | 
            +
                        </a>
         | 
| 1085 | 
            +
                        {% endif %}
         | 
| 1086 | 
            +
                    </nav>
         | 
| 1087 | 
            +
             | 
| 1088 | 
            +
                    <div class="sidebar-footer">
         | 
| 1089 | 
            +
                        <a href="https://discord.gg/HB8fMR6GTr" target="_blank" rel="noopener noreferrer" class="discord-link">
         | 
| 1090 | 
            +
                            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 127.14 96.36" fill="currentColor">
         | 
| 1091 | 
            +
                                <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"/>
         | 
| 1092 | 
            +
                            </svg>
         | 
| 1093 | 
            +
                            Join our Discord
         | 
| 1094 | 
            +
                        </a>
         | 
| 1095 | 
            +
             | 
| 1096 | 
            +
                        {% if current_user.is_authenticated %}
         | 
| 1097 | 
            +
                        <div class="user-auth" onclick="toggleUserDropdown(event)">
         | 
| 1098 | 
            +
                            <div class="user-name">{{ current_user.username }}</div>
         | 
| 1099 | 
            +
                            <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">
         | 
| 1100 | 
            +
                                <polyline points="6 9 12 15 18 9"></polyline>
         | 
| 1101 | 
            +
                            </svg>
         | 
| 1102 | 
            +
             | 
| 1103 | 
            +
                            <div class="user-dropdown">
         | 
| 1104 | 
            +
                                <a href="{{ url_for('auth.logout') }}" class="dropdown-item">
         | 
| 1105 | 
            +
                                    <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">
         | 
| 1106 | 
            +
                                        <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
         | 
| 1107 | 
            +
                                        <polyline points="16 17 21 12 16 7"></polyline>
         | 
| 1108 | 
            +
                                        <line x1="21" y1="12" x2="9" y2="12"></line>
         | 
| 1109 | 
            +
                                    </svg>
         | 
| 1110 | 
            +
                                    Logout
         | 
| 1111 | 
            +
                                </a>
         | 
| 1112 | 
            +
                            </div>
         | 
| 1113 | 
            +
                        </div>
         | 
| 1114 | 
            +
                        {% else %}
         | 
| 1115 | 
            +
                        <a href="{{ url_for('auth.login', next=request.path) }}" class="login-link">
         | 
| 1116 | 
            +
                            <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face">
         | 
| 1117 | 
            +
                            Login
         | 
| 1118 | 
            +
                        </a>
         | 
| 1119 | 
            +
                        <!-- Login tip overlay -->
         | 
| 1120 | 
            +
                        <div id="login-tip-overlay" class="login-tip-overlay">
         | 
| 1121 | 
            +
                            <div class="login-tip-content">
         | 
| 1122 | 
            +
                                Log in to track your votes, see personalized leaderboards, and more!
         | 
| 1123 | 
            +
                            </div>
         | 
| 1124 | 
            +
                            <div class="login-tip-actions">
         | 
| 1125 | 
            +
                                <button class="login-tip-close" onclick="dismissLoginTip()">Don't show again</button>
         | 
| 1126 | 
            +
                                <a href="{{ url_for('auth.login', next=request.path) }}" class="login-now-btn">Login now</a>
         | 
| 1127 | 
            +
                            </div>
         | 
| 1128 | 
            +
                            <div class="login-tip-caret"></div>
         | 
| 1129 | 
            +
                        </div>
         | 
| 1130 | 
            +
                        {% endif %}
         | 
| 1131 | 
            +
                    </div>
         | 
| 1132 | 
            +
                </div>
         | 
| 1133 | 
            +
             | 
| 1134 | 
            +
                <div class="main-content">
         | 
| 1135 | 
            +
                    <!-- Flash messages -->
         | 
| 1136 | 
            +
                    {% with messages = get_flashed_messages(with_categories=true) %}
         | 
| 1137 | 
            +
                    {% if messages %}
         | 
| 1138 | 
            +
                    <div class="flash-messages">
         | 
| 1139 | 
            +
                        {% for category, message in messages %}
         | 
| 1140 | 
            +
                        <script>
         | 
| 1141 | 
            +
                            document.addEventListener('DOMContentLoaded', function () {
         | 
| 1142 | 
            +
                                openToast('{{ message }}', '{{ category }}');
         | 
| 1143 | 
            +
                            });
         | 
| 1144 | 
            +
                        </script>
         | 
| 1145 | 
            +
                        {% endfor %}
         | 
| 1146 | 
            +
                    </div>
         | 
| 1147 | 
            +
                    {% endif %}
         | 
| 1148 | 
            +
                    {% endwith %}
         | 
| 1149 | 
            +
             | 
| 1150 | 
            +
                    <div class="main-content-inner">
         | 
| 1151 | 
            +
                        {% block content %}{% endblock %}
         | 
| 1152 | 
            +
                    </div>
         | 
| 1153 | 
            +
                </div>
         | 
| 1154 | 
            +
             | 
| 1155 | 
            +
                <!-- Toast container -->
         | 
| 1156 | 
            +
                <div class="toast-container" id="toast-container"></div>
         | 
| 1157 | 
            +
             | 
| 1158 | 
            +
                {% if not current_user.is_authenticated %}
         | 
| 1159 | 
            +
                <!-- Mobile login banner -->
         | 
| 1160 | 
            +
                <div id="login-banner" class="login-banner">
         | 
| 1161 | 
            +
                    <div class="login-banner-content">
         | 
| 1162 | 
            +
                        Log in to track your votes and see personalized leaderboards!
         | 
| 1163 | 
            +
                    </div>
         | 
| 1164 | 
            +
                    <div class="login-banner-actions">
         | 
| 1165 | 
            +
                        <button class="login-banner-close" onclick="dismissLoginTip()">No thanks</button>
         | 
| 1166 | 
            +
                        <a href="{{ url_for('auth.login', next=request.path) }}" class="login-banner-btn">Login</a>
         | 
| 1167 | 
            +
                    </div>
         | 
| 1168 | 
            +
                </div>
         | 
| 1169 | 
            +
                {% endif %}
         | 
| 1170 | 
            +
             | 
| 1171 | 
            +
                {% block extra_scripts %}{% endblock %}
         | 
| 1172 | 
            +
                <script src="https://unpkg.com/@popperjs/core@2"></script>
         | 
| 1173 | 
            +
                <script>
         | 
| 1174 | 
            +
                    function toggleSidebar() {
         | 
| 1175 | 
            +
                        const sidebar = document.querySelector('.sidebar');
         | 
| 1176 | 
            +
                        const backdrop = document.querySelector('.backdrop');
         | 
| 1177 | 
            +
                        sidebar.classList.toggle('active');
         | 
| 1178 | 
            +
                        backdrop.classList.toggle('active');
         | 
| 1179 | 
            +
                    }
         | 
| 1180 | 
            +
             | 
| 1181 | 
            +
                    function toggleUserDropdown(event) {
         | 
| 1182 | 
            +
                        event.stopPropagation();
         | 
| 1183 | 
            +
                        const userAuth = document.querySelector('.user-auth');
         | 
| 1184 | 
            +
                        const userDropdown = document.querySelector('.user-dropdown');
         | 
| 1185 | 
            +
                        userAuth.classList.toggle('active');
         | 
| 1186 | 
            +
                        userDropdown.classList.toggle('active');
         | 
| 1187 | 
            +
                    }
         | 
| 1188 | 
            +
             | 
| 1189 | 
            +
                    // Function to check if the login tip has been dismissed
         | 
| 1190 | 
            +
                    function isLoginTipDismissed() {
         | 
| 1191 | 
            +
                        try {
         | 
| 1192 | 
            +
                            return localStorage.getItem('login_tip_dismissed') === 'true';
         | 
| 1193 | 
            +
                        } catch (error) {
         | 
| 1194 | 
            +
                            // Fallback if localStorage is blocked
         | 
| 1195 | 
            +
                            console.warn('localStorage access failed:', error);
         | 
| 1196 | 
            +
                            return false;
         | 
| 1197 | 
            +
                        }
         | 
| 1198 | 
            +
                    }
         | 
| 1199 | 
            +
             | 
| 1200 | 
            +
                    // Function to set localStorage when login tip is dismissed
         | 
| 1201 | 
            +
                    function dismissLoginTip() {
         | 
| 1202 | 
            +
                        try {
         | 
| 1203 | 
            +
                            // Store the preference in localStorage
         | 
| 1204 | 
            +
                            localStorage.setItem('login_tip_dismissed', 'true');
         | 
| 1205 | 
            +
                            
         | 
| 1206 | 
            +
                            // Hide all login notifications
         | 
| 1207 | 
            +
                            const loginTip = document.getElementById('login-tip-overlay');
         | 
| 1208 | 
            +
                            const loginBanner = document.getElementById('login-banner');
         | 
| 1209 | 
            +
                            const backdrop = document.querySelector('.login-backdrop');
         | 
| 1210 | 
            +
                            
         | 
| 1211 | 
            +
                            if (loginTip) {
         | 
| 1212 | 
            +
                                loginTip.classList.remove('show');
         | 
| 1213 | 
            +
                            }
         | 
| 1214 | 
            +
                            
         | 
| 1215 | 
            +
                            if (loginBanner) {
         | 
| 1216 | 
            +
                                loginBanner.style.display = 'none';
         | 
| 1217 | 
            +
                            }
         | 
| 1218 | 
            +
                            
         | 
| 1219 | 
            +
                            if (backdrop) {
         | 
| 1220 | 
            +
                                backdrop.style.display = 'none';
         | 
| 1221 | 
            +
                            }
         | 
| 1222 | 
            +
                        } catch (error) {
         | 
| 1223 | 
            +
                            console.warn('localStorage write failed:', error);
         | 
| 1224 | 
            +
                            // Still hide the tips even if localStorage fails
         | 
| 1225 | 
            +
                            const loginTip = document.getElementById('login-tip-overlay');
         | 
| 1226 | 
            +
                            const loginBanner = document.getElementById('login-banner');
         | 
| 1227 | 
            +
                            const backdrop = document.querySelector('.login-backdrop');
         | 
| 1228 | 
            +
                            
         | 
| 1229 | 
            +
                            if (loginTip) {
         | 
| 1230 | 
            +
                                loginTip.classList.remove('show');
         | 
| 1231 | 
            +
                            }
         | 
| 1232 | 
            +
                            
         | 
| 1233 | 
            +
                            if (loginBanner) {
         | 
| 1234 | 
            +
                                loginBanner.style.display = 'none';
         | 
| 1235 | 
            +
                            }
         | 
| 1236 | 
            +
                            
         | 
| 1237 | 
            +
                            if (backdrop) {
         | 
| 1238 | 
            +
                                backdrop.style.display = 'none';
         | 
| 1239 | 
            +
                            }
         | 
| 1240 | 
            +
                        }
         | 
| 1241 | 
            +
                    }
         | 
| 1242 | 
            +
             | 
| 1243 | 
            +
                    // Loading overlay functionality
         | 
| 1244 | 
            +
                    document.addEventListener('DOMContentLoaded', function () {
         | 
| 1245 | 
            +
                        // Show login tip if user is not logged in and hasn't dismissed it
         | 
| 1246 | 
            +
                        const loginTipOverlay = document.getElementById('login-tip-overlay');
         | 
| 1247 | 
            +
                        const loginBanner = document.getElementById('login-banner');
         | 
| 1248 | 
            +
                        const loginLink = document.querySelector('.login-link');
         | 
| 1249 | 
            +
                        
         | 
| 1250 | 
            +
                        if (loginLink && !isLoginTipDismissed()) {
         | 
| 1251 | 
            +
                            // Check screen width to determine which login notification to show
         | 
| 1252 | 
            +
                            if (window.innerWidth <= 768) {
         | 
| 1253 | 
            +
                                // Create and add a backdrop for the login banner
         | 
| 1254 | 
            +
                                const backdrop = document.createElement('div');
         | 
| 1255 | 
            +
                                backdrop.className = 'login-backdrop';
         | 
| 1256 | 
            +
                                backdrop.style.position = 'fixed';
         | 
| 1257 | 
            +
                                backdrop.style.top = '0';
         | 
| 1258 | 
            +
                                backdrop.style.left = '0';
         | 
| 1259 | 
            +
                                backdrop.style.width = '100%';
         | 
| 1260 | 
            +
                                backdrop.style.height = '100%';
         | 
| 1261 | 
            +
                                backdrop.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
         | 
| 1262 | 
            +
                                backdrop.style.zIndex = '9997';
         | 
| 1263 | 
            +
                                backdrop.style.display = 'none';
         | 
| 1264 | 
            +
                                document.body.appendChild(backdrop);
         | 
| 1265 | 
            +
                                
         | 
| 1266 | 
            +
                                // Show mobile banner with backdrop
         | 
| 1267 | 
            +
                                if (loginBanner) {
         | 
| 1268 | 
            +
                                    loginBanner.style.display = 'block';
         | 
| 1269 | 
            +
                                    backdrop.style.display = 'block';
         | 
| 1270 | 
            +
                                    
         | 
| 1271 | 
            +
                                    // Add event listener to close banner when clicking backdrop
         | 
| 1272 | 
            +
                                    backdrop.addEventListener('click', function() {
         | 
| 1273 | 
            +
                                        dismissLoginTip();
         | 
| 1274 | 
            +
                                    });
         | 
| 1275 | 
            +
                                }
         | 
| 1276 | 
            +
                            } else {
         | 
| 1277 | 
            +
                                // Show desktop popover
         | 
| 1278 | 
            +
                                if (loginTipOverlay) {
         | 
| 1279 | 
            +
                                    // Position the overlay with Popper.js
         | 
| 1280 | 
            +
                                    const popperInstance = Popper.createPopper(loginLink, loginTipOverlay, {
         | 
| 1281 | 
            +
                                        placement: 'top',
         | 
| 1282 | 
            +
                                        modifiers: [
         | 
| 1283 | 
            +
                                            {
         | 
| 1284 | 
            +
                                                name: 'offset',
         | 
| 1285 | 
            +
                                                options: {
         | 
| 1286 | 
            +
                                                    offset: [0, 10],
         | 
| 1287 | 
            +
                                                },
         | 
| 1288 | 
            +
                                            },
         | 
| 1289 | 
            +
                                        ],
         | 
| 1290 | 
            +
                                    });
         | 
| 1291 | 
            +
                                    
         | 
| 1292 | 
            +
                                    loginTipOverlay.classList.add('show');
         | 
| 1293 | 
            +
                                    popperInstance.update();
         | 
| 1294 | 
            +
                                }
         | 
| 1295 | 
            +
                            }
         | 
| 1296 | 
            +
                        }
         | 
| 1297 | 
            +
                        
         | 
| 1298 | 
            +
                        // Handle resize events to switch between banner and popover
         | 
| 1299 | 
            +
                        window.addEventListener('resize', function() {
         | 
| 1300 | 
            +
                            if (isLoginTipDismissed()) return;
         | 
| 1301 | 
            +
                            
         | 
| 1302 | 
            +
                            const backdrop = document.querySelector('.login-backdrop');
         | 
| 1303 | 
            +
                            
         | 
| 1304 | 
            +
                            if (window.innerWidth <= 768) {
         | 
| 1305 | 
            +
                                // Switch to mobile banner
         | 
| 1306 | 
            +
                                if (loginTipOverlay) {
         | 
| 1307 | 
            +
                                    loginTipOverlay.classList.remove('show');
         | 
| 1308 | 
            +
                                }
         | 
| 1309 | 
            +
                                if (loginBanner && backdrop) {
         | 
| 1310 | 
            +
                                    loginBanner.style.display = 'block';
         | 
| 1311 | 
            +
                                    backdrop.style.display = 'block';
         | 
| 1312 | 
            +
                                }
         | 
| 1313 | 
            +
                            } else {
         | 
| 1314 | 
            +
                                // Switch to desktop popover
         | 
| 1315 | 
            +
                                if (loginBanner && backdrop) {
         | 
| 1316 | 
            +
                                    loginBanner.style.display = 'none';
         | 
| 1317 | 
            +
                                    backdrop.style.display = 'none';
         | 
| 1318 | 
            +
                                }
         | 
| 1319 | 
            +
                                if (loginTipOverlay && loginLink) {
         | 
| 1320 | 
            +
                                    const popperInstance = Popper.createPopper(loginLink, loginTipOverlay, {
         | 
| 1321 | 
            +
                                        placement: 'top',
         | 
| 1322 | 
            +
                                        modifiers: [
         | 
| 1323 | 
            +
                                            {
         | 
| 1324 | 
            +
                                                name: 'offset',
         | 
| 1325 | 
            +
                                                options: {
         | 
| 1326 | 
            +
                                                    offset: [0, 10],
         | 
| 1327 | 
            +
                                                },
         | 
| 1328 | 
            +
                                            },
         | 
| 1329 | 
            +
                                        ],
         | 
| 1330 | 
            +
                                    });
         | 
| 1331 | 
            +
                                    
         | 
| 1332 | 
            +
                                    loginTipOverlay.classList.add('show');
         | 
| 1333 | 
            +
                                    popperInstance.update();
         | 
| 1334 | 
            +
                                }
         | 
| 1335 | 
            +
                            }
         | 
| 1336 | 
            +
                        });
         | 
| 1337 | 
            +
                        
         | 
| 1338 | 
            +
                        // Hide the loading overlay when page has loaded
         | 
| 1339 | 
            +
                        const loadingOverlay = document.getElementById('loading-overlay');
         | 
| 1340 | 
            +
                        loadingOverlay.classList.remove('active');
         | 
| 1341 | 
            +
             | 
| 1342 | 
            +
                        // Override fetch to handle Turnstile verification errors
         | 
| 1343 | 
            +
                        const originalFetch = window.fetch;
         | 
| 1344 | 
            +
                        window.fetch = async function (url, options) {
         | 
| 1345 | 
            +
                            try {
         | 
| 1346 | 
            +
                                const response = await originalFetch(url, options);
         | 
| 1347 | 
            +
             | 
| 1348 | 
            +
                                // If we get a 403 error with a specific error message, handle verification
         | 
| 1349 | 
            +
                                if (response.status === 403) {
         | 
| 1350 | 
            +
                                    const data = await response.clone().json();
         | 
| 1351 | 
            +
                                    if (data && (data.error === "Turnstile verification required" || data.error === "Turnstile verification expired")) {
         | 
| 1352 | 
            +
                                        // Redirect to Turnstile verification page with the current URL as the redirect target
         | 
| 1353 | 
            +
                                        window.location.href = "/turnstile?redirect_url=" + encodeURIComponent(window.location.href);
         | 
| 1354 | 
            +
                                        return new Response(JSON.stringify({ redirecting: true }), {
         | 
| 1355 | 
            +
                                            status: 200,
         | 
| 1356 | 
            +
                                            headers: { 'Content-Type': 'application/json' }
         | 
| 1357 | 
            +
                                        });
         | 
| 1358 | 
            +
                                    }
         | 
| 1359 | 
            +
                                }
         | 
| 1360 | 
            +
             | 
| 1361 | 
            +
                                return response;
         | 
| 1362 | 
            +
                            } catch (error) {
         | 
| 1363 | 
            +
                                return Promise.reject(error);
         | 
| 1364 | 
            +
                            }
         | 
| 1365 | 
            +
                        };
         | 
| 1366 | 
            +
                    });
         | 
| 1367 | 
            +
             | 
| 1368 | 
            +
                    // Close dropdown when clicking outside
         | 
| 1369 | 
            +
                    document.addEventListener('click', function (event) {
         | 
| 1370 | 
            +
                        const userDropdown = document.querySelector('.user-dropdown');
         | 
| 1371 | 
            +
                        const userAuth = document.querySelector('.user-auth');
         | 
| 1372 | 
            +
                        if (userDropdown && userAuth && userDropdown.classList.contains('active') && !userAuth.contains(event.target)) {
         | 
| 1373 | 
            +
                            userAuth.classList.remove('active');
         | 
| 1374 | 
            +
                            userDropdown.classList.remove('active');
         | 
| 1375 | 
            +
                        }
         | 
| 1376 | 
            +
                    });
         | 
| 1377 | 
            +
             | 
| 1378 | 
            +
                    // Toast functionality
         | 
| 1379 | 
            +
                    function openToast(message, type = 'info', duration = 5000) {
         | 
| 1380 | 
            +
                        const toastContainer = document.getElementById('toast-container');
         | 
| 1381 | 
            +
                        const toast = document.createElement('div');
         | 
| 1382 | 
            +
                        toast.className = `toast ${type}`;
         | 
| 1383 | 
            +
             | 
| 1384 | 
            +
                        // Generate icon based on type
         | 
| 1385 | 
            +
                        let iconSvg = '';
         | 
| 1386 | 
            +
                        if (type === 'info') {
         | 
| 1387 | 
            +
                            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>';
         | 
| 1388 | 
            +
                        } else if (type === 'success') {
         | 
| 1389 | 
            +
                            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>';
         | 
| 1390 | 
            +
                        } else if (type === 'warning') {
         | 
| 1391 | 
            +
                            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>';
         | 
| 1392 | 
            +
                        } else if (type === 'error') {
         | 
| 1393 | 
            +
                            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>';
         | 
| 1394 | 
            +
                        }
         | 
| 1395 | 
            +
             | 
| 1396 | 
            +
                        toast.innerHTML = `
         | 
| 1397 | 
            +
                            <div class="toast-icon">${iconSvg}</div>
         | 
| 1398 | 
            +
                            <div class="toast-content">${message}</div>
         | 
| 1399 | 
            +
                            <div class="toast-close" onclick="closeToast(this.parentNode)">
         | 
| 1400 | 
            +
                                <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">
         | 
| 1401 | 
            +
                                    <line x1="18" y1="6" x2="6" y2="18"></line>
         | 
| 1402 | 
            +
                                    <line x1="6" y1="6" x2="18" y2="18"></line>
         | 
| 1403 | 
            +
                                </svg>
         | 
| 1404 | 
            +
                            </div>
         | 
| 1405 | 
            +
                            <div class="toast-progress"></div>
         | 
| 1406 | 
            +
                        `;
         | 
| 1407 | 
            +
             | 
| 1408 | 
            +
                        toastContainer.appendChild(toast);
         | 
| 1409 | 
            +
             | 
| 1410 | 
            +
                        // Animate progress bar
         | 
| 1411 | 
            +
                        const progressBar = toast.querySelector('.toast-progress');
         | 
| 1412 | 
            +
                        progressBar.style.animation = `shrink ${duration / 1000}s linear forwards`;
         | 
| 1413 | 
            +
                        progressBar.style.transformOrigin = 'left';
         | 
| 1414 | 
            +
                        progressBar.style.transform = 'scaleX(1)';
         | 
| 1415 | 
            +
             | 
| 1416 | 
            +
                        // Auto-remove toast after duration
         | 
| 1417 | 
            +
                        const timeoutId = setTimeout(() => {
         | 
| 1418 | 
            +
                            closeToast(toast);
         | 
| 1419 | 
            +
                        }, duration);
         | 
| 1420 | 
            +
             | 
| 1421 | 
            +
                        // Store timeout ID on the toast element
         | 
| 1422 | 
            +
                        toast.dataset.timeoutId = timeoutId;
         | 
| 1423 | 
            +
             | 
| 1424 | 
            +
                        return toast;
         | 
| 1425 | 
            +
                    }
         | 
| 1426 | 
            +
             | 
| 1427 | 
            +
                    function closeToast(toast) {
         | 
| 1428 | 
            +
                        // Clear the timeout to prevent duplicate removal attempts
         | 
| 1429 | 
            +
                        if (toast.dataset.timeoutId) {
         | 
| 1430 | 
            +
                            clearTimeout(parseInt(toast.dataset.timeoutId));
         | 
| 1431 | 
            +
                        }
         | 
| 1432 | 
            +
             | 
| 1433 | 
            +
                        // Add slide-out animation
         | 
| 1434 | 
            +
                        toast.classList.add('slide-out');
         | 
| 1435 | 
            +
             | 
| 1436 | 
            +
                        // Remove toast after animation completes
         | 
| 1437 | 
            +
                        setTimeout(() => {
         | 
| 1438 | 
            +
                            if (toast.parentNode) {
         | 
| 1439 | 
            +
                                toast.parentNode.removeChild(toast);
         | 
| 1440 | 
            +
                            }
         | 
| 1441 | 
            +
                        }, 300);
         | 
| 1442 | 
            +
                    }
         | 
| 1443 | 
            +
                </script>
         | 
| 1444 | 
            +
            </body>
         | 
| 1445 | 
            +
             | 
| 1446 | 
            +
            </html>
         | 
    	
        templates/email.html
    ADDED
    
    | @@ -0,0 +1,174 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            <!DOCTYPE html>
         | 
| 2 | 
            +
            <html lang="en">
         | 
| 3 | 
            +
             | 
| 4 | 
            +
            <head>
         | 
| 5 | 
            +
                <meta charset="UTF-8">
         | 
| 6 | 
            +
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
         | 
| 7 | 
            +
                <title>Email Preferences - TTS Arena</title>
         | 
| 8 | 
            +
                <link rel="preconnect" href="https://fonts.googleapis.com">
         | 
| 9 | 
            +
                <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
         | 
| 10 | 
            +
                <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
         | 
| 11 | 
            +
                <style>
         | 
| 12 | 
            +
                    :root {
         | 
| 13 | 
            +
                        --primary-color: #5046e5;
         | 
| 14 | 
            +
                        --secondary-color: #f0f0f0;
         | 
| 15 | 
            +
                        --text-color: #333;
         | 
| 16 | 
            +
                        --light-gray: #f5f5f5;
         | 
| 17 | 
            +
                        --border-color: #e0e0e0;
         | 
| 18 | 
            +
                        --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
         | 
| 19 | 
            +
                        --radius: 8px;
         | 
| 20 | 
            +
                        --success-color: #10b981;
         | 
| 21 | 
            +
                        --info-color: #3b82f6;
         | 
| 22 | 
            +
                        --warning-color: #f59e0b;
         | 
| 23 | 
            +
                        --error-color: #ef4444;
         | 
| 24 | 
            +
                    }
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                    * {
         | 
| 27 | 
            +
                        margin: 0;
         | 
| 28 | 
            +
                        padding: 0;
         | 
| 29 | 
            +
                        box-sizing: border-box;
         | 
| 30 | 
            +
                        font-family: 'Inter', sans-serif;
         | 
| 31 | 
            +
                    }
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                    body {
         | 
| 34 | 
            +
                        color: var(--text-color);
         | 
| 35 | 
            +
                        background-color: var(--light-gray);
         | 
| 36 | 
            +
                        min-height: 100vh;
         | 
| 37 | 
            +
                        display: flex;
         | 
| 38 | 
            +
                        align-items: center;
         | 
| 39 | 
            +
                        justify-content: center;
         | 
| 40 | 
            +
                        padding: 20px;
         | 
| 41 | 
            +
                    }
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                    .container {
         | 
| 44 | 
            +
                        max-width: 600px;
         | 
| 45 | 
            +
                        width: 100%;
         | 
| 46 | 
            +
                        background-color: white;
         | 
| 47 | 
            +
                        border-radius: var(--radius);
         | 
| 48 | 
            +
                        box-shadow: var(--shadow);
         | 
| 49 | 
            +
                        padding: 40px;
         | 
| 50 | 
            +
                    }
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                    .logo {
         | 
| 53 | 
            +
                        font-size: 24px;
         | 
| 54 | 
            +
                        font-weight: 700;
         | 
| 55 | 
            +
                        margin-bottom: 24px;
         | 
| 56 | 
            +
                        color: var(--primary-color);
         | 
| 57 | 
            +
                        text-align: center;
         | 
| 58 | 
            +
                    }
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                    h1 {
         | 
| 61 | 
            +
                        font-size: 24px;
         | 
| 62 | 
            +
                        margin-bottom: 16px;
         | 
| 63 | 
            +
                        text-align: center;
         | 
| 64 | 
            +
                    }
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                    p {
         | 
| 67 | 
            +
                        margin-bottom: 24px;
         | 
| 68 | 
            +
                        line-height: 1.6;
         | 
| 69 | 
            +
                    }
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                    .btn-container {
         | 
| 72 | 
            +
                        display: flex;
         | 
| 73 | 
            +
                        gap: 16px;
         | 
| 74 | 
            +
                        margin-top: 32px;
         | 
| 75 | 
            +
                    }
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                    .btn {
         | 
| 78 | 
            +
                        flex: 1;
         | 
| 79 | 
            +
                        background-color: var(--primary-color);
         | 
| 80 | 
            +
                        color: white;
         | 
| 81 | 
            +
                        border: none;
         | 
| 82 | 
            +
                        border-radius: var(--radius);
         | 
| 83 | 
            +
                        padding: 12px 24px;
         | 
| 84 | 
            +
                        cursor: pointer;
         | 
| 85 | 
            +
                        font-weight: 500;
         | 
| 86 | 
            +
                        transition: background-color 0.2s;
         | 
| 87 | 
            +
                        text-align: center;
         | 
| 88 | 
            +
                        text-decoration: none;
         | 
| 89 | 
            +
                        display: inline-block;
         | 
| 90 | 
            +
                    }
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                    .btn:hover {
         | 
| 93 | 
            +
                        background-color: #4038c7;
         | 
| 94 | 
            +
                    }
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                    .btn-secondary {
         | 
| 97 | 
            +
                        background-color: white;
         | 
| 98 | 
            +
                        color: var(--text-color);
         | 
| 99 | 
            +
                        border: 1px solid var(--border-color);
         | 
| 100 | 
            +
                    }
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                    .btn-secondary:hover {
         | 
| 103 | 
            +
                        background-color: var(--light-gray);
         | 
| 104 | 
            +
                    }
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                    .footer {
         | 
| 107 | 
            +
                        margin-top: 40px;
         | 
| 108 | 
            +
                        text-align: center;
         | 
| 109 | 
            +
                        font-size: 14px;
         | 
| 110 | 
            +
                        color: #666;
         | 
| 111 | 
            +
                    }
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                    .footer a {
         | 
| 114 | 
            +
                        color: var(--primary-color);
         | 
| 115 | 
            +
                        text-decoration: none;
         | 
| 116 | 
            +
                    }
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                    /* Responsive styles */
         | 
| 119 | 
            +
                    @media (max-width: 768px) {
         | 
| 120 | 
            +
                        .container {
         | 
| 121 | 
            +
                            padding: 30px 20px;
         | 
| 122 | 
            +
                        }
         | 
| 123 | 
            +
             | 
| 124 | 
            +
                        h1 {
         | 
| 125 | 
            +
                            font-size: 22px;
         | 
| 126 | 
            +
                        }
         | 
| 127 | 
            +
                    }
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                    @media (max-width: 480px) {
         | 
| 130 | 
            +
                        .btn-container {
         | 
| 131 | 
            +
                            flex-direction: column;
         | 
| 132 | 
            +
                            gap: 12px;
         | 
| 133 | 
            +
                        }
         | 
| 134 | 
            +
             | 
| 135 | 
            +
                        .btn {
         | 
| 136 | 
            +
                            width: 100%;
         | 
| 137 | 
            +
                        }
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                        .container {
         | 
| 140 | 
            +
                            padding: 25px 15px;
         | 
| 141 | 
            +
                        }
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                        h1 {
         | 
| 144 | 
            +
                            font-size: 20px;
         | 
| 145 | 
            +
                        }
         | 
| 146 | 
            +
             | 
| 147 | 
            +
                        .logo {
         | 
| 148 | 
            +
                            font-size: 22px;
         | 
| 149 | 
            +
                            margin-bottom: 20px;
         | 
| 150 | 
            +
                        }
         | 
| 151 | 
            +
                    }
         | 
| 152 | 
            +
                </style>
         | 
| 153 | 
            +
            </head>
         | 
| 154 | 
            +
             | 
| 155 | 
            +
            <body>
         | 
| 156 | 
            +
                <div class="container">
         | 
| 157 | 
            +
                    <div class="logo">TTS Arena</div>
         | 
| 158 | 
            +
                    <h1>Email Updates</h1>
         | 
| 159 | 
            +
                    <p>
         | 
| 160 | 
            +
                        Would you mind receiving occasional email updates about TTS Arena? We'll keep you informed about new models, 
         | 
| 161 | 
            +
                        improvements, and important announcements.
         | 
| 162 | 
            +
                    </p>
         | 
| 163 | 
            +
                    <p>
         | 
| 164 | 
            +
                        We respect your privacy and promise not to spam your inbox. You can unsubscribe at any time. If you choose not to receive updates, we won't ask you again.
         | 
| 165 | 
            +
                    </p>
         | 
| 166 | 
            +
                    <div class="btn-container">
         | 
| 167 | 
            +
                        <a href="{{ url_for('subscribe_email', choice='yes') }}" class="btn">Yes, I'd like updates</a>
         | 
| 168 | 
            +
                        <a href="{{ url_for('subscribe_email', choice='no') }}" class="btn btn-secondary">No, thanks</a>
         | 
| 169 | 
            +
                    </div>
         | 
| 170 | 
            +
                </div>
         | 
| 171 | 
            +
            </body>
         | 
| 172 | 
            +
             | 
| 173 | 
            +
            </html>
         | 
| 174 | 
            +
             | 
    	
        templates/leaderboard.html
    ADDED
    
    | @@ -0,0 +1,1413 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            {% extends "base.html" %}
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            {% block title %}Leaderboard - TTS Arena{% endblock %}
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            {% block current_page %}Leaderboard{% endblock %}
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            {% block extra_head %}
         | 
| 8 | 
            +
            <style>
         | 
| 9 | 
            +
                .leaderboard-container {
         | 
| 10 | 
            +
                    background: white;
         | 
| 11 | 
            +
                    border-radius: var(--radius);
         | 
| 12 | 
            +
                    box-shadow: var(--shadow);
         | 
| 13 | 
            +
                    overflow: hidden;
         | 
| 14 | 
            +
                    width: 100%;
         | 
| 15 | 
            +
                    overflow-x: auto; /* Allow horizontal scrolling on small screens */
         | 
| 16 | 
            +
                }
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                .leaderboard-header {
         | 
| 19 | 
            +
                    display: grid;
         | 
| 20 | 
            +
                    grid-template-columns: 80px 1fr 120px 120px 120px;
         | 
| 21 | 
            +
                    padding: 16px;
         | 
| 22 | 
            +
                    background-color: var(--light-gray);
         | 
| 23 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 24 | 
            +
                    font-weight: 600;
         | 
| 25 | 
            +
                    min-width: 600px; /* Ensure minimum width for the grid */
         | 
| 26 | 
            +
                }
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                .leaderboard-row {
         | 
| 29 | 
            +
                    display: grid;
         | 
| 30 | 
            +
                    grid-template-columns: 80px 1fr 120px 120px 120px;
         | 
| 31 | 
            +
                    padding: 16px;
         | 
| 32 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 33 | 
            +
                    align-items: center;
         | 
| 34 | 
            +
                    min-width: 600px; /* Ensure minimum width for the grid */
         | 
| 35 | 
            +
                }
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                .leaderboard-row:last-child {
         | 
| 38 | 
            +
                    border-bottom: none;
         | 
| 39 | 
            +
                }
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                .rank {
         | 
| 42 | 
            +
                    font-weight: 600;
         | 
| 43 | 
            +
                    color: var(--primary-color);
         | 
| 44 | 
            +
                }
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                .model-name {
         | 
| 47 | 
            +
                    font-weight: 500;
         | 
| 48 | 
            +
                    display: flex;
         | 
| 49 | 
            +
                    align-items: center;
         | 
| 50 | 
            +
                    gap: 6px;
         | 
| 51 | 
            +
                }
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                .model-name-link {
         | 
| 54 | 
            +
                    text-decoration: none;
         | 
| 55 | 
            +
                    color: var(--text-color);
         | 
| 56 | 
            +
                }
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                .model-name-link:hover {
         | 
| 59 | 
            +
                    text-decoration: underline;
         | 
| 60 | 
            +
                }
         | 
| 61 | 
            +
                
         | 
| 62 | 
            +
                .license-icon {
         | 
| 63 | 
            +
                    width: 12px;
         | 
| 64 | 
            +
                    height: 12px;
         | 
| 65 | 
            +
                    cursor: help;
         | 
| 66 | 
            +
                    position: relative;
         | 
| 67 | 
            +
                    opacity: 0.7;
         | 
| 68 | 
            +
                    display: flex;
         | 
| 69 | 
            +
                    align-items: center;
         | 
| 70 | 
            +
                    justify-content: center;
         | 
| 71 | 
            +
                }
         | 
| 72 | 
            +
                
         | 
| 73 | 
            +
                .license-icon img {
         | 
| 74 | 
            +
                    width: 12px;
         | 
| 75 | 
            +
                    height: 12px;
         | 
| 76 | 
            +
                    vertical-align: middle;
         | 
| 77 | 
            +
                }
         | 
| 78 | 
            +
                
         | 
| 79 | 
            +
                .tooltip {
         | 
| 80 | 
            +
                    visibility: hidden;
         | 
| 81 | 
            +
                    background-color: rgba(0, 0, 0, 0.8);
         | 
| 82 | 
            +
                    color: white;
         | 
| 83 | 
            +
                    text-align: center;
         | 
| 84 | 
            +
                    border-radius: 4px;
         | 
| 85 | 
            +
                    padding: 5px 10px;
         | 
| 86 | 
            +
                    position: absolute;
         | 
| 87 | 
            +
                    z-index: 1;
         | 
| 88 | 
            +
                    bottom: 125%;
         | 
| 89 | 
            +
                    left: 50%;
         | 
| 90 | 
            +
                    transform: translateX(-50%);
         | 
| 91 | 
            +
                    opacity: 0;
         | 
| 92 | 
            +
                    transition: opacity 0.3s;
         | 
| 93 | 
            +
                    font-weight: normal;
         | 
| 94 | 
            +
                    font-size: 12px;
         | 
| 95 | 
            +
                    white-space: nowrap;
         | 
| 96 | 
            +
                }
         | 
| 97 | 
            +
                
         | 
| 98 | 
            +
                .license-icon:hover .tooltip {
         | 
| 99 | 
            +
                    visibility: visible;
         | 
| 100 | 
            +
                    opacity: 1;
         | 
| 101 | 
            +
                }
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                .win-rate, .total-votes, .elo-score {
         | 
| 104 | 
            +
                    text-align: right;
         | 
| 105 | 
            +
                    color: #666;
         | 
| 106 | 
            +
                }
         | 
| 107 | 
            +
                
         | 
| 108 | 
            +
                .elo-score {
         | 
| 109 | 
            +
                    font-weight: 600;
         | 
| 110 | 
            +
                }
         | 
| 111 | 
            +
                
         | 
| 112 | 
            +
                .tier-s {
         | 
| 113 | 
            +
                    background-color: rgba(255, 215, 0, 0.1);
         | 
| 114 | 
            +
                }
         | 
| 115 | 
            +
                
         | 
| 116 | 
            +
                .tier-a {
         | 
| 117 | 
            +
                    background-color: rgba(80, 200, 120, 0.1);
         | 
| 118 | 
            +
                }
         | 
| 119 | 
            +
                
         | 
| 120 | 
            +
                .tier-b {
         | 
| 121 | 
            +
                    background-color: rgba(80, 70, 229, 0.1);
         | 
| 122 | 
            +
                }
         | 
| 123 | 
            +
                
         | 
| 124 | 
            +
                .filter-controls {
         | 
| 125 | 
            +
                    display: flex;
         | 
| 126 | 
            +
                    margin-bottom: 24px;
         | 
| 127 | 
            +
                    align-items: center;
         | 
| 128 | 
            +
                    gap: 16px;
         | 
| 129 | 
            +
                }
         | 
| 130 | 
            +
                
         | 
| 131 | 
            +
                .tabs {
         | 
| 132 | 
            +
                    display: flex;
         | 
| 133 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 134 | 
            +
                    margin-bottom: 24px;
         | 
| 135 | 
            +
                }
         | 
| 136 | 
            +
             | 
| 137 | 
            +
                .tab {
         | 
| 138 | 
            +
                    padding: 12px 24px;
         | 
| 139 | 
            +
                    cursor: pointer;
         | 
| 140 | 
            +
                    position: relative;
         | 
| 141 | 
            +
                    font-weight: 500;
         | 
| 142 | 
            +
                }
         | 
| 143 | 
            +
             | 
| 144 | 
            +
                .tab.active {
         | 
| 145 | 
            +
                    color: var(--primary-color);
         | 
| 146 | 
            +
                }
         | 
| 147 | 
            +
             | 
| 148 | 
            +
                .tab.active::after {
         | 
| 149 | 
            +
                    content: '';
         | 
| 150 | 
            +
                    position: absolute;
         | 
| 151 | 
            +
                    bottom: -1px;
         | 
| 152 | 
            +
                    left: 0;
         | 
| 153 | 
            +
                    width: 100%;
         | 
| 154 | 
            +
                    height: 2px;
         | 
| 155 | 
            +
                    background-color: var(--primary-color);
         | 
| 156 | 
            +
                }
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                .coming-soon {
         | 
| 159 | 
            +
                    text-align: center;
         | 
| 160 | 
            +
                    padding: 60px 0;
         | 
| 161 | 
            +
                    color: #666;
         | 
| 162 | 
            +
                    font-size: 18px;
         | 
| 163 | 
            +
                    font-weight: 500;
         | 
| 164 | 
            +
                }
         | 
| 165 | 
            +
                
         | 
| 166 | 
            +
                .no-data {
         | 
| 167 | 
            +
                    text-align: center;
         | 
| 168 | 
            +
                    padding: 40px 0;
         | 
| 169 | 
            +
                    color: #666;
         | 
| 170 | 
            +
                }
         | 
| 171 | 
            +
                
         | 
| 172 | 
            +
                .no-data h3 {
         | 
| 173 | 
            +
                    margin-bottom: 12px;
         | 
| 174 | 
            +
                    color: #333;
         | 
| 175 | 
            +
                }
         | 
| 176 | 
            +
                
         | 
| 177 | 
            +
                .no-data p {
         | 
| 178 | 
            +
                    margin-bottom: 20px;
         | 
| 179 | 
            +
                    max-width: 500px;
         | 
| 180 | 
            +
                    margin-left: auto;
         | 
| 181 | 
            +
                    margin-right: auto;
         | 
| 182 | 
            +
                }
         | 
| 183 | 
            +
                
         | 
| 184 | 
            +
                .view-toggle {
         | 
| 185 | 
            +
                    display: flex;
         | 
| 186 | 
            +
                    justify-content: flex-end;
         | 
| 187 | 
            +
                    margin-bottom: 20px;
         | 
| 188 | 
            +
                }
         | 
| 189 | 
            +
                
         | 
| 190 | 
            +
                .segmented-control {
         | 
| 191 | 
            +
                    position: relative;
         | 
| 192 | 
            +
                    display: inline-flex;
         | 
| 193 | 
            +
                    background-color: var(--light-gray);
         | 
| 194 | 
            +
                    border-radius: 8px;
         | 
| 195 | 
            +
                    padding: 4px;
         | 
| 196 | 
            +
                    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
         | 
| 197 | 
            +
                    -webkit-user-select: none;
         | 
| 198 | 
            +
                    user-select: none;
         | 
| 199 | 
            +
                }
         | 
| 200 | 
            +
                
         | 
| 201 | 
            +
                .segmented-control input[type="radio"] {
         | 
| 202 | 
            +
                    display: none;
         | 
| 203 | 
            +
                }
         | 
| 204 | 
            +
                
         | 
| 205 | 
            +
                .segmented-control label {
         | 
| 206 | 
            +
                    position: relative;
         | 
| 207 | 
            +
                    z-index: 2;
         | 
| 208 | 
            +
                    padding: 8px 20px;
         | 
| 209 | 
            +
                    font-size: 14px;
         | 
| 210 | 
            +
                    font-weight: 500;
         | 
| 211 | 
            +
                    text-align: center;
         | 
| 212 | 
            +
                    cursor: pointer;
         | 
| 213 | 
            +
                    transition: color 0.2s ease;
         | 
| 214 | 
            +
                    color: #666;
         | 
| 215 | 
            +
                    border-radius: 6px;
         | 
| 216 | 
            +
                }
         | 
| 217 | 
            +
                
         | 
| 218 | 
            +
                .segmented-control label:hover {
         | 
| 219 | 
            +
                    color: #333;
         | 
| 220 | 
            +
                }
         | 
| 221 | 
            +
                
         | 
| 222 | 
            +
                .segmented-control input[type="radio"]:checked + label {
         | 
| 223 | 
            +
                    color: #fff;
         | 
| 224 | 
            +
                }
         | 
| 225 | 
            +
                
         | 
| 226 | 
            +
                .slider {
         | 
| 227 | 
            +
                    position: absolute;
         | 
| 228 | 
            +
                    z-index: 1;
         | 
| 229 | 
            +
                    top: 4px;
         | 
| 230 | 
            +
                    left: 4px;
         | 
| 231 | 
            +
                    height: calc(100% - 8px);
         | 
| 232 | 
            +
                    border-radius: 6px;
         | 
| 233 | 
            +
                    transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
         | 
| 234 | 
            +
                    background-color: var(--primary-color);
         | 
| 235 | 
            +
                }
         | 
| 236 | 
            +
                
         | 
| 237 | 
            +
                .login-prompt {
         | 
| 238 | 
            +
                    display: none;
         | 
| 239 | 
            +
                    position: fixed;
         | 
| 240 | 
            +
                    top: 0;
         | 
| 241 | 
            +
                    left: 0;
         | 
| 242 | 
            +
                    width: 100%;
         | 
| 243 | 
            +
                    height: 100%;
         | 
| 244 | 
            +
                    background-color: rgba(0,0,0,0.7);
         | 
| 245 | 
            +
                    z-index: 9999;
         | 
| 246 | 
            +
                    justify-content: center;
         | 
| 247 | 
            +
                    align-items: center;
         | 
| 248 | 
            +
                }
         | 
| 249 | 
            +
                
         | 
| 250 | 
            +
                .login-prompt-content {
         | 
| 251 | 
            +
                    background-color: var(--light-gray);
         | 
| 252 | 
            +
                    padding: 24px;
         | 
| 253 | 
            +
                    border-radius: var(--radius);
         | 
| 254 | 
            +
                    box-shadow: var(--shadow);
         | 
| 255 | 
            +
                    text-align: center;
         | 
| 256 | 
            +
                    max-width: 400px;
         | 
| 257 | 
            +
                    position: relative;
         | 
| 258 | 
            +
                }
         | 
| 259 | 
            +
                
         | 
| 260 | 
            +
                .login-prompt-content h3 {
         | 
| 261 | 
            +
                    margin-bottom: 16px;
         | 
| 262 | 
            +
                }
         | 
| 263 | 
            +
                
         | 
| 264 | 
            +
                .login-prompt-content p {
         | 
| 265 | 
            +
                    margin-bottom: 24px;
         | 
| 266 | 
            +
                }
         | 
| 267 | 
            +
                
         | 
| 268 | 
            +
                .login-prompt-close {
         | 
| 269 | 
            +
                    position: absolute;
         | 
| 270 | 
            +
                    top: 12px;
         | 
| 271 | 
            +
                    right: 12px;
         | 
| 272 | 
            +
                    font-size: 20px;
         | 
| 273 | 
            +
                    cursor: pointer;
         | 
| 274 | 
            +
                    color: #999;
         | 
| 275 | 
            +
                }
         | 
| 276 | 
            +
                
         | 
| 277 | 
            +
                .btn {
         | 
| 278 | 
            +
                    display: inline-block;
         | 
| 279 | 
            +
                    background-color: var(--primary-color);
         | 
| 280 | 
            +
                    color: white;
         | 
| 281 | 
            +
                    padding: 8px 16px;
         | 
| 282 | 
            +
                    border-radius: 4px;
         | 
| 283 | 
            +
                    text-decoration: none;
         | 
| 284 | 
            +
                    font-weight: 500;
         | 
| 285 | 
            +
                }
         | 
| 286 | 
            +
             | 
| 287 | 
            +
                /* Timeline styles */
         | 
| 288 | 
            +
                .timeline-container {
         | 
| 289 | 
            +
                    margin-bottom: 24px;
         | 
| 290 | 
            +
                    position: relative;
         | 
| 291 | 
            +
                }
         | 
| 292 | 
            +
                
         | 
| 293 | 
            +
                .timeline-header {
         | 
| 294 | 
            +
                    display: flex;
         | 
| 295 | 
            +
                    justify-content: space-between;
         | 
| 296 | 
            +
                    align-items: center;
         | 
| 297 | 
            +
                    margin-bottom: 16px;
         | 
| 298 | 
            +
                }
         | 
| 299 | 
            +
                
         | 
| 300 | 
            +
                .timeline-title {
         | 
| 301 | 
            +
                    font-weight: 600;
         | 
| 302 | 
            +
                    color: var(--text-color);
         | 
| 303 | 
            +
                    display: flex;
         | 
| 304 | 
            +
                    align-items: center;
         | 
| 305 | 
            +
                    gap: 8px;
         | 
| 306 | 
            +
                }
         | 
| 307 | 
            +
                
         | 
| 308 | 
            +
                .timeline-title svg {
         | 
| 309 | 
            +
                    opacity: 0.7;
         | 
| 310 | 
            +
                }
         | 
| 311 | 
            +
                
         | 
| 312 | 
            +
                .timeline-controls {
         | 
| 313 | 
            +
                    display: flex;
         | 
| 314 | 
            +
                    align-items: center;
         | 
| 315 | 
            +
                    gap: 8px;
         | 
| 316 | 
            +
                }
         | 
| 317 | 
            +
                
         | 
| 318 | 
            +
                .timeline-select {
         | 
| 319 | 
            +
                    padding: 8px 12px;
         | 
| 320 | 
            +
                    border-radius: 4px;
         | 
| 321 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 322 | 
            +
                    background-color: white;
         | 
| 323 | 
            +
                    color: var(--text-color);
         | 
| 324 | 
            +
                    font-size: 14px;
         | 
| 325 | 
            +
                    appearance: none;
         | 
| 326 | 
            +
                    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
         | 
| 327 | 
            +
                    background-repeat: no-repeat;
         | 
| 328 | 
            +
                    background-position: right 8px center;
         | 
| 329 | 
            +
                    background-size: 16px;
         | 
| 330 | 
            +
                    padding-right: 36px;
         | 
| 331 | 
            +
                    cursor: pointer;
         | 
| 332 | 
            +
                }
         | 
| 333 | 
            +
                
         | 
| 334 | 
            +
                .timeline-button {
         | 
| 335 | 
            +
                    padding: 8px 12px;
         | 
| 336 | 
            +
                    border-radius: 4px;
         | 
| 337 | 
            +
                    border: 1px solid var(--border-color);
         | 
| 338 | 
            +
                    background-color: white;
         | 
| 339 | 
            +
                    color: var(--text-color);
         | 
| 340 | 
            +
                    font-size: 14px;
         | 
| 341 | 
            +
                    cursor: pointer;
         | 
| 342 | 
            +
                    display: flex;
         | 
| 343 | 
            +
                    align-items: center;
         | 
| 344 | 
            +
                    gap: 4px;
         | 
| 345 | 
            +
                }
         | 
| 346 | 
            +
                
         | 
| 347 | 
            +
                .timeline-button:hover {
         | 
| 348 | 
            +
                    background-color: var(--light-gray);
         | 
| 349 | 
            +
                }
         | 
| 350 | 
            +
                
         | 
| 351 | 
            +
                .timeline-track {
         | 
| 352 | 
            +
                    height: 8px;
         | 
| 353 | 
            +
                    background-color: var(--light-gray);
         | 
| 354 | 
            +
                    border-radius: 4px;
         | 
| 355 | 
            +
                    position: relative;
         | 
| 356 | 
            +
                    margin: 20px 0;
         | 
| 357 | 
            +
                }
         | 
| 358 | 
            +
                
         | 
| 359 | 
            +
                .timeline-progress {
         | 
| 360 | 
            +
                    position: absolute;
         | 
| 361 | 
            +
                    height: 100%;
         | 
| 362 | 
            +
                    background-color: var(--primary-color);
         | 
| 363 | 
            +
                    border-radius: 4px;
         | 
| 364 | 
            +
                    transition: width 0.3s ease;
         | 
| 365 | 
            +
                }
         | 
| 366 | 
            +
                
         | 
| 367 | 
            +
                .timeline-marker {
         | 
| 368 | 
            +
                    position: absolute;
         | 
| 369 | 
            +
                    width: 16px;
         | 
| 370 | 
            +
                    height: 16px;
         | 
| 371 | 
            +
                    background-color: white;
         | 
| 372 | 
            +
                    border: 3px solid var(--primary-color);
         | 
| 373 | 
            +
                    border-radius: 50%;
         | 
| 374 | 
            +
                    top: 50%;
         | 
| 375 | 
            +
                    transform: translate(-50%, -50%);
         | 
| 376 | 
            +
                    cursor: pointer;
         | 
| 377 | 
            +
                    z-index: 2;
         | 
| 378 | 
            +
                    transition: left 0.3s ease;
         | 
| 379 | 
            +
                }
         | 
| 380 | 
            +
                
         | 
| 381 | 
            +
                .timeline-dates {
         | 
| 382 | 
            +
                    display: flex;
         | 
| 383 | 
            +
                    justify-content: space-between;
         | 
| 384 | 
            +
                    margin-top: 8px;
         | 
| 385 | 
            +
                    color: #666;
         | 
| 386 | 
            +
                    font-size: 12px;
         | 
| 387 | 
            +
                }
         | 
| 388 | 
            +
                
         | 
| 389 | 
            +
                .historical-indicator {
         | 
| 390 | 
            +
                    display: none;
         | 
| 391 | 
            +
                    background-color: var(--primary-color);
         | 
| 392 | 
            +
                    color: white;
         | 
| 393 | 
            +
                    padding: 4px 10px;
         | 
| 394 | 
            +
                    border-radius: 12px;
         | 
| 395 | 
            +
                    font-size: 12px;
         | 
| 396 | 
            +
                    font-weight: 500;
         | 
| 397 | 
            +
                    margin-bottom: 16px;
         | 
| 398 | 
            +
                    align-items: center;
         | 
| 399 | 
            +
                    gap: 6px;
         | 
| 400 | 
            +
                }
         | 
| 401 | 
            +
                
         | 
| 402 | 
            +
                .historical-indicator.active {
         | 
| 403 | 
            +
                    display: inline-flex;
         | 
| 404 | 
            +
                }
         | 
| 405 | 
            +
                
         | 
| 406 | 
            +
                .loading-spinner {
         | 
| 407 | 
            +
                    display: none;
         | 
| 408 | 
            +
                    width: 20px;
         | 
| 409 | 
            +
                    height: 20px;
         | 
| 410 | 
            +
                    border-radius: 50%;
         | 
| 411 | 
            +
                    border: 2px solid rgba(255, 255, 255, 0.3);
         | 
| 412 | 
            +
                    border-top-color: white;
         | 
| 413 | 
            +
                    animation: spin 1s linear infinite;
         | 
| 414 | 
            +
                }
         | 
| 415 | 
            +
                
         | 
| 416 | 
            +
                .loading.loading-spinner {
         | 
| 417 | 
            +
                    display: inline-block;
         | 
| 418 | 
            +
                }
         | 
| 419 | 
            +
                
         | 
| 420 | 
            +
                @keyframes spin {
         | 
| 421 | 
            +
                    to { transform: rotate(360deg); }
         | 
| 422 | 
            +
                }
         | 
| 423 | 
            +
             | 
| 424 | 
            +
                @media (max-width: 768px) {
         | 
| 425 | 
            +
                    .leaderboard-header, .leaderboard-row {
         | 
| 426 | 
            +
                        grid-template-columns: 60px 1fr 80px 80px;
         | 
| 427 | 
            +
                        min-width: 400px; /* Reduced minimum width for mobile */
         | 
| 428 | 
            +
                    }
         | 
| 429 | 
            +
             | 
| 430 | 
            +
                    .total-votes {
         | 
| 431 | 
            +
                        display: none;
         | 
| 432 | 
            +
                    }
         | 
| 433 | 
            +
             | 
| 434 | 
            +
                    .leaderboard-header .total-votes-header {
         | 
| 435 | 
            +
                        display: none;
         | 
| 436 | 
            +
                    }
         | 
| 437 | 
            +
                    
         | 
| 438 | 
            +
                    .filter-controls {
         | 
| 439 | 
            +
                        flex-direction: column;
         | 
| 440 | 
            +
                        align-items: flex-start;
         | 
| 441 | 
            +
                    }
         | 
| 442 | 
            +
                    
         | 
| 443 | 
            +
                    .timeline-header {
         | 
| 444 | 
            +
                        flex-direction: column;
         | 
| 445 | 
            +
                        align-items: flex-start;
         | 
| 446 | 
            +
                        gap: 12px;
         | 
| 447 | 
            +
                    }
         | 
| 448 | 
            +
                    
         | 
| 449 | 
            +
                    .timeline-controls {
         | 
| 450 | 
            +
                        width: 100%;
         | 
| 451 | 
            +
                    }
         | 
| 452 | 
            +
                    
         | 
| 453 | 
            +
                    .timeline-select {
         | 
| 454 | 
            +
                        flex-grow: 1;
         | 
| 455 | 
            +
                    }
         | 
| 456 | 
            +
                }
         | 
| 457 | 
            +
             | 
| 458 | 
            +
                @media (max-width: 480px) {
         | 
| 459 | 
            +
                    .leaderboard-header, .leaderboard-row {
         | 
| 460 | 
            +
                        grid-template-columns: 50px 1fr 70px;
         | 
| 461 | 
            +
                        min-width: 300px; /* Further reduced for very small screens */
         | 
| 462 | 
            +
                        font-size: 14px;
         | 
| 463 | 
            +
                        padding: 12px 8px;
         | 
| 464 | 
            +
                    }
         | 
| 465 | 
            +
             | 
| 466 | 
            +
                    .elo-score {
         | 
| 467 | 
            +
                        font-size: 14px;
         | 
| 468 | 
            +
                    }
         | 
| 469 | 
            +
             | 
| 470 | 
            +
                    .total-votes, .win-rate {
         | 
| 471 | 
            +
                        display: none;
         | 
| 472 | 
            +
                    }
         | 
| 473 | 
            +
             | 
| 474 | 
            +
                    .leaderboard-header .total-votes-header,
         | 
| 475 | 
            +
                    .leaderboard-header div:nth-child(3) {
         | 
| 476 | 
            +
                        display: none;
         | 
| 477 | 
            +
                    }
         | 
| 478 | 
            +
             | 
| 479 | 
            +
                    .tab {
         | 
| 480 | 
            +
                        padding: 10px 16px;
         | 
| 481 | 
            +
                        font-size: 14px;
         | 
| 482 | 
            +
                    }
         | 
| 483 | 
            +
                }
         | 
| 484 | 
            +
             | 
| 485 | 
            +
                /* Dark mode styles */
         | 
| 486 | 
            +
                @media (prefers-color-scheme: dark) {
         | 
| 487 | 
            +
                    .no-data {
         | 
| 488 | 
            +
                        color: var(--text-color);
         | 
| 489 | 
            +
                    }
         | 
| 490 | 
            +
             | 
| 491 | 
            +
                    .no-data h3 {
         | 
| 492 | 
            +
                        color: var(--text-color);
         | 
| 493 | 
            +
                    }
         | 
| 494 | 
            +
                    
         | 
| 495 | 
            +
                    
         | 
| 496 | 
            +
                    .leaderboard-container {
         | 
| 497 | 
            +
                        background-color: var(--light-gray);
         | 
| 498 | 
            +
                        border-color: var(--border-color);
         | 
| 499 | 
            +
                    }
         | 
| 500 | 
            +
                    
         | 
| 501 | 
            +
                    .leaderboard-header {
         | 
| 502 | 
            +
                        background-color: rgba(80, 70, 229, 0.1);
         | 
| 503 | 
            +
                        border-color: var(--border-color);
         | 
| 504 | 
            +
                    }
         | 
| 505 | 
            +
                    
         | 
| 506 | 
            +
                    .leaderboard-row {
         | 
| 507 | 
            +
                        border-color: var(--border-color);
         | 
| 508 | 
            +
                    }
         | 
| 509 | 
            +
                    
         | 
| 510 | 
            +
                    .leaderboard-row:hover {
         | 
| 511 | 
            +
                        background-color: rgba(255, 255, 255, 0.05);
         | 
| 512 | 
            +
                    }
         | 
| 513 | 
            +
                    
         | 
| 514 | 
            +
                    .tier-s {
         | 
| 515 | 
            +
                        background-color: rgba(255, 215, 0, 0.1);
         | 
| 516 | 
            +
                    }
         | 
| 517 | 
            +
                    
         | 
| 518 | 
            +
                    .tier-a {
         | 
| 519 | 
            +
                        background-color: rgba(192, 192, 192, 0.1);
         | 
| 520 | 
            +
                    }
         | 
| 521 | 
            +
                    
         | 
| 522 | 
            +
                    .tier-b {
         | 
| 523 | 
            +
                        background-color: rgba(205, 127, 50, 0.1);
         | 
| 524 | 
            +
                    }
         | 
| 525 | 
            +
                    
         | 
| 526 | 
            +
                    .segmented-control {
         | 
| 527 | 
            +
                        background-color: var(--light-gray);
         | 
| 528 | 
            +
                        border-color: var(--border-color);
         | 
| 529 | 
            +
                    }
         | 
| 530 | 
            +
                    
         | 
| 531 | 
            +
                    .segmented-control label {
         | 
| 532 | 
            +
                        color: var(--text-color);
         | 
| 533 | 
            +
                    }
         | 
| 534 | 
            +
             | 
| 535 | 
            +
                    .segmented-control label:hover {
         | 
| 536 | 
            +
                        color: var(--text-color);
         | 
| 537 | 
            +
                    }
         | 
| 538 | 
            +
                    
         | 
| 539 | 
            +
                    .segmented-control .slider {
         | 
| 540 | 
            +
                        background-color: var(--primary-color);
         | 
| 541 | 
            +
                    }
         | 
| 542 | 
            +
                    
         | 
| 543 | 
            +
                    .tooltip {
         | 
| 544 | 
            +
                        background-color: var(--light-gray);
         | 
| 545 | 
            +
                        color: var(--text-color);
         | 
| 546 | 
            +
                        border-color: var(--border-color);
         | 
| 547 | 
            +
                    }
         | 
| 548 | 
            +
                    .license-icon img {
         | 
| 549 | 
            +
                        filter: invert(1);
         | 
| 550 | 
            +
                    }
         | 
| 551 | 
            +
                    
         | 
| 552 | 
            +
                    .timeline-select, .timeline-button {
         | 
| 553 | 
            +
                        background-color: var(--light-gray);
         | 
| 554 | 
            +
                        border-color: var(--border-color);
         | 
| 555 | 
            +
                        color: var(--text-color);
         | 
| 556 | 
            +
                    }
         | 
| 557 | 
            +
                    
         | 
| 558 | 
            +
                    .timeline-button:hover {
         | 
| 559 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 560 | 
            +
                    }
         | 
| 561 | 
            +
                    
         | 
| 562 | 
            +
                    .timeline-track {
         | 
| 563 | 
            +
                        background-color: rgba(255, 255, 255, 0.1);
         | 
| 564 | 
            +
                    }
         | 
| 565 | 
            +
                    
         | 
| 566 | 
            +
                    .timeline-marker {
         | 
| 567 | 
            +
                        background-color: var(--light-gray);
         | 
| 568 | 
            +
                    }
         | 
| 569 | 
            +
                }
         | 
| 570 | 
            +
             | 
| 571 | 
            +
                /* Top voters leaderboard styles */
         | 
| 572 | 
            +
                .voters-leaderboard {
         | 
| 573 | 
            +
                    margin-top: 32px;
         | 
| 574 | 
            +
                }
         | 
| 575 | 
            +
             | 
| 576 | 
            +
                .voters-leaderboard-header {
         | 
| 577 | 
            +
                    display: flex;
         | 
| 578 | 
            +
                    justify-content: space-between;
         | 
| 579 | 
            +
                    align-items: center;
         | 
| 580 | 
            +
                    margin-bottom: 16px;
         | 
| 581 | 
            +
                }
         | 
| 582 | 
            +
             | 
| 583 | 
            +
                .visibility-toggle {
         | 
| 584 | 
            +
                    display: flex;
         | 
| 585 | 
            +
                    align-items: center;
         | 
| 586 | 
            +
                    gap: 8px;
         | 
| 587 | 
            +
                }
         | 
| 588 | 
            +
             | 
| 589 | 
            +
                .toggle-switch {
         | 
| 590 | 
            +
                    position: relative;
         | 
| 591 | 
            +
                    display: inline-block;
         | 
| 592 | 
            +
                    width: 48px;
         | 
| 593 | 
            +
                    height: 24px;
         | 
| 594 | 
            +
                }
         | 
| 595 | 
            +
             | 
| 596 | 
            +
                .toggle-switch input {
         | 
| 597 | 
            +
                    opacity: 0;
         | 
| 598 | 
            +
                    width: 0;
         | 
| 599 | 
            +
                    height: 0;
         | 
| 600 | 
            +
                }
         | 
| 601 | 
            +
             | 
| 602 | 
            +
                .toggle-slider {
         | 
| 603 | 
            +
                    position: absolute;
         | 
| 604 | 
            +
                    cursor: pointer;
         | 
| 605 | 
            +
                    top: 0;
         | 
| 606 | 
            +
                    left: 0;
         | 
| 607 | 
            +
                    right: 0;
         | 
| 608 | 
            +
                    bottom: 0;
         | 
| 609 | 
            +
                    background-color: #ccc;
         | 
| 610 | 
            +
                    transition: .4s;
         | 
| 611 | 
            +
                    border-radius: 24px;
         | 
| 612 | 
            +
                }
         | 
| 613 | 
            +
             | 
| 614 | 
            +
                .toggle-slider:before {
         | 
| 615 | 
            +
                    position: absolute;
         | 
| 616 | 
            +
                    content: "";
         | 
| 617 | 
            +
                    height: 18px;
         | 
| 618 | 
            +
                    width: 18px;
         | 
| 619 | 
            +
                    left: 3px;
         | 
| 620 | 
            +
                    bottom: 3px;
         | 
| 621 | 
            +
                    background-color: white;
         | 
| 622 | 
            +
                    transition: .4s;
         | 
| 623 | 
            +
                    border-radius: 50%;
         | 
| 624 | 
            +
                }
         | 
| 625 | 
            +
             | 
| 626 | 
            +
                input:checked + .toggle-slider {
         | 
| 627 | 
            +
                    background-color: var(--primary-color);
         | 
| 628 | 
            +
                }
         | 
| 629 | 
            +
             | 
| 630 | 
            +
                input:checked + .toggle-slider:before {
         | 
| 631 | 
            +
                    transform: translateX(24px);
         | 
| 632 | 
            +
                }
         | 
| 633 | 
            +
             | 
| 634 | 
            +
                .toggle-label {
         | 
| 635 | 
            +
                    font-size: 14px;
         | 
| 636 | 
            +
                    color: var(--text-color);
         | 
| 637 | 
            +
                }
         | 
| 638 | 
            +
             | 
| 639 | 
            +
                .voters-table {
         | 
| 640 | 
            +
                    width: 100%;
         | 
| 641 | 
            +
                    border-collapse: collapse;
         | 
| 642 | 
            +
                }
         | 
| 643 | 
            +
             | 
| 644 | 
            +
                .voters-table th, .voters-table td {
         | 
| 645 | 
            +
                    padding: 12px 16px;
         | 
| 646 | 
            +
                    text-align: left;
         | 
| 647 | 
            +
                    border-bottom: 1px solid var(--border-color);
         | 
| 648 | 
            +
                }
         | 
| 649 | 
            +
                
         | 
| 650 | 
            +
                .voters-table tr:last-child td {
         | 
| 651 | 
            +
                    border-bottom: none;
         | 
| 652 | 
            +
                }
         | 
| 653 | 
            +
                
         | 
| 654 | 
            +
                .voters-table tr.current-user {
         | 
| 655 | 
            +
                    background-color: rgba(80, 70, 229, 0.1);
         | 
| 656 | 
            +
                }
         | 
| 657 | 
            +
                
         | 
| 658 | 
            +
                .voters-table tr.current-user td {
         | 
| 659 | 
            +
                    font-weight: 500;
         | 
| 660 | 
            +
                }
         | 
| 661 | 
            +
                
         | 
| 662 | 
            +
                .thank-you-message {
         | 
| 663 | 
            +
                    /* text-align: center; */
         | 
| 664 | 
            +
                    margin-top: 24px;
         | 
| 665 | 
            +
                    padding: 16px;
         | 
| 666 | 
            +
                    background-color: rgba(80, 200, 120, 0.1);
         | 
| 667 | 
            +
                    border-radius: var(--radius);
         | 
| 668 | 
            +
                    font-size: 16px;
         | 
| 669 | 
            +
                }
         | 
| 670 | 
            +
                
         | 
| 671 | 
            +
                @media (prefers-color-scheme: dark) {
         | 
| 672 | 
            +
                    .thank-you-message {
         | 
| 673 | 
            +
                        background-color: rgba(80, 200, 120, 0.2);
         | 
| 674 | 
            +
                    }
         | 
| 675 | 
            +
                }
         | 
| 676 | 
            +
             | 
| 677 | 
            +
                .voters-table th {
         | 
| 678 | 
            +
                    font-weight: 600;
         | 
| 679 | 
            +
                    color: var(--text-color);
         | 
| 680 | 
            +
                    background-color: var(--light-gray);
         | 
| 681 | 
            +
                }
         | 
| 682 | 
            +
             | 
| 683 | 
            +
                .voters-table tbody tr:hover {
         | 
| 684 | 
            +
                    background-color: var(--light-gray);
         | 
| 685 | 
            +
                }
         | 
| 686 | 
            +
             | 
| 687 | 
            +
                .login-prompt {
         | 
| 688 | 
            +
                    text-align: center;
         | 
| 689 | 
            +
                    padding: 24px;
         | 
| 690 | 
            +
                    background-color: var(--light-gray);
         | 
| 691 | 
            +
                    border-radius: var(--radius);
         | 
| 692 | 
            +
                    margin-top: 16px;
         | 
| 693 | 
            +
                }
         | 
| 694 | 
            +
             | 
| 695 | 
            +
                .no-voters-msg {
         | 
| 696 | 
            +
                    text-align: center;
         | 
| 697 | 
            +
                    padding: 24px;
         | 
| 698 | 
            +
                    color: var(--text-color);
         | 
| 699 | 
            +
                }
         | 
| 700 | 
            +
            </style>
         | 
| 701 | 
            +
            {% endblock %}
         | 
| 702 | 
            +
             | 
| 703 | 
            +
            {% block content %}
         | 
| 704 | 
            +
            <div class="tabs">
         | 
| 705 | 
            +
                <div class="tab active" data-tab="tts">TTS</div>
         | 
| 706 | 
            +
                <div class="tab" data-tab="conversational">Conversational</div>
         | 
| 707 | 
            +
                <div class="tab" data-tab="voters">Top Voters</div>
         | 
| 708 | 
            +
            </div>
         | 
| 709 | 
            +
             | 
| 710 | 
            +
            <div id="tts-tab" class="tab-content">
         | 
| 711 | 
            +
                <div class="view-toggle">
         | 
| 712 | 
            +
                    <div class="segmented-control">
         | 
| 713 | 
            +
                        <input type="radio" id="tts-public" name="tts-view" checked>
         | 
| 714 | 
            +
                        <label for="tts-public">Public</label>
         | 
| 715 | 
            +
                        <input type="radio" id="tts-personal" name="tts-view">
         | 
| 716 | 
            +
                        <label for="tts-personal">Personal</label>
         | 
| 717 | 
            +
                        <div class="slider"></div>
         | 
| 718 | 
            +
                    </div>
         | 
| 719 | 
            +
                </div>
         | 
| 720 | 
            +
             | 
| 721 | 
            +
                <!-- Historical timeline for TTS models - temporarily disabled -->
         | 
| 722 | 
            +
                <div id="tts-timeline-container" class="timeline-container" style="display: none;">
         | 
| 723 | 
            +
                    <div class="timeline-header">
         | 
| 724 | 
            +
                        <div class="timeline-title">
         | 
| 725 | 
            +
                            <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">
         | 
| 726 | 
            +
                                <circle cx="12" cy="12" r="10"></circle>
         | 
| 727 | 
            +
                                <polyline points="12 6 12 12 16 14"></polyline>
         | 
| 728 | 
            +
                            </svg>
         | 
| 729 | 
            +
                            Leaderboard History
         | 
| 730 | 
            +
                        </div>
         | 
| 731 | 
            +
                        
         | 
| 732 | 
            +
                        <div class="timeline-controls">
         | 
| 733 | 
            +
                            <select id="tts-date-select" class="timeline-select">
         | 
| 734 | 
            +
                                {% if formatted_tts_dates %}
         | 
| 735 | 
            +
                                    {% for date in formatted_tts_dates %}
         | 
| 736 | 
            +
                                        <option value="{{ tts_key_dates[loop.index0].strftime('%Y-%m-%d') }}">{{ date }}</option>
         | 
| 737 | 
            +
                                    {% endfor %}
         | 
| 738 | 
            +
                                {% else %}
         | 
| 739 | 
            +
                                    <option value="">No historical data</option>
         | 
| 740 | 
            +
                                {% endif %}
         | 
| 741 | 
            +
                            </select>
         | 
| 742 | 
            +
                            
         | 
| 743 | 
            +
                            <button id="tts-load-historical" class="timeline-button">
         | 
| 744 | 
            +
                                <span>Load</span>
         | 
| 745 | 
            +
                                <span id="tts-loading-spinner" class="loading-spinner"></span>
         | 
| 746 | 
            +
                            </button>
         | 
| 747 | 
            +
                        </div>
         | 
| 748 | 
            +
                    </div>
         | 
| 749 | 
            +
                    
         | 
| 750 | 
            +
                    {% if tts_key_dates and tts_key_dates|length > 1 %}
         | 
| 751 | 
            +
                    <div class="timeline-track">
         | 
| 752 | 
            +
                        <div id="tts-timeline-progress" class="timeline-progress" style="width: 0%"></div>
         | 
| 753 | 
            +
                        <div id="tts-timeline-marker" class="timeline-marker" style="left: 0%"></div>
         | 
| 754 | 
            +
                    </div>
         | 
| 755 | 
            +
                    <div class="timeline-dates">
         | 
| 756 | 
            +
                        <div>{{ tts_key_dates[0].strftime('%b %Y') }}</div>
         | 
| 757 | 
            +
                        <div>{{ tts_key_dates[-1].strftime('%b %Y') }}</div>
         | 
| 758 | 
            +
                    </div>
         | 
| 759 | 
            +
                    {% else %}
         | 
| 760 | 
            +
                    <div class="no-data">
         | 
| 761 | 
            +
                        <p>Not enough historical data available to show timeline.</p>
         | 
| 762 | 
            +
                    </div>
         | 
| 763 | 
            +
                    {% endif %}
         | 
| 764 | 
            +
                </div>
         | 
| 765 | 
            +
                
         | 
| 766 | 
            +
                <!-- Historical indicator - temporarily disabled -->
         | 
| 767 | 
            +
                <div class="historical-indicator" id="tts-historical-indicator" style="display: none;">
         | 
| 768 | 
            +
                    <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">
         | 
| 769 | 
            +
                        <circle cx="12" cy="12" r="10"></circle>
         | 
| 770 | 
            +
                        <polyline points="12 6 12 12 16 14"></polyline>
         | 
| 771 | 
            +
                    </svg>
         | 
| 772 | 
            +
                    <span id="tts-historical-date">Historical view</span>
         | 
| 773 | 
            +
                </div>
         | 
| 774 | 
            +
             | 
| 775 | 
            +
                <div id="tts-public-leaderboard" class="leaderboard-view active">
         | 
| 776 | 
            +
                    {% if tts_leaderboard and tts_leaderboard|length > 0 %}
         | 
| 777 | 
            +
                    <div class="leaderboard-container">
         | 
| 778 | 
            +
                        <div class="leaderboard-header">
         | 
| 779 | 
            +
                            <div>Rank</div>
         | 
| 780 | 
            +
                            <div>Model</div>
         | 
| 781 | 
            +
                            <div style="text-align: right">Win Rate</div>
         | 
| 782 | 
            +
                            <div style="text-align: right" class="total-votes-header">Total Votes</div>
         | 
| 783 | 
            +
                            <div style="text-align: right">ELO</div>
         | 
| 784 | 
            +
                        </div>
         | 
| 785 | 
            +
                        
         | 
| 786 | 
            +
                        {% for model in tts_leaderboard %}
         | 
| 787 | 
            +
                        <div class="leaderboard-row {{ model.tier }}">
         | 
| 788 | 
            +
                            <div class="rank">#{{ model.rank }}</div>
         | 
| 789 | 
            +
                            <div class="model-name">
         | 
| 790 | 
            +
                                <a href="{{ model.model_url }}" target="_blank" class="model-name-link">{{ model.name }}</a>
         | 
| 791 | 
            +
                                <div class="license-icon">
         | 
| 792 | 
            +
                                    {% if model.is_open %}
         | 
| 793 | 
            +
                                    <img src="{{ url_for('static', filename='open.svg') }}" alt="Open">
         | 
| 794 | 
            +
                                    <span class="tooltip">Open model</span>
         | 
| 795 | 
            +
                                    {% else %}
         | 
| 796 | 
            +
                                    <img src="{{ url_for('static', filename='closed.svg') }}" alt="Proprietary">
         | 
| 797 | 
            +
                                    <span class="tooltip">Proprietary model</span>
         | 
| 798 | 
            +
                                    {% endif %}
         | 
| 799 | 
            +
                                </div>
         | 
| 800 | 
            +
                            </div>
         | 
| 801 | 
            +
                            <div class="win-rate">{{ model.win_rate }}</div>
         | 
| 802 | 
            +
                            <div class="total-votes">{{ model.total_votes }}</div>
         | 
| 803 | 
            +
                            <div class="elo-score">{{ model.elo }}</div>
         | 
| 804 | 
            +
                        </div>
         | 
| 805 | 
            +
                        {% endfor %}
         | 
| 806 | 
            +
                    </div>
         | 
| 807 | 
            +
                    {% else %}
         | 
| 808 | 
            +
                    <div class="no-data">
         | 
| 809 | 
            +
                        <h3>No data available yet</h3>
         | 
| 810 | 
            +
                        <p>Be the first to vote and help build the leaderboard! Compare models in the arena to see how they stack up.</p>
         | 
| 811 | 
            +
                        <a href="{{ url_for('arena') }}" class="btn">Go to Arena</a>
         | 
| 812 | 
            +
                    </div>
         | 
| 813 | 
            +
                    {% endif %}
         | 
| 814 | 
            +
                </div>
         | 
| 815 | 
            +
                
         | 
| 816 | 
            +
                <div id="tts-personal-leaderboard" class="leaderboard-view" style="display: none;">
         | 
| 817 | 
            +
                    {% if current_user.is_authenticated and tts_personal_leaderboard and tts_personal_leaderboard|length > 0 %}
         | 
| 818 | 
            +
                    <div class="leaderboard-container">
         | 
| 819 | 
            +
                        <div class="leaderboard-header">
         | 
| 820 | 
            +
                            <div>Rank</div>
         | 
| 821 | 
            +
                            <div>Model</div>
         | 
| 822 | 
            +
                            <div style="text-align: right">Win Rate</div>
         | 
| 823 | 
            +
                            <div style="text-align: right" class="total-votes-header">Total Votes</div>
         | 
| 824 | 
            +
                            <div style="text-align: right">Wins</div>
         | 
| 825 | 
            +
                        </div>
         | 
| 826 | 
            +
                        
         | 
| 827 | 
            +
                        {% for model in tts_personal_leaderboard %}
         | 
| 828 | 
            +
                        <div class="leaderboard-row">
         | 
| 829 | 
            +
                            <div class="rank">#{{ model.rank }}</div>
         | 
| 830 | 
            +
                            <div class="model-name">
         | 
| 831 | 
            +
                                <a href="{{ model.model_url }}" target="_blank" class="model-name-link">{{ model.name }}</a>
         | 
| 832 | 
            +
                                <div class="license-icon">
         | 
| 833 | 
            +
                                    {% if model.is_open %}
         | 
| 834 | 
            +
                                    <img src="{{ url_for('static', filename='open.svg') }}" alt="Open">
         | 
| 835 | 
            +
                                    <span class="tooltip">Open model</span>
         | 
| 836 | 
            +
                                    {% else %}
         | 
| 837 | 
            +
                                    <img src="{{ url_for('static', filename='closed.svg') }}" alt="Proprietary">
         | 
| 838 | 
            +
                                    <span class="tooltip">Proprietary model</span>
         | 
| 839 | 
            +
                                    {% endif %}
         | 
| 840 | 
            +
                                </div>
         | 
| 841 | 
            +
                            </div>
         | 
| 842 | 
            +
                            <div class="win-rate">{{ model.win_rate }}</div>
         | 
| 843 | 
            +
                            <div class="total-votes">{{ model.total_votes }}</div>
         | 
| 844 | 
            +
                            <div class="elo-score">{{ model.wins }}</div>
         | 
| 845 | 
            +
                        </div>
         | 
| 846 | 
            +
                        {% endfor %}
         | 
| 847 | 
            +
                    </div>
         | 
| 848 | 
            +
                    {% else %}
         | 
| 849 | 
            +
                    <div class="no-data">
         | 
| 850 | 
            +
                        <h3>No personal data yet</h3>
         | 
| 851 | 
            +
                        <p>You haven't voted on any TTS models yet. Visit the arena to compare models and build your personal leaderboard.</p>
         | 
| 852 | 
            +
                        <a href="{{ url_for('arena') }}" class="btn">Go to Arena</a>
         | 
| 853 | 
            +
                    </div>
         | 
| 854 | 
            +
                    {% endif %}
         | 
| 855 | 
            +
                </div>
         | 
| 856 | 
            +
                
         | 
| 857 | 
            +
                <!-- Historical TTS leaderboard - temporarily disabled -->
         | 
| 858 | 
            +
                <div id="tts-historical-leaderboard" class="leaderboard-view" style="display: none;">
         | 
| 859 | 
            +
                    <!-- This will be populated dynamically with JavaScript -->
         | 
| 860 | 
            +
                    <div class="leaderboard-container">
         | 
| 861 | 
            +
                        <div class="leaderboard-header">
         | 
| 862 | 
            +
                            <div>Rank</div>
         | 
| 863 | 
            +
                            <div>Model</div>
         | 
| 864 | 
            +
                            <div style="text-align: right">Win Rate</div>
         | 
| 865 | 
            +
                            <div style="text-align: right" class="total-votes-header">Total Votes</div>
         | 
| 866 | 
            +
                            <div style="text-align: right">ELO</div>
         | 
| 867 | 
            +
                        </div>
         | 
| 868 | 
            +
                        <div id="tts-historical-rows">
         | 
| 869 | 
            +
                            <!-- Historical rows will be inserted here -->
         | 
| 870 | 
            +
                            <div class="no-data">
         | 
| 871 | 
            +
                                <p>Select a date and click "Load" to view historical data.</p>
         | 
| 872 | 
            +
                            </div>
         | 
| 873 | 
            +
                        </div>
         | 
| 874 | 
            +
                    </div>
         | 
| 875 | 
            +
                </div>
         | 
| 876 | 
            +
            </div>
         | 
| 877 | 
            +
             | 
| 878 | 
            +
            <div id="conversational-tab" class="tab-content" style="display: none;">
         | 
| 879 | 
            +
                <div class="view-toggle">
         | 
| 880 | 
            +
                    <div class="segmented-control">
         | 
| 881 | 
            +
                        <input type="radio" id="conversational-public" name="conversational-view" checked>
         | 
| 882 | 
            +
                        <label for="conversational-public">Public</label>
         | 
| 883 | 
            +
                        <input type="radio" id="conversational-personal" name="conversational-view">
         | 
| 884 | 
            +
                        <label for="conversational-personal">Personal</label>
         | 
| 885 | 
            +
                        <div class="slider"></div>
         | 
| 886 | 
            +
                    </div>
         | 
| 887 | 
            +
                </div>
         | 
| 888 | 
            +
             | 
| 889 | 
            +
                <!-- Historical timeline for Conversational models - temporarily disabled -->
         | 
| 890 | 
            +
                <div id="conversational-timeline-container" class="timeline-container" style="display: none;">
         | 
| 891 | 
            +
                    <div class="timeline-header">
         | 
| 892 | 
            +
                        <div class="timeline-title">
         | 
| 893 | 
            +
                            <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">
         | 
| 894 | 
            +
                                <circle cx="12" cy="12" r="10"></circle>
         | 
| 895 | 
            +
                                <polyline points="12 6 12 12 16 14"></polyline>
         | 
| 896 | 
            +
                            </svg>
         | 
| 897 | 
            +
                            Leaderboard History
         | 
| 898 | 
            +
                        </div>
         | 
| 899 | 
            +
                        
         | 
| 900 | 
            +
                        <div class="timeline-controls">
         | 
| 901 | 
            +
                            <select id="conversational-date-select" class="timeline-select">
         | 
| 902 | 
            +
                                {% if formatted_conversational_dates %}
         | 
| 903 | 
            +
                                    {% for date in formatted_conversational_dates %}
         | 
| 904 | 
            +
                                        <option value="{{ conversational_key_dates[loop.index0].strftime('%Y-%m-%d') }}">{{ date }}</option>
         | 
| 905 | 
            +
                                    {% endfor %}
         | 
| 906 | 
            +
                                {% else %}
         | 
| 907 | 
            +
                                    <option value="">No historical data</option>
         | 
| 908 | 
            +
                                {% endif %}
         | 
| 909 | 
            +
                            </select>
         | 
| 910 | 
            +
                            
         | 
| 911 | 
            +
                            <button id="conversational-load-historical" class="timeline-button">
         | 
| 912 | 
            +
                                <span>Load</span>
         | 
| 913 | 
            +
                                <span id="conversational-loading-spinner" class="loading-spinner"></span>
         | 
| 914 | 
            +
                            </button>
         | 
| 915 | 
            +
                        </div>
         | 
| 916 | 
            +
                    </div>
         | 
| 917 | 
            +
                    
         | 
| 918 | 
            +
                    {% if conversational_key_dates and conversational_key_dates|length > 1 %}
         | 
| 919 | 
            +
                    <div class="timeline-track">
         | 
| 920 | 
            +
                        <div id="conversational-timeline-progress" class="timeline-progress" style="width: 0%"></div>
         | 
| 921 | 
            +
                        <div id="conversational-timeline-marker" class="timeline-marker" style="left: 0%"></div>
         | 
| 922 | 
            +
                    </div>
         | 
| 923 | 
            +
                    <div class="timeline-dates">
         | 
| 924 | 
            +
                        <div>{{ conversational_key_dates[0].strftime('%b %Y') }}</div>
         | 
| 925 | 
            +
                        <div>{{ conversational_key_dates[-1].strftime('%b %Y') }}</div>
         | 
| 926 | 
            +
                    </div>
         | 
| 927 | 
            +
                    {% else %}
         | 
| 928 | 
            +
                    <div class="no-data">
         | 
| 929 | 
            +
                        <p>Not enough historical data available to show timeline.</p>
         | 
| 930 | 
            +
                    </div>
         | 
| 931 | 
            +
                    {% endif %}
         | 
| 932 | 
            +
                </div>
         | 
| 933 | 
            +
                
         | 
| 934 | 
            +
                <!-- Historical indicator - temporarily disabled -->
         | 
| 935 | 
            +
                <div class="historical-indicator" id="conversational-historical-indicator" style="display: none;">
         | 
| 936 | 
            +
                    <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">
         | 
| 937 | 
            +
                        <circle cx="12" cy="12" r="10"></circle>
         | 
| 938 | 
            +
                        <polyline points="12 6 12 12 16 14"></polyline>
         | 
| 939 | 
            +
                    </svg>
         | 
| 940 | 
            +
                    <span id="conversational-historical-date">Historical view</span>
         | 
| 941 | 
            +
                </div>
         | 
| 942 | 
            +
             | 
| 943 | 
            +
                <div id="conversational-public-leaderboard" class="leaderboard-view active">
         | 
| 944 | 
            +
                    {% if conversational_leaderboard and conversational_leaderboard|length > 0 %}
         | 
| 945 | 
            +
                    <div class="leaderboard-container">
         | 
| 946 | 
            +
                        <div class="leaderboard-header">
         | 
| 947 | 
            +
                            <div>Rank</div>
         | 
| 948 | 
            +
                            <div>Model</div>
         | 
| 949 | 
            +
                            <div style="text-align: right">Win Rate</div>
         | 
| 950 | 
            +
                            <div style="text-align: right" class="total-votes-header">Total Votes</div>
         | 
| 951 | 
            +
                            <div style="text-align: right">ELO</div>
         | 
| 952 | 
            +
                        </div>
         | 
| 953 | 
            +
                        
         | 
| 954 | 
            +
                        {% for model in conversational_leaderboard %}
         | 
| 955 | 
            +
                        <div class="leaderboard-row {{ model.tier }}">
         | 
| 956 | 
            +
                            <div class="rank">#{{ model.rank }}</div>
         | 
| 957 | 
            +
                            <div class="model-name">
         | 
| 958 | 
            +
                                {{ model.name }}
         | 
| 959 | 
            +
                                <div class="license-icon">
         | 
| 960 | 
            +
                                    {% if model.is_open %}
         | 
| 961 | 
            +
                                    <img src="{{ url_for('static', filename='open.svg') }}" alt="Open">
         | 
| 962 | 
            +
                                    <span class="tooltip">Open model</span>
         | 
| 963 | 
            +
                                    {% else %}
         | 
| 964 | 
            +
                                    <img src="{{ url_for('static', filename='closed.svg') }}" alt="Proprietary">
         | 
| 965 | 
            +
                                    <span class="tooltip">Proprietary model</span>
         | 
| 966 | 
            +
                                    {% endif %}
         | 
| 967 | 
            +
                                </div>
         | 
| 968 | 
            +
                            </div>
         | 
| 969 | 
            +
                            <div class="win-rate">{{ model.win_rate }}</div>
         | 
| 970 | 
            +
                            <div class="total-votes">{{ model.total_votes }}</div>
         | 
| 971 | 
            +
                            <div class="elo-score">{{ model.elo }}</div>
         | 
| 972 | 
            +
                        </div>
         | 
| 973 | 
            +
                        {% endfor %}
         | 
| 974 | 
            +
                    </div>
         | 
| 975 | 
            +
                    {% else %}
         | 
| 976 | 
            +
                    <div class="no-data">
         | 
| 977 | 
            +
                        <h3>No data available yet</h3>
         | 
| 978 | 
            +
                        <p>Be the first to vote and help build the conversational leaderboard! Compare models in the arena to see how they stack up.</p>
         | 
| 979 | 
            +
                        <a href="{{ url_for('arena') }}#conversational" class="btn">Go to Arena</a>
         | 
| 980 | 
            +
                    </div>
         | 
| 981 | 
            +
                    {% endif %}
         | 
| 982 | 
            +
                </div>
         | 
| 983 | 
            +
                
         | 
| 984 | 
            +
                <div id="conversational-personal-leaderboard" class="leaderboard-view" style="display: none;">
         | 
| 985 | 
            +
                    {% if current_user.is_authenticated and conversational_personal_leaderboard and conversational_personal_leaderboard|length > 0 %}
         | 
| 986 | 
            +
                    <div class="leaderboard-container">
         | 
| 987 | 
            +
                        <div class="leaderboard-header">
         | 
| 988 | 
            +
                            <div>Rank</div>
         | 
| 989 | 
            +
                            <div>Model</div>
         | 
| 990 | 
            +
                            <div style="text-align: right">Win Rate</div>
         | 
| 991 | 
            +
                            <div style="text-align: right" class="total-votes-header">Total Votes</div>
         | 
| 992 | 
            +
                            <div style="text-align: right">Wins</div>
         | 
| 993 | 
            +
                        </div>
         | 
| 994 | 
            +
                        
         | 
| 995 | 
            +
                        {% for model in conversational_personal_leaderboard %}
         | 
| 996 | 
            +
                        <div class="leaderboard-row">
         | 
| 997 | 
            +
                            <div class="rank">#{{ model.rank }}</div>
         | 
| 998 | 
            +
                            <div class="model-name">
         | 
| 999 | 
            +
                                {{ model.name }}
         | 
| 1000 | 
            +
                                <div class="license-icon">
         | 
| 1001 | 
            +
                                    {% if model.is_open %}
         | 
| 1002 | 
            +
                                    <img src="{{ url_for('static', filename='open.svg') }}" alt="Open">
         | 
| 1003 | 
            +
                                    <span class="tooltip">Open model</span>
         | 
| 1004 | 
            +
                                    {% else %}
         | 
| 1005 | 
            +
                                    <img src="{{ url_for('static', filename='closed.svg') }}" alt="Proprietary">
         | 
| 1006 | 
            +
                                    <span class="tooltip">Proprietary model</span>
         | 
| 1007 | 
            +
                                    {% endif %}
         | 
| 1008 | 
            +
                                </div>
         | 
| 1009 | 
            +
                            </div>
         | 
| 1010 | 
            +
                            <div class="win-rate">{{ model.win_rate }}</div>
         | 
| 1011 | 
            +
                            <div class="total-votes">{{ model.total_votes }}</div>
         | 
| 1012 | 
            +
                            <div class="elo-score">{{ model.wins }}</div>
         | 
| 1013 | 
            +
                        </div>
         | 
| 1014 | 
            +
                        {% endfor %}
         | 
| 1015 | 
            +
                    </div>
         | 
| 1016 | 
            +
                    {% else %}
         | 
| 1017 | 
            +
                    <div class="no-data">
         | 
| 1018 | 
            +
                        <h3>No personal data yet</h3>
         | 
| 1019 | 
            +
                        <p>You haven't voted on any conversational models yet. Visit the arena to compare models and build your personal leaderboard.</p>
         | 
| 1020 | 
            +
                        <a href="{{ url_for('arena') }}#conversational" class="btn">Go to Arena</a>
         | 
| 1021 | 
            +
                    </div>
         | 
| 1022 | 
            +
                    {% endif %}
         | 
| 1023 | 
            +
                </div>
         | 
| 1024 | 
            +
                
         | 
| 1025 | 
            +
                <!-- Historical Conversational leaderboard - temporarily disabled -->
         | 
| 1026 | 
            +
                <div id="conversational-historical-leaderboard" class="leaderboard-view" style="display: none;">
         | 
| 1027 | 
            +
                    <!-- This will be populated dynamically with JavaScript -->
         | 
| 1028 | 
            +
                    <div class="leaderboard-container">
         | 
| 1029 | 
            +
                        <div class="leaderboard-header">
         | 
| 1030 | 
            +
                            <div>Rank</div>
         | 
| 1031 | 
            +
                            <div>Model</div>
         | 
| 1032 | 
            +
                            <div style="text-align: right">Win Rate</div>
         | 
| 1033 | 
            +
                            <div style="text-align: right" class="total-votes-header">Total Votes</div>
         | 
| 1034 | 
            +
                            <div style="text-align: right">ELO</div>
         | 
| 1035 | 
            +
                        </div>
         | 
| 1036 | 
            +
                        <div id="conversational-historical-rows">
         | 
| 1037 | 
            +
                            <!-- Historical rows will be inserted here -->
         | 
| 1038 | 
            +
                            <div class="no-data">
         | 
| 1039 | 
            +
                                <p>Select a date and click "Load" to view historical data.</p>
         | 
| 1040 | 
            +
                            </div>
         | 
| 1041 | 
            +
                        </div>
         | 
| 1042 | 
            +
                    </div>
         | 
| 1043 | 
            +
                </div>
         | 
| 1044 | 
            +
            </div>
         | 
| 1045 | 
            +
             | 
| 1046 | 
            +
            <!-- Add Top Voters Tab -->
         | 
| 1047 | 
            +
            <div id="voters-tab" class="tab-content" style="display: none;">
         | 
| 1048 | 
            +
                <div class="voters-leaderboard">
         | 
| 1049 | 
            +
                    <div class="voters-leaderboard-header">
         | 
| 1050 | 
            +
                        <h2>Top Voters</h2>
         | 
| 1051 | 
            +
                        {% if current_user.is_authenticated %}
         | 
| 1052 | 
            +
                        <div class="visibility-toggle">
         | 
| 1053 | 
            +
                            <span class="toggle-label">Show me in leaderboard</span>
         | 
| 1054 | 
            +
                            <label class="toggle-switch">
         | 
| 1055 | 
            +
                                <input type="checkbox" id="visibility-toggle" {% if user_leaderboard_visibility %}checked{% endif %}>
         | 
| 1056 | 
            +
                                <span class="toggle-slider"></span>
         | 
| 1057 | 
            +
                            </label>
         | 
| 1058 | 
            +
                        </div>
         | 
| 1059 | 
            +
                        {% endif %}
         | 
| 1060 | 
            +
                    </div>
         | 
| 1061 | 
            +
                    
         | 
| 1062 | 
            +
                    {% if top_voters %}
         | 
| 1063 | 
            +
                    <div class="leaderboard-container">
         | 
| 1064 | 
            +
                        <table class="voters-table">
         | 
| 1065 | 
            +
                            <thead>
         | 
| 1066 | 
            +
                                <tr>
         | 
| 1067 | 
            +
                                    <th>Rank</th>
         | 
| 1068 | 
            +
                                    <th>Username</th>
         | 
| 1069 | 
            +
                                    <th>Total Votes</th>
         | 
| 1070 | 
            +
                                    <th>Joined</th>
         | 
| 1071 | 
            +
                                </tr>
         | 
| 1072 | 
            +
                            </thead>
         | 
| 1073 | 
            +
                            <tbody>
         | 
| 1074 | 
            +
                                {% for voter in top_voters %}
         | 
| 1075 | 
            +
                                <tr{% if current_user.is_authenticated and current_user.username == voter.username %} class="current-user"{% endif %}>
         | 
| 1076 | 
            +
                                    <td>{{ voter.rank }}</td>
         | 
| 1077 | 
            +
                                    <td><a href="https://huggingface.co/{{ voter.username }}" target="_blank" rel="noopener">{{ voter.username }}</a></td>
         | 
| 1078 | 
            +
                                    <td>{{ voter.vote_count }}</td>
         | 
| 1079 | 
            +
                                    <td>{{ voter.join_date }}</td>
         | 
| 1080 | 
            +
                                </tr>
         | 
| 1081 | 
            +
                                {% endfor %}
         | 
| 1082 | 
            +
                            </tbody>
         | 
| 1083 | 
            +
                        </table>
         | 
| 1084 | 
            +
                    </div>
         | 
| 1085 | 
            +
                    {% else %}
         | 
| 1086 | 
            +
                    <div class="no-data">
         | 
| 1087 | 
            +
                        <p>No voters data available yet. Start voting to appear on the leaderboard!</p>
         | 
| 1088 | 
            +
                    </div>
         | 
| 1089 | 
            +
                    {% endif %}
         | 
| 1090 | 
            +
                    
         | 
| 1091 | 
            +
                    {% if top_voters %}
         | 
| 1092 | 
            +
                    <div class="thank-you-message">
         | 
| 1093 | 
            +
                        <p>Thank you to all our voters for helping improve TTS Arena! Your contributions make this community better.</p>
         | 
| 1094 | 
            +
                    </div>
         | 
| 1095 | 
            +
                    {% endif %}
         | 
| 1096 | 
            +
                    
         | 
| 1097 | 
            +
                    {% if not current_user.is_authenticated %}
         | 
| 1098 | 
            +
                    <div class="login-prompt">
         | 
| 1099 | 
            +
                        <p>Log in to appear on the leaderboard and track your voting stats!</p>
         | 
| 1100 | 
            +
                        <div class="button-container" style="margin-top: 16px;">
         | 
| 1101 | 
            +
                            <a href="{{ url_for('auth.login') }}" class="btn">Log In</a>
         | 
| 1102 | 
            +
                        </div>
         | 
| 1103 | 
            +
                    </div>
         | 
| 1104 | 
            +
                    {% endif %}
         | 
| 1105 | 
            +
                </div>
         | 
| 1106 | 
            +
            </div>
         | 
| 1107 | 
            +
             | 
| 1108 | 
            +
            <div class="login-prompt">
         | 
| 1109 | 
            +
                <div class="login-prompt-content">
         | 
| 1110 | 
            +
                    <div class="login-prompt-close">×</div>
         | 
| 1111 | 
            +
                    <h3>Login Required</h3>
         | 
| 1112 | 
            +
                    <p>You need to be logged in to view your personal leaderboard.</p>
         | 
| 1113 | 
            +
                    <a href="{{ url_for('auth.login', next=request.path) }}" class="btn">Login with Hugging Face</a>
         | 
| 1114 | 
            +
                </div>
         | 
| 1115 | 
            +
            </div>
         | 
| 1116 | 
            +
             | 
| 1117 | 
            +
            <!-- Pass auth status via data attribute -->
         | 
| 1118 | 
            +
            <div id="auth-data" data-is-logged-in="{% if current_user.is_authenticated %}true{% else %}false{% endif %}"></div>
         | 
| 1119 | 
            +
             | 
| 1120 | 
            +
            <script>
         | 
| 1121 | 
            +
                // Set auth status from server-side
         | 
| 1122 | 
            +
                var isLoggedIn = document.getElementById('auth-data').dataset.isLoggedIn === 'true';
         | 
| 1123 | 
            +
            </script>
         | 
| 1124 | 
            +
             | 
| 1125 | 
            +
            <script>
         | 
| 1126 | 
            +
                document.addEventListener('DOMContentLoaded', function() {
         | 
| 1127 | 
            +
                    // Initialize slider positions
         | 
| 1128 | 
            +
                    const ttsSlider = document.querySelector('#tts-tab .slider');
         | 
| 1129 | 
            +
                    const convSlider = document.querySelector('#conversational-tab .slider');
         | 
| 1130 | 
            +
                    
         | 
| 1131 | 
            +
                    // Function to position sliders based on selected radio
         | 
| 1132 | 
            +
                    function positionSliders() {
         | 
| 1133 | 
            +
                        // Position TTS slider
         | 
| 1134 | 
            +
                        if (ttsSlider) {
         | 
| 1135 | 
            +
                            const ttsSelectedRadio = document.querySelector('#tts-tab input[name="tts-view"]:checked');
         | 
| 1136 | 
            +
                            if (ttsSelectedRadio) {
         | 
| 1137 | 
            +
                                const ttsSelectedLabel = document.querySelector(`label[for="${ttsSelectedRadio.id}"]`);
         | 
| 1138 | 
            +
                                ttsSlider.style.width = `${ttsSelectedLabel.offsetWidth}px`;
         | 
| 1139 | 
            +
                                ttsSlider.style.transform = `translateX(${ttsSelectedLabel.offsetLeft - 4}px)`;
         | 
| 1140 | 
            +
                            }
         | 
| 1141 | 
            +
                        }
         | 
| 1142 | 
            +
                        
         | 
| 1143 | 
            +
                        // Position Conversational slider
         | 
| 1144 | 
            +
                        if (convSlider) {
         | 
| 1145 | 
            +
                            const convSelectedRadio = document.querySelector('#conversational-tab input[name="conversational-view"]:checked');
         | 
| 1146 | 
            +
                            if (convSelectedRadio) {
         | 
| 1147 | 
            +
                                const convSelectedLabel = document.querySelector(`label[for="${convSelectedRadio.id}"]`);
         | 
| 1148 | 
            +
                                convSlider.style.width = `${convSelectedLabel.offsetWidth}px`;
         | 
| 1149 | 
            +
                                convSlider.style.transform = `translateX(${convSelectedLabel.offsetLeft - 4}px)`;
         | 
| 1150 | 
            +
                            }
         | 
| 1151 | 
            +
                        }
         | 
| 1152 | 
            +
                    }
         | 
| 1153 | 
            +
                    
         | 
| 1154 | 
            +
                    // Position sliders on load
         | 
| 1155 | 
            +
                    positionSliders();
         | 
| 1156 | 
            +
                    
         | 
| 1157 | 
            +
                    // Tab switching
         | 
| 1158 | 
            +
                    const tabs = document.querySelectorAll('.tab');
         | 
| 1159 | 
            +
                    const tabContents = document.querySelectorAll('.tab-content');
         | 
| 1160 | 
            +
                    
         | 
| 1161 | 
            +
                    // Check URL hash for direct tab access
         | 
| 1162 | 
            +
                    function checkHashAndSetTab() {
         | 
| 1163 | 
            +
                        const hash = window.location.hash.toLowerCase();
         | 
| 1164 | 
            +
                        if (hash === '#conversational') {
         | 
| 1165 | 
            +
                            // Switch to conversational tab
         | 
| 1166 | 
            +
                            tabs.forEach(t => t.classList.remove('active'));
         | 
| 1167 | 
            +
                            tabContents.forEach(c => c.style.display = 'none');
         | 
| 1168 | 
            +
                            
         | 
| 1169 | 
            +
                            document.querySelector('.tab[data-tab="conversational"]').classList.add('active');
         | 
| 1170 | 
            +
                            document.getElementById('conversational-tab').style.display = 'block';
         | 
| 1171 | 
            +
                            
         | 
| 1172 | 
            +
                            // Ensure sliders are positioned correctly
         | 
| 1173 | 
            +
                            setTimeout(positionSliders, 50);
         | 
| 1174 | 
            +
                        } else if (hash === '#tts' || hash === '') {
         | 
| 1175 | 
            +
                            // Switch to TTS tab (default)
         | 
| 1176 | 
            +
                            tabs.forEach(t => t.classList.remove('active'));
         | 
| 1177 | 
            +
                            tabContents.forEach(c => c.style.display = 'none');
         | 
| 1178 | 
            +
                            
         | 
| 1179 | 
            +
                            document.querySelector('.tab[data-tab="tts"]').classList.add('active');
         | 
| 1180 | 
            +
                            document.getElementById('tts-tab').style.display = 'block';
         | 
| 1181 | 
            +
                            
         | 
| 1182 | 
            +
                            // Ensure sliders are positioned correctly
         | 
| 1183 | 
            +
                            setTimeout(positionSliders, 50);
         | 
| 1184 | 
            +
                        }
         | 
| 1185 | 
            +
                    }
         | 
| 1186 | 
            +
                    
         | 
| 1187 | 
            +
                    // Check hash on page load
         | 
| 1188 | 
            +
                    checkHashAndSetTab();
         | 
| 1189 | 
            +
                    
         | 
| 1190 | 
            +
                    // Listen for hash changes
         | 
| 1191 | 
            +
                    window.addEventListener('hashchange', checkHashAndSetTab);
         | 
| 1192 | 
            +
                    
         | 
| 1193 | 
            +
                    tabs.forEach(tab => {
         | 
| 1194 | 
            +
                        tab.addEventListener('click', function() {
         | 
| 1195 | 
            +
                            const tabId = this.dataset.tab;
         | 
| 1196 | 
            +
                            
         | 
| 1197 | 
            +
                            // Update URL hash without page reload
         | 
| 1198 | 
            +
                            history.replaceState(null, null, `#${tabId}`);
         | 
| 1199 | 
            +
                            
         | 
| 1200 | 
            +
                            // Remove active class from all tabs and hide all contents
         | 
| 1201 | 
            +
                            tabs.forEach(t => t.classList.remove('active'));
         | 
| 1202 | 
            +
                            tabContents.forEach(c => c.style.display = 'none');
         | 
| 1203 | 
            +
                            
         | 
| 1204 | 
            +
                            // Add active class to clicked tab and show corresponding content
         | 
| 1205 | 
            +
                            this.classList.add('active');
         | 
| 1206 | 
            +
                            document.getElementById(tabId + '-tab').style.display = 'block';
         | 
| 1207 | 
            +
                            
         | 
| 1208 | 
            +
                            // Position sliders after tab switch
         | 
| 1209 | 
            +
                            setTimeout(positionSliders, 0);
         | 
| 1210 | 
            +
                        });
         | 
| 1211 | 
            +
                    });
         | 
| 1212 | 
            +
                    
         | 
| 1213 | 
            +
                    // View toggle functionality
         | 
| 1214 | 
            +
                    const viewToggles = document.querySelectorAll('.segmented-control input[type="radio"]');
         | 
| 1215 | 
            +
                    const loginPrompt = document.querySelector('.login-prompt');
         | 
| 1216 | 
            +
                    const loginPromptClose = document.querySelector('.login-prompt-close');
         | 
| 1217 | 
            +
                    
         | 
| 1218 | 
            +
                    viewToggles.forEach(toggle => {
         | 
| 1219 | 
            +
                        toggle.addEventListener('change', function() {
         | 
| 1220 | 
            +
                            const view = this.id.split('-')[1]; // 'public', 'personal', or 'historical'
         | 
| 1221 | 
            +
                            const tabId = this.closest('.tab-content').id.split('-')[0]; // 'tts' or 'conversational'
         | 
| 1222 | 
            +
                            
         | 
| 1223 | 
            +
                            if (view === 'personal' && !isLoggedIn) {
         | 
| 1224 | 
            +
                                // Show login prompt
         | 
| 1225 | 
            +
                                loginPrompt.style.display = 'flex';
         | 
| 1226 | 
            +
                                // Reset the radio button to public
         | 
| 1227 | 
            +
                                document.getElementById(`${tabId}-public`).checked = true;
         | 
| 1228 | 
            +
                                return;
         | 
| 1229 | 
            +
                            }
         | 
| 1230 | 
            +
                            
         | 
| 1231 | 
            +
                            // Position the slider using our function
         | 
| 1232 | 
            +
                            positionSliders();
         | 
| 1233 | 
            +
                            
         | 
| 1234 | 
            +
                            // Show corresponding leaderboard
         | 
| 1235 | 
            +
                            const leaderboardViews = document.querySelectorAll(`#${tabId}-tab .leaderboard-view`);
         | 
| 1236 | 
            +
                            leaderboardViews.forEach(v => {
         | 
| 1237 | 
            +
                                v.style.display = 'none';
         | 
| 1238 | 
            +
                                v.classList.remove('active');
         | 
| 1239 | 
            +
                            });
         | 
| 1240 | 
            +
                            const activeView = document.getElementById(`${tabId}-${view}-leaderboard`);
         | 
| 1241 | 
            +
                            activeView.style.display = 'block';
         | 
| 1242 | 
            +
                            activeView.classList.add('active');
         | 
| 1243 | 
            +
                            
         | 
| 1244 | 
            +
                            // Toggle timeline visibility - temporarily disabled
         | 
| 1245 | 
            +
                            /* 
         | 
| 1246 | 
            +
                            const timelineContainer = document.getElementById(`${tabId}-timeline-container`);
         | 
| 1247 | 
            +
                            if (timelineContainer) {
         | 
| 1248 | 
            +
                                timelineContainer.style.display = view === 'historical' ? 'block' : 'none';
         | 
| 1249 | 
            +
                            }
         | 
| 1250 | 
            +
                            */
         | 
| 1251 | 
            +
                        });
         | 
| 1252 | 
            +
                    });
         | 
| 1253 | 
            +
                    
         | 
| 1254 | 
            +
                    // Close login prompt
         | 
| 1255 | 
            +
                    if (loginPromptClose) {
         | 
| 1256 | 
            +
                        loginPromptClose.addEventListener('click', function() {
         | 
| 1257 | 
            +
                            loginPrompt.style.display = 'none';
         | 
| 1258 | 
            +
                        });
         | 
| 1259 | 
            +
                    }
         | 
| 1260 | 
            +
                    
         | 
| 1261 | 
            +
                    // Historical data functionality - temporarily disabled
         | 
| 1262 | 
            +
                    /* 
         | 
| 1263 | 
            +
                    function setupHistoricalView(modelType) {
         | 
| 1264 | 
            +
                        const loadButton = document.getElementById(`${modelType}-load-historical`);
         | 
| 1265 | 
            +
                        const dateSelect = document.getElementById(`${modelType}-date-select`);
         | 
| 1266 | 
            +
                        const historicalRows = document.getElementById(`${modelType}-historical-rows`);
         | 
| 1267 | 
            +
                        const loadingSpinner = document.getElementById(`${modelType}-loading-spinner`);
         | 
| 1268 | 
            +
                        const historicalIndicator = document.getElementById(`${modelType}-historical-indicator`);
         | 
| 1269 | 
            +
                        const historicalDate = document.getElementById(`${modelType}-historical-date`);
         | 
| 1270 | 
            +
                        const timelineMarker = document.getElementById(`${modelType}-timeline-marker`);
         | 
| 1271 | 
            +
                        const timelineProgress = document.getElementById(`${modelType}-timeline-progress`);
         | 
| 1272 | 
            +
                        
         | 
| 1273 | 
            +
                        if (!loadButton || !dateSelect || !historicalRows) return;
         | 
| 1274 | 
            +
                        
         | 
| 1275 | 
            +
                        loadButton.addEventListener('click', function() {
         | 
| 1276 | 
            +
                            const selectedDate = dateSelect.value;
         | 
| 1277 | 
            +
                            if (!selectedDate) return;
         | 
| 1278 | 
            +
                            
         | 
| 1279 | 
            +
                            // Show loading state
         | 
| 1280 | 
            +
                            loadingSpinner.classList.add('loading');
         | 
| 1281 | 
            +
                            
         | 
| 1282 | 
            +
                            // Fetch historical data
         | 
| 1283 | 
            +
                            fetch(`/api/historical-leaderboard/${modelType}?date=${selectedDate}`)
         | 
| 1284 | 
            +
                                .then(response => {
         | 
| 1285 | 
            +
                                    if (!response.ok) {
         | 
| 1286 | 
            +
                                        throw new Error('Network response was not ok');
         | 
| 1287 | 
            +
                                    }
         | 
| 1288 | 
            +
                                    return response.json();
         | 
| 1289 | 
            +
                                })
         | 
| 1290 | 
            +
                                .then(data => {
         | 
| 1291 | 
            +
                                    // Update historical indicator
         | 
| 1292 | 
            +
                                    historicalIndicator.classList.add('active');
         | 
| 1293 | 
            +
                                    historicalDate.textContent = data.date;
         | 
| 1294 | 
            +
                                    
         | 
| 1295 | 
            +
                                    // Clear existing rows
         | 
| 1296 | 
            +
                                    historicalRows.innerHTML = '';
         | 
| 1297 | 
            +
                                    
         | 
| 1298 | 
            +
                                    if (data.leaderboard && data.leaderboard.length > 0) {
         | 
| 1299 | 
            +
                                        // Add new rows
         | 
| 1300 | 
            +
                                        data.leaderboard.forEach(model => {
         | 
| 1301 | 
            +
                                            const row = document.createElement('div');
         | 
| 1302 | 
            +
                                            row.className = `leaderboard-row ${model.tier || ''}`;
         | 
| 1303 | 
            +
                                            
         | 
| 1304 | 
            +
                                            row.innerHTML = `
         | 
| 1305 | 
            +
                                                <div class="rank">#${model.rank}</div>
         | 
| 1306 | 
            +
                                                <div class="model-name">
         | 
| 1307 | 
            +
                                                    ${model.model_url ? 
         | 
| 1308 | 
            +
                                                        `<a href="${model.model_url}" target="_blank" class="model-name-link">${model.name}</a>` : 
         | 
| 1309 | 
            +
                                                        model.name
         | 
| 1310 | 
            +
                                                    }
         | 
| 1311 | 
            +
                                                    <div class="license-icon">
         | 
| 1312 | 
            +
                                                        <img src="${model.is_open ? '/static/open.svg' : '/static/closed.svg'}" alt="${model.is_open ? 'Open' : 'Proprietary'}">
         | 
| 1313 | 
            +
                                                        <span class="tooltip">${model.is_open ? 'Open model' : 'Proprietary model'}</span>
         | 
| 1314 | 
            +
                                                    </div>
         | 
| 1315 | 
            +
                                                </div>
         | 
| 1316 | 
            +
                                                <div class="win-rate">${model.win_rate}</div>
         | 
| 1317 | 
            +
                                                <div class="total-votes">${model.total_votes}</div>
         | 
| 1318 | 
            +
                                                <div class="elo-score">${model.elo}</div>
         | 
| 1319 | 
            +
                                            `;
         | 
| 1320 | 
            +
                                            
         | 
| 1321 | 
            +
                                            historicalRows.appendChild(row);
         | 
| 1322 | 
            +
                                        });
         | 
| 1323 | 
            +
                                    } else {
         | 
| 1324 | 
            +
                                        // Show no data message
         | 
| 1325 | 
            +
                                        historicalRows.innerHTML = `
         | 
| 1326 | 
            +
                                            <div class="no-data">
         | 
| 1327 | 
            +
                                                <p>No data available for this date.</p>
         | 
| 1328 | 
            +
                                            </div>
         | 
| 1329 | 
            +
                                        `;
         | 
| 1330 | 
            +
                                    }
         | 
| 1331 | 
            +
                                    
         | 
| 1332 | 
            +
                                    // Update timeline marker position based on selected date
         | 
| 1333 | 
            +
                                    updateTimelinePosition(modelType, selectedDate);
         | 
| 1334 | 
            +
                                })
         | 
| 1335 | 
            +
                                .catch(error => {
         | 
| 1336 | 
            +
                                    console.error('Error fetching historical data:', error);
         | 
| 1337 | 
            +
                                    historicalRows.innerHTML = `
         | 
| 1338 | 
            +
                                        <div class="no-data">
         | 
| 1339 | 
            +
                                            <p>Error loading data. Please try again.</p>
         | 
| 1340 | 
            +
                                        </div>
         | 
| 1341 | 
            +
                                    `;
         | 
| 1342 | 
            +
                                })
         | 
| 1343 | 
            +
                                .finally(() => {
         | 
| 1344 | 
            +
                                    // Hide loading state
         | 
| 1345 | 
            +
                                    loadingSpinner.classList.remove('loading');
         | 
| 1346 | 
            +
                                });
         | 
| 1347 | 
            +
                        });
         | 
| 1348 | 
            +
                        
         | 
| 1349 | 
            +
                        // Update the timeline marker position
         | 
| 1350 | 
            +
                        function updateTimelinePosition(modelType, selectedDate) {
         | 
| 1351 | 
            +
                            const timeline = document.querySelector(`#${modelType}-timeline-container .timeline-track`);
         | 
| 1352 | 
            +
                            if (!timeline || !timelineMarker || !timelineProgress) return;
         | 
| 1353 | 
            +
                            
         | 
| 1354 | 
            +
                            // Get all the dates
         | 
| 1355 | 
            +
                            const options = Array.from(dateSelect.options);
         | 
| 1356 | 
            +
                            const dateValues = options.map(option => option.value);
         | 
| 1357 | 
            +
                            const selectedIndex = dateValues.indexOf(selectedDate);
         | 
| 1358 | 
            +
                            
         | 
| 1359 | 
            +
                            if (selectedIndex >= 0 && dateValues.length > 1) {
         | 
| 1360 | 
            +
                                // Calculate percentage position (0 to 100)
         | 
| 1361 | 
            +
                                const position = (selectedIndex / (dateValues.length - 1)) * 100;
         | 
| 1362 | 
            +
                                
         | 
| 1363 | 
            +
                                // Update marker and progress
         | 
| 1364 | 
            +
                                timelineMarker.style.left = `${position}%`;
         | 
| 1365 | 
            +
                                timelineProgress.style.width = `${position}%`;
         | 
| 1366 | 
            +
                            }
         | 
| 1367 | 
            +
                        }
         | 
| 1368 | 
            +
                    }
         | 
| 1369 | 
            +
                    
         | 
| 1370 | 
            +
                    // Setup historical view for both model types
         | 
| 1371 | 
            +
                    setupHistoricalView('tts');
         | 
| 1372 | 
            +
                    setupHistoricalView('conversational');
         | 
| 1373 | 
            +
                    */
         | 
| 1374 | 
            +
                    
         | 
| 1375 | 
            +
                    // Final positioning after all DOM operations are complete
         | 
| 1376 | 
            +
                    setTimeout(positionSliders, 100);
         | 
| 1377 | 
            +
                    
         | 
| 1378 | 
            +
                    // Reposition sliders on window resize
         | 
| 1379 | 
            +
                    window.addEventListener('resize', function() {
         | 
| 1380 | 
            +
                        positionSliders();
         | 
| 1381 | 
            +
                    });
         | 
| 1382 | 
            +
             | 
| 1383 | 
            +
                    // Add to the end of the script section  
         | 
| 1384 | 
            +
                    document.getElementById('visibility-toggle')?.addEventListener('change', function() {
         | 
| 1385 | 
            +
                        // Send request to toggle visibility
         | 
| 1386 | 
            +
                        fetch('/api/toggle-leaderboard-visibility', {
         | 
| 1387 | 
            +
                            method: 'POST',
         | 
| 1388 | 
            +
                            headers: {
         | 
| 1389 | 
            +
                                'Content-Type': 'application/json',
         | 
| 1390 | 
            +
                            },
         | 
| 1391 | 
            +
                            credentials: 'same-origin'
         | 
| 1392 | 
            +
                        })
         | 
| 1393 | 
            +
                        .then(response => response.json())
         | 
| 1394 | 
            +
                        .then(data => {
         | 
| 1395 | 
            +
                            if (data.success) {
         | 
| 1396 | 
            +
                                // Use the toast function from base.html
         | 
| 1397 | 
            +
                                openToast(data.message, 'success');
         | 
| 1398 | 
            +
                            } else {
         | 
| 1399 | 
            +
                                openToast(data.error || 'Failed to update visibility', 'error');
         | 
| 1400 | 
            +
                                // Revert the toggle state if there was an error
         | 
| 1401 | 
            +
                                this.checked = !this.checked;
         | 
| 1402 | 
            +
                            }
         | 
| 1403 | 
            +
                        })
         | 
| 1404 | 
            +
                        .catch(error => {
         | 
| 1405 | 
            +
                            console.error('Error:', error);
         | 
| 1406 | 
            +
                            openToast('Failed to update visibility', 'error');
         | 
| 1407 | 
            +
                            // Revert the toggle state if there was an error
         | 
| 1408 | 
            +
                            this.checked = !this.checked;
         | 
| 1409 | 
            +
                        });
         | 
| 1410 | 
            +
                    });
         | 
| 1411 | 
            +
                });
         | 
| 1412 | 
            +
            </script>
         | 
| 1413 | 
            +
            {% endblock %} 
         | 
    	
        templates/turnstile.html
    ADDED
    
    | @@ -0,0 +1,277 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            <!DOCTYPE html>
         | 
| 2 | 
            +
            <html lang="en">
         | 
| 3 | 
            +
            <head>
         | 
| 4 | 
            +
                <meta charset="UTF-8">
         | 
| 5 | 
            +
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
         | 
| 6 | 
            +
                <title>Verification Required - TTS Arena</title>
         | 
| 7 | 
            +
                <link rel="preconnect" href="https://fonts.googleapis.com">
         | 
| 8 | 
            +
                <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
         | 
| 9 | 
            +
                <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
         | 
| 10 | 
            +
                <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
         | 
| 11 | 
            +
                <style>
         | 
| 12 | 
            +
                    :root {
         | 
| 13 | 
            +
                        --primary-color: #5046e5;
         | 
| 14 | 
            +
                        --secondary-color: #f0f0f0;
         | 
| 15 | 
            +
                        --text-color: #333;
         | 
| 16 | 
            +
                        --light-gray: #f5f5f5;
         | 
| 17 | 
            +
                        --border-color: #e0e0e0;
         | 
| 18 | 
            +
                        --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
         | 
| 19 | 
            +
                        --radius: 8px;
         | 
| 20 | 
            +
                    }
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                    * {
         | 
| 23 | 
            +
                        margin: 0;
         | 
| 24 | 
            +
                        padding: 0;
         | 
| 25 | 
            +
                        box-sizing: border-box;
         | 
| 26 | 
            +
                        font-family: 'Inter', sans-serif;
         | 
| 27 | 
            +
                    }
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                    body {
         | 
| 30 | 
            +
                        color: var(--text-color);
         | 
| 31 | 
            +
                        display: flex;
         | 
| 32 | 
            +
                        min-height: 100vh;
         | 
| 33 | 
            +
                        justify-content: center;
         | 
| 34 | 
            +
                        align-items: center;
         | 
| 35 | 
            +
                        background-color: var(--light-gray);
         | 
| 36 | 
            +
                    }
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                    .verification-container {
         | 
| 39 | 
            +
                        background-color: white;
         | 
| 40 | 
            +
                        border-radius: var(--radius);
         | 
| 41 | 
            +
                        box-shadow: var(--shadow);
         | 
| 42 | 
            +
                        padding: 32px;
         | 
| 43 | 
            +
                        width: 100%;
         | 
| 44 | 
            +
                        max-width: 450px;
         | 
| 45 | 
            +
                        text-align: center;
         | 
| 46 | 
            +
                    }
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                    .logo {
         | 
| 49 | 
            +
                        font-size: 24px;
         | 
| 50 | 
            +
                        font-weight: 700;
         | 
| 51 | 
            +
                        margin-bottom: 24px;
         | 
| 52 | 
            +
                        color: var(--primary-color);
         | 
| 53 | 
            +
                    }
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                    h1 {
         | 
| 56 | 
            +
                        font-size: 20px;
         | 
| 57 | 
            +
                        margin-bottom: 16px;
         | 
| 58 | 
            +
                        color: var(--text-color);
         | 
| 59 | 
            +
                    }
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                    p {
         | 
| 62 | 
            +
                        margin-bottom: 24px;
         | 
| 63 | 
            +
                        color: #666;
         | 
| 64 | 
            +
                        line-height: 1.5;
         | 
| 65 | 
            +
                    }
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                    .turnstile-container {
         | 
| 68 | 
            +
                        display: flex;
         | 
| 69 | 
            +
                        justify-content: center;
         | 
| 70 | 
            +
                        margin-bottom: 24px;
         | 
| 71 | 
            +
                    }
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                    .btn {
         | 
| 74 | 
            +
                        background-color: var(--primary-color);
         | 
| 75 | 
            +
                        color: white;
         | 
| 76 | 
            +
                        border: none;
         | 
| 77 | 
            +
                        border-radius: var(--radius);
         | 
| 78 | 
            +
                        padding: 12px 24px;
         | 
| 79 | 
            +
                        font-weight: 500;
         | 
| 80 | 
            +
                        cursor: pointer;
         | 
| 81 | 
            +
                        font-size: 1rem;
         | 
| 82 | 
            +
                        transition: background-color 0.2s;
         | 
| 83 | 
            +
                        width: 100%;
         | 
| 84 | 
            +
                    }
         | 
| 85 | 
            +
             | 
| 86 | 
            +
                    .btn:hover {
         | 
| 87 | 
            +
                        background-color: #4038c7;
         | 
| 88 | 
            +
                    }
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                    .btn:disabled {
         | 
| 91 | 
            +
                        background-color: #a8a4e0;
         | 
| 92 | 
            +
                        cursor: not-allowed;
         | 
| 93 | 
            +
                    }
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                    .loader {
         | 
| 96 | 
            +
                        display: none;
         | 
| 97 | 
            +
                        width: 24px;
         | 
| 98 | 
            +
                        height: 24px;
         | 
| 99 | 
            +
                        border: 3px solid rgba(255, 255, 255, 0.3);
         | 
| 100 | 
            +
                        border-radius: 50%;
         | 
| 101 | 
            +
                        border-top-color: white;
         | 
| 102 | 
            +
                        animation: spin 1s ease infinite;
         | 
| 103 | 
            +
                        margin: 0 auto;
         | 
| 104 | 
            +
                    }
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                    @keyframes spin {
         | 
| 107 | 
            +
                        to {
         | 
| 108 | 
            +
                            transform: rotate(360deg);
         | 
| 109 | 
            +
                        }
         | 
| 110 | 
            +
                    }
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                    .btn.loading .btn-text {
         | 
| 113 | 
            +
                        display: none;
         | 
| 114 | 
            +
                        font-size: 1rem;
         | 
| 115 | 
            +
                    }
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                    .btn.loading .loader {
         | 
| 118 | 
            +
                        display: inline-block;
         | 
| 119 | 
            +
                    }
         | 
| 120 | 
            +
             | 
| 121 | 
            +
                    .status-message {
         | 
| 122 | 
            +
                        margin-top: 16px;
         | 
| 123 | 
            +
                        font-size: 14px;
         | 
| 124 | 
            +
                        color: #666;
         | 
| 125 | 
            +
                        display: none;
         | 
| 126 | 
            +
                    }
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                    /* Dark mode styles */
         | 
| 129 | 
            +
                    @media (prefers-color-scheme: dark) {
         | 
| 130 | 
            +
                        :root {
         | 
| 131 | 
            +
                            --primary-color: #6c63ff;
         | 
| 132 | 
            +
                            --secondary-color: #2d2b38;
         | 
| 133 | 
            +
                            --text-color: #e0e0e0;
         | 
| 134 | 
            +
                            --light-gray: #1e1e24;
         | 
| 135 | 
            +
                            --border-color: #3a3a45;
         | 
| 136 | 
            +
                            --shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
         | 
| 137 | 
            +
                        }
         | 
| 138 | 
            +
                        
         | 
| 139 | 
            +
                        body {
         | 
| 140 | 
            +
                            background-color: #121218;
         | 
| 141 | 
            +
                        }
         | 
| 142 | 
            +
                        
         | 
| 143 | 
            +
                        .verification-container {
         | 
| 144 | 
            +
                            background-color: var(--light-gray);
         | 
| 145 | 
            +
                            border: 1px solid var(--border-color);
         | 
| 146 | 
            +
                        }
         | 
| 147 | 
            +
                        
         | 
| 148 | 
            +
                        p {
         | 
| 149 | 
            +
                            color: #aaa;
         | 
| 150 | 
            +
                        }
         | 
| 151 | 
            +
             | 
| 152 | 
            +
                        .status-message {
         | 
| 153 | 
            +
                            color: #aaa;
         | 
| 154 | 
            +
                        }
         | 
| 155 | 
            +
                    }
         | 
| 156 | 
            +
                </style>
         | 
| 157 | 
            +
            </head>
         | 
| 158 | 
            +
            <body>
         | 
| 159 | 
            +
                <div class="verification-container">
         | 
| 160 | 
            +
                    <div class="logo">TTS Arena</div>
         | 
| 161 | 
            +
                    <h1>Verification Required</h1>
         | 
| 162 | 
            +
                    <p>Please complete the verification below to access TTS Arena.</p>
         | 
| 163 | 
            +
                    
         | 
| 164 | 
            +
                    <div id="turnstile-form">
         | 
| 165 | 
            +
                        <div class="turnstile-container">
         | 
| 166 | 
            +
                            <div id="cf-turnstile" class="cf-turnstile"></div>
         | 
| 167 | 
            +
                        </div>
         | 
| 168 | 
            +
                        <button type="button" class="btn" id="submit-btn" disabled onclick="submitVerification()">
         | 
| 169 | 
            +
                            <span class="btn-text">Continue to TTS Arena</span>
         | 
| 170 | 
            +
                            <span class="loader"></span>
         | 
| 171 | 
            +
                        </button>
         | 
| 172 | 
            +
                        <div id="status-message" class="status-message"></div>
         | 
| 173 | 
            +
                    </div>
         | 
| 174 | 
            +
                </div>
         | 
| 175 | 
            +
             | 
| 176 | 
            +
                <script>
         | 
| 177 | 
            +
                    // Store the token and redirect URL
         | 
| 178 | 
            +
                    let turnstileToken = '';
         | 
| 179 | 
            +
                    const redirectUrl = '{{ redirect_url }}';
         | 
| 180 | 
            +
                    // Make sure the redirect URL uses HTTPS for HuggingFace Spaces
         | 
| 181 | 
            +
                    const secureRedirectUrl = redirectUrl.replace(/^http:\/\//i, 'https://');
         | 
| 182 | 
            +
                    const verifyEndpoint = '{{ url_for("verify_turnstile") }}';
         | 
| 183 | 
            +
                    const statusMessage = document.getElementById('status-message');
         | 
| 184 | 
            +
                    const submitButton = document.getElementById('submit-btn');
         | 
| 185 | 
            +
             | 
| 186 | 
            +
                    // Function to enable the button when verification is successful
         | 
| 187 | 
            +
                    function onTurnstileSuccess(token) {
         | 
| 188 | 
            +
                        turnstileToken = token;
         | 
| 189 | 
            +
                        submitButton.disabled = false;
         | 
| 190 | 
            +
                        console.log("Turnstile verification successful");
         | 
| 191 | 
            +
                    }
         | 
| 192 | 
            +
             | 
| 193 | 
            +
                    // Function to handle verification errors
         | 
| 194 | 
            +
                    function onTurnstileError(error) {
         | 
| 195 | 
            +
                        console.error("Turnstile error:", error);
         | 
| 196 | 
            +
                        statusMessage.textContent = "Verification error. Please try again.";
         | 
| 197 | 
            +
                        statusMessage.style.display = "block";
         | 
| 198 | 
            +
                    }
         | 
| 199 | 
            +
             | 
| 200 | 
            +
                    // Function to submit the verification via AJAX to handle iframe issues
         | 
| 201 | 
            +
                    function submitVerification() {
         | 
| 202 | 
            +
                        if (!turnstileToken) {
         | 
| 203 | 
            +
                            statusMessage.textContent = "Please complete the verification first.";
         | 
| 204 | 
            +
                            statusMessage.style.display = "block";
         | 
| 205 | 
            +
                            return;
         | 
| 206 | 
            +
                        }
         | 
| 207 | 
            +
             | 
| 208 | 
            +
                        // Show loading state
         | 
| 209 | 
            +
                        submitButton.classList.add('loading');
         | 
| 210 | 
            +
                        submitButton.disabled = true;
         | 
| 211 | 
            +
                        
         | 
| 212 | 
            +
                        // Create form data
         | 
| 213 | 
            +
                        const formData = new FormData();
         | 
| 214 | 
            +
                        formData.append('cf-turnstile-response', turnstileToken);
         | 
| 215 | 
            +
                        formData.append('redirect_url', secureRedirectUrl);
         | 
| 216 | 
            +
                        
         | 
| 217 | 
            +
                        // Send verification request
         | 
| 218 | 
            +
                        fetch(verifyEndpoint, {
         | 
| 219 | 
            +
                            method: 'POST',
         | 
| 220 | 
            +
                            body: formData,
         | 
| 221 | 
            +
                            credentials: 'same-origin', // Important for cookies
         | 
| 222 | 
            +
                            headers: {
         | 
| 223 | 
            +
                                'X-Requested-With': 'XMLHttpRequest',
         | 
| 224 | 
            +
                                'Accept': 'application/json'
         | 
| 225 | 
            +
                            }
         | 
| 226 | 
            +
                        })
         | 
| 227 | 
            +
                        .then(response => {
         | 
| 228 | 
            +
                            if (response.redirected) {
         | 
| 229 | 
            +
                                // Handle redirect from the response
         | 
| 230 | 
            +
                                window.location.href = response.url;
         | 
| 231 | 
            +
                            } else {
         | 
| 232 | 
            +
                                return response.json().then(data => {
         | 
| 233 | 
            +
                                    if (data.success) {
         | 
| 234 | 
            +
                                        // If we got a JSON success response, redirect
         | 
| 235 | 
            +
                                        window.location.href = secureRedirectUrl;
         | 
| 236 | 
            +
                                    } else {
         | 
| 237 | 
            +
                                        throw new Error("Verification failed");
         | 
| 238 | 
            +
                                    }
         | 
| 239 | 
            +
                                });
         | 
| 240 | 
            +
                            }
         | 
| 241 | 
            +
                        })
         | 
| 242 | 
            +
                        .catch(error => {
         | 
| 243 | 
            +
                            console.error("Verification error:", error);
         | 
| 244 | 
            +
                            statusMessage.textContent = "Verification failed. Please try again.";
         | 
| 245 | 
            +
                            statusMessage.style.display = "block";
         | 
| 246 | 
            +
                            submitButton.classList.remove('loading');
         | 
| 247 | 
            +
                            submitButton.disabled = false;
         | 
| 248 | 
            +
                            
         | 
| 249 | 
            +
                            // Reset Turnstile if something goes wrong
         | 
| 250 | 
            +
                            if (typeof turnstile !== 'undefined') {
         | 
| 251 | 
            +
                                turnstile.reset('#cf-turnstile');
         | 
| 252 | 
            +
                            }
         | 
| 253 | 
            +
                        });
         | 
| 254 | 
            +
                    }
         | 
| 255 | 
            +
             | 
| 256 | 
            +
                    // Initialize Turnstile when the page is loaded
         | 
| 257 | 
            +
                    document.addEventListener('DOMContentLoaded', function() {
         | 
| 258 | 
            +
                        // Check for Turnstile script readiness
         | 
| 259 | 
            +
                        function waitForTurnstile() {
         | 
| 260 | 
            +
                            if (typeof turnstile !== 'undefined') {
         | 
| 261 | 
            +
                                // Render the Turnstile widget
         | 
| 262 | 
            +
                                turnstile.render('#cf-turnstile', {
         | 
| 263 | 
            +
                                    sitekey: '{{ turnstile_site_key }}',
         | 
| 264 | 
            +
                                    callback: onTurnstileSuccess,
         | 
| 265 | 
            +
                                    'error-callback': onTurnstileError
         | 
| 266 | 
            +
                                });
         | 
| 267 | 
            +
                            } else {
         | 
| 268 | 
            +
                                // If not ready yet, wait and try again
         | 
| 269 | 
            +
                                setTimeout(waitForTurnstile, 100);
         | 
| 270 | 
            +
                            }
         | 
| 271 | 
            +
                        }
         | 
| 272 | 
            +
                        
         | 
| 273 | 
            +
                        waitForTurnstile();
         | 
| 274 | 
            +
                    });
         | 
| 275 | 
            +
                </script>
         | 
| 276 | 
            +
            </body>
         | 
| 277 | 
            +
            </html> 
         | 
    	
        tts.old.py
    ADDED
    
    | @@ -0,0 +1,117 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            # TODO: V2 of TTS Router
         | 
| 2 | 
            +
            # Currently just use current TTS router.
         | 
| 3 | 
            +
            from gradio_client import Client
         | 
| 4 | 
            +
            import os
         | 
| 5 | 
            +
            from dotenv import load_dotenv
         | 
| 6 | 
            +
            import fal_client
         | 
| 7 | 
            +
            import requests
         | 
| 8 | 
            +
            import time
         | 
| 9 | 
            +
            import io
         | 
| 10 | 
            +
            from pyht import Client as PyhtClient
         | 
| 11 | 
            +
            from pyht.client import TTSOptions
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            load_dotenv()
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            try:
         | 
| 16 | 
            +
                client = Client("TTS-AGI/tts-router", hf_token=os.getenv("HF_TOKEN"))
         | 
| 17 | 
            +
            except Exception as e:
         | 
| 18 | 
            +
                print(f"Error initializing client: {e}")
         | 
| 19 | 
            +
                client = None
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            model_mapping = {
         | 
| 22 | 
            +
                "eleven-multilingual-v2": "eleven",
         | 
| 23 | 
            +
                "playht-2.0": "playht",
         | 
| 24 | 
            +
                "styletts2": "styletts2",
         | 
| 25 | 
            +
                "kokoro-v1": "kokorov1",
         | 
| 26 | 
            +
                "cosyvoice-2.0": "cosyvoice",
         | 
| 27 | 
            +
                "playht-3.0-mini": "playht3",
         | 
| 28 | 
            +
                "papla-p1": "papla",
         | 
| 29 | 
            +
                "hume-octave": "hume",
         | 
| 30 | 
            +
            }
         | 
| 31 | 
            +
             | 
| 32 | 
            +
             | 
| 33 | 
            +
            def predict_csm(script):
         | 
| 34 | 
            +
                result = fal_client.subscribe(
         | 
| 35 | 
            +
                    "fal-ai/csm-1b",
         | 
| 36 | 
            +
                    arguments={
         | 
| 37 | 
            +
                        # "scene": [{
         | 
| 38 | 
            +
                        #     "text": "Hey how are you doing.",
         | 
| 39 | 
            +
                        #     "speaker_id": 0
         | 
| 40 | 
            +
                        # }, {
         | 
| 41 | 
            +
                        #     "text": "Pretty good, pretty good.",
         | 
| 42 | 
            +
                        #     "speaker_id": 1
         | 
| 43 | 
            +
                        # }, {
         | 
| 44 | 
            +
                        #     "text": "I'm great, so happy to be speaking to you.",
         | 
| 45 | 
            +
                        #     "speaker_id": 0
         | 
| 46 | 
            +
                        # }]
         | 
| 47 | 
            +
                        "scene": script
         | 
| 48 | 
            +
                    },
         | 
| 49 | 
            +
                    with_logs=True,
         | 
| 50 | 
            +
                )
         | 
| 51 | 
            +
                return requests.get(result["audio"]["url"]).content
         | 
| 52 | 
            +
             | 
| 53 | 
            +
             | 
| 54 | 
            +
            def predict_playdialog(script):
         | 
| 55 | 
            +
                # Initialize the PyHT client
         | 
| 56 | 
            +
                pyht_client = PyhtClient(
         | 
| 57 | 
            +
                    user_id=os.getenv("PLAY_USERID"),
         | 
| 58 | 
            +
                    api_key=os.getenv("PLAY_SECRETKEY"),
         | 
| 59 | 
            +
                )
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                # Define the voices
         | 
| 62 | 
            +
                voice_1 = "s3://voice-cloning-zero-shot/baf1ef41-36b6-428c-9bdf-50ba54682bd8/original/manifest.json"
         | 
| 63 | 
            +
                voice_2 = "s3://voice-cloning-zero-shot/e040bd1b-f190-4bdb-83f0-75ef85b18f84/original/manifest.json"
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                # Convert script format from CSM to PlayDialog format
         | 
| 66 | 
            +
                if isinstance(script, list):
         | 
| 67 | 
            +
                    # Process script in CSM format (list of dictionaries)
         | 
| 68 | 
            +
                    text = ""
         | 
| 69 | 
            +
                    for turn in script:
         | 
| 70 | 
            +
                        speaker_id = turn.get("speaker_id", 0)
         | 
| 71 | 
            +
                        prefix = "Host 1:" if speaker_id == 0 else "Host 2:"
         | 
| 72 | 
            +
                        text += f"{prefix} {turn['text']}\n"
         | 
| 73 | 
            +
                else:
         | 
| 74 | 
            +
                    # If it's already a string, use as is
         | 
| 75 | 
            +
                    text = script
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                # Set up TTSOptions
         | 
| 78 | 
            +
                options = TTSOptions(
         | 
| 79 | 
            +
                    voice=voice_1, voice_2=voice_2, turn_prefix="Host 1:", turn_prefix_2="Host 2:"
         | 
| 80 | 
            +
                )
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                # Generate audio using PlayDialog
         | 
| 83 | 
            +
                audio_chunks = []
         | 
| 84 | 
            +
                for chunk in pyht_client.tts(text, options, voice_engine="PlayDialog"):
         | 
| 85 | 
            +
                    audio_chunks.append(chunk)
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                # Combine all chunks into a single audio file
         | 
| 88 | 
            +
                return b"".join(audio_chunks)
         | 
| 89 | 
            +
             | 
| 90 | 
            +
             | 
| 91 | 
            +
            def predict_tts(text, model):
         | 
| 92 | 
            +
                global client
         | 
| 93 | 
            +
                # Exceptions: special models that shouldn't be passed to the router
         | 
| 94 | 
            +
                if model == "csm-1b":
         | 
| 95 | 
            +
                    return predict_csm(text)
         | 
| 96 | 
            +
                elif model == "playdialog-1.0":
         | 
| 97 | 
            +
                    return predict_playdialog(text)
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                if not model in model_mapping:
         | 
| 100 | 
            +
                    raise ValueError(f"Model {model} not found")
         | 
| 101 | 
            +
                result = client.predict(
         | 
| 102 | 
            +
                    text=text, model=model_mapping[model], api_name="/synthesize"
         | 
| 103 | 
            +
                )  # returns path to audio file
         | 
| 104 | 
            +
                return result
         | 
| 105 | 
            +
             | 
| 106 | 
            +
             | 
| 107 | 
            +
            if __name__ == "__main__":
         | 
| 108 | 
            +
                print("Predicting PlayDialog")
         | 
| 109 | 
            +
                print(
         | 
| 110 | 
            +
                    predict_playdialog(
         | 
| 111 | 
            +
                        [
         | 
| 112 | 
            +
                            {"text": "Hey how are you doing.", "speaker_id": 0},
         | 
| 113 | 
            +
                            {"text": "Pretty good, pretty good.", "speaker_id": 1},
         | 
| 114 | 
            +
                            {"text": "I'm great, so happy to be speaking to you.", "speaker_id": 0},
         | 
| 115 | 
            +
                        ]
         | 
| 116 | 
            +
                    )
         | 
| 117 | 
            +
                )
         | 
    	
        tts.py
    ADDED
    
    | @@ -0,0 +1,245 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            # TODO: V2 of TTS Router
         | 
| 2 | 
            +
            # Currently just use current TTS router.
         | 
| 3 | 
            +
            import os
         | 
| 4 | 
            +
            import json
         | 
| 5 | 
            +
            from dotenv import load_dotenv
         | 
| 6 | 
            +
            import fal_client
         | 
| 7 | 
            +
            import requests
         | 
| 8 | 
            +
            import time
         | 
| 9 | 
            +
            import io
         | 
| 10 | 
            +
            from pyht import Client as PyhtClient
         | 
| 11 | 
            +
            from pyht.client import TTSOptions
         | 
| 12 | 
            +
            import base64
         | 
| 13 | 
            +
            import tempfile
         | 
| 14 | 
            +
            import random
         | 
| 15 | 
            +
             | 
| 16 | 
            +
            load_dotenv()
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            ZEROGPU_TOKENS = os.getenv("ZEROGPU_TOKENS", "").split(",")
         | 
| 19 | 
            +
             | 
| 20 | 
            +
             | 
| 21 | 
            +
            def get_zerogpu_token():
         | 
| 22 | 
            +
                return random.choice(ZEROGPU_TOKENS)
         | 
| 23 | 
            +
             | 
| 24 | 
            +
             | 
| 25 | 
            +
            model_mapping = {
         | 
| 26 | 
            +
                "eleven-multilingual-v2": {
         | 
| 27 | 
            +
                    "provider": "elevenlabs",
         | 
| 28 | 
            +
                    "model": "eleven_multilingual_v2",
         | 
| 29 | 
            +
                },
         | 
| 30 | 
            +
                "eleven-turbo-v2.5": {
         | 
| 31 | 
            +
                    "provider": "elevenlabs",
         | 
| 32 | 
            +
                    "model": "eleven_turbo_v2_5",
         | 
| 33 | 
            +
                },
         | 
| 34 | 
            +
                "eleven-flash-v2.5": {
         | 
| 35 | 
            +
                    "provider": "elevenlabs",
         | 
| 36 | 
            +
                    "model": "eleven_flash_v2_5",
         | 
| 37 | 
            +
                },
         | 
| 38 | 
            +
                "cartesia-sonic-2": {
         | 
| 39 | 
            +
                    "provider": "cartesia",
         | 
| 40 | 
            +
                    "model": "sonic-2",
         | 
| 41 | 
            +
                },
         | 
| 42 | 
            +
                "spark-tts": {
         | 
| 43 | 
            +
                    "provider": "spark",
         | 
| 44 | 
            +
                    "model": "spark-tts",
         | 
| 45 | 
            +
                },
         | 
| 46 | 
            +
                "playht-2.0": {
         | 
| 47 | 
            +
                    "provider": "playht",
         | 
| 48 | 
            +
                    "model": "PlayHT2.0",
         | 
| 49 | 
            +
                },
         | 
| 50 | 
            +
                "styletts2": {
         | 
| 51 | 
            +
                    "provider": "styletts",
         | 
| 52 | 
            +
                    "model": "styletts2",
         | 
| 53 | 
            +
                },
         | 
| 54 | 
            +
                "kokoro-v1": {
         | 
| 55 | 
            +
                    "provider": "kokoro",
         | 
| 56 | 
            +
                    "model": "kokoro_v1",
         | 
| 57 | 
            +
                },
         | 
| 58 | 
            +
                "cosyvoice-2.0": {
         | 
| 59 | 
            +
                    "provider": "cosyvoice",
         | 
| 60 | 
            +
                    "model": "cosyvoice_2_0",
         | 
| 61 | 
            +
                },
         | 
| 62 | 
            +
                "papla-p1": {
         | 
| 63 | 
            +
                    "provider": "papla",
         | 
| 64 | 
            +
                    "model": "papla_p1",
         | 
| 65 | 
            +
                },
         | 
| 66 | 
            +
                "hume-octave": {
         | 
| 67 | 
            +
                    "provider": "hume",
         | 
| 68 | 
            +
                    "model": "octave",
         | 
| 69 | 
            +
                },
         | 
| 70 | 
            +
                "megatts3": {
         | 
| 71 | 
            +
                    "provider": "megatts3",
         | 
| 72 | 
            +
                    "model": "megatts3",
         | 
| 73 | 
            +
                },
         | 
| 74 | 
            +
            }
         | 
| 75 | 
            +
             | 
| 76 | 
            +
            url = "https://tts-agi-tts-router-v2.hf.space/tts"
         | 
| 77 | 
            +
            headers = {
         | 
| 78 | 
            +
                "accept": "application/json",
         | 
| 79 | 
            +
                "Content-Type": "application/json",
         | 
| 80 | 
            +
                "Authorization": f'Bearer {os.getenv("HF_TOKEN")}',
         | 
| 81 | 
            +
            }
         | 
| 82 | 
            +
            data = {"text": "string", "provider": "string", "model": "string"}
         | 
| 83 | 
            +
             | 
| 84 | 
            +
             | 
| 85 | 
            +
            def predict_csm(script):
         | 
| 86 | 
            +
                result = fal_client.subscribe(
         | 
| 87 | 
            +
                    "fal-ai/csm-1b",
         | 
| 88 | 
            +
                    arguments={
         | 
| 89 | 
            +
                        # "scene": [{
         | 
| 90 | 
            +
                        #     "text": "Hey how are you doing.",
         | 
| 91 | 
            +
                        #     "speaker_id": 0
         | 
| 92 | 
            +
                        # }, {
         | 
| 93 | 
            +
                        #     "text": "Pretty good, pretty good.",
         | 
| 94 | 
            +
                        #     "speaker_id": 1
         | 
| 95 | 
            +
                        # }, {
         | 
| 96 | 
            +
                        #     "text": "I'm great, so happy to be speaking to you.",
         | 
| 97 | 
            +
                        #     "speaker_id": 0
         | 
| 98 | 
            +
                        # }]
         | 
| 99 | 
            +
                        "scene": script
         | 
| 100 | 
            +
                    },
         | 
| 101 | 
            +
                    with_logs=True,
         | 
| 102 | 
            +
                )
         | 
| 103 | 
            +
                return requests.get(result["audio"]["url"]).content
         | 
| 104 | 
            +
             | 
| 105 | 
            +
             | 
| 106 | 
            +
            def predict_playdialog(script):
         | 
| 107 | 
            +
                # Initialize the PyHT client
         | 
| 108 | 
            +
                pyht_client = PyhtClient(
         | 
| 109 | 
            +
                    user_id=os.getenv("PLAY_USERID"),
         | 
| 110 | 
            +
                    api_key=os.getenv("PLAY_SECRETKEY"),
         | 
| 111 | 
            +
                )
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                # Define the voices
         | 
| 114 | 
            +
                voice_1 = "s3://voice-cloning-zero-shot/baf1ef41-36b6-428c-9bdf-50ba54682bd8/original/manifest.json"
         | 
| 115 | 
            +
                voice_2 = "s3://voice-cloning-zero-shot/e040bd1b-f190-4bdb-83f0-75ef85b18f84/original/manifest.json"
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                # Convert script format from CSM to PlayDialog format
         | 
| 118 | 
            +
                if isinstance(script, list):
         | 
| 119 | 
            +
                    # Process script in CSM format (list of dictionaries)
         | 
| 120 | 
            +
                    text = ""
         | 
| 121 | 
            +
                    for turn in script:
         | 
| 122 | 
            +
                        speaker_id = turn.get("speaker_id", 0)
         | 
| 123 | 
            +
                        prefix = "Host 1:" if speaker_id == 0 else "Host 2:"
         | 
| 124 | 
            +
                        text += f"{prefix} {turn['text']}\n"
         | 
| 125 | 
            +
                else:
         | 
| 126 | 
            +
                    # If it's already a string, use as is
         | 
| 127 | 
            +
                    text = script
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                # Set up TTSOptions
         | 
| 130 | 
            +
                options = TTSOptions(
         | 
| 131 | 
            +
                    voice=voice_1, voice_2=voice_2, turn_prefix="Host 1:", turn_prefix_2="Host 2:"
         | 
| 132 | 
            +
                )
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                # Generate audio using PlayDialog
         | 
| 135 | 
            +
                audio_chunks = []
         | 
| 136 | 
            +
                for chunk in pyht_client.tts(text, options, voice_engine="PlayDialog"):
         | 
| 137 | 
            +
                    audio_chunks.append(chunk)
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                # Combine all chunks into a single audio file
         | 
| 140 | 
            +
                return b"".join(audio_chunks)
         | 
| 141 | 
            +
             | 
| 142 | 
            +
             | 
| 143 | 
            +
            def predict_dia(script):
         | 
| 144 | 
            +
                # Convert script to the required format for Dia
         | 
| 145 | 
            +
                if isinstance(script, list):
         | 
| 146 | 
            +
                    # Convert from list of dictionaries to formatted string
         | 
| 147 | 
            +
                    formatted_text = ""
         | 
| 148 | 
            +
                    for turn in script:
         | 
| 149 | 
            +
                        speaker_id = turn.get("speaker_id", 0)
         | 
| 150 | 
            +
                        speaker_tag = "[S1]" if speaker_id == 0 else "[S2]"
         | 
| 151 | 
            +
                        text = turn.get("text", "").strip().replace("[S1]", "").replace("[S2]", "")
         | 
| 152 | 
            +
                        formatted_text += f"{speaker_tag} {text} "
         | 
| 153 | 
            +
                    text = formatted_text.strip()
         | 
| 154 | 
            +
                else:
         | 
| 155 | 
            +
                    # If it's already a string, use as is
         | 
| 156 | 
            +
                    text = script
         | 
| 157 | 
            +
                print(text)
         | 
| 158 | 
            +
                # Make a POST request to initiate the dialogue generation
         | 
| 159 | 
            +
                headers = {
         | 
| 160 | 
            +
                    # "Content-Type": "application/json",
         | 
| 161 | 
            +
                    "Authorization": f"Bearer {get_zerogpu_token()}"
         | 
| 162 | 
            +
                }
         | 
| 163 | 
            +
             | 
| 164 | 
            +
                response = requests.post(
         | 
| 165 | 
            +
                    "https://mrfakename-dia-1-6b.hf.space/gradio_api/call/generate_dialogue",
         | 
| 166 | 
            +
                    headers=headers,
         | 
| 167 | 
            +
                    json={"data": [text]},
         | 
| 168 | 
            +
                )
         | 
| 169 | 
            +
             | 
| 170 | 
            +
                # Extract the event ID from the response
         | 
| 171 | 
            +
                event_id = response.json()["event_id"]
         | 
| 172 | 
            +
             | 
| 173 | 
            +
                # Make a streaming request to get the generated dialogue
         | 
| 174 | 
            +
                stream_url = f"https://mrfakename-dia-1-6b.hf.space/gradio_api/call/generate_dialogue/{event_id}"
         | 
| 175 | 
            +
             | 
| 176 | 
            +
                # Use a streaming request to get the audio data
         | 
| 177 | 
            +
                with requests.get(stream_url, headers=headers, stream=True) as stream_response:
         | 
| 178 | 
            +
                    # Process the streaming response
         | 
| 179 | 
            +
                    for line in stream_response.iter_lines():
         | 
| 180 | 
            +
                        if line:
         | 
| 181 | 
            +
                            if line.startswith(b"data: ") and not line.startswith(b"data: null"):
         | 
| 182 | 
            +
                                audio_data = line[6:]
         | 
| 183 | 
            +
                                return requests.get(json.loads(audio_data)[0]["url"]).content
         | 
| 184 | 
            +
             | 
| 185 | 
            +
             | 
| 186 | 
            +
            def predict_tts(text, model):
         | 
| 187 | 
            +
                global client
         | 
| 188 | 
            +
                print(f"Predicting TTS for {model}")
         | 
| 189 | 
            +
                # Exceptions: special models that shouldn't be passed to the router
         | 
| 190 | 
            +
                if model == "csm-1b":
         | 
| 191 | 
            +
                    return predict_csm(text)
         | 
| 192 | 
            +
                elif model == "playdialog-1.0":
         | 
| 193 | 
            +
                    return predict_playdialog(text)
         | 
| 194 | 
            +
                elif model == "dia-1.6b":
         | 
| 195 | 
            +
                    return predict_dia(text)
         | 
| 196 | 
            +
             | 
| 197 | 
            +
                if not model in model_mapping:
         | 
| 198 | 
            +
                    raise ValueError(f"Model {model} not found")
         | 
| 199 | 
            +
             | 
| 200 | 
            +
                result = requests.post(
         | 
| 201 | 
            +
                    url,
         | 
| 202 | 
            +
                    headers=headers,
         | 
| 203 | 
            +
                    data=json.dumps(
         | 
| 204 | 
            +
                        {
         | 
| 205 | 
            +
                            "text": text,
         | 
| 206 | 
            +
                            "provider": model_mapping[model]["provider"],
         | 
| 207 | 
            +
                            "model": model_mapping[model]["model"],
         | 
| 208 | 
            +
                        }
         | 
| 209 | 
            +
                    ),
         | 
| 210 | 
            +
                )
         | 
| 211 | 
            +
             | 
| 212 | 
            +
                response_json = result.json()
         | 
| 213 | 
            +
             | 
| 214 | 
            +
                audio_data = response_json["audio_data"]  # base64 encoded audio data
         | 
| 215 | 
            +
                extension = response_json["extension"]
         | 
| 216 | 
            +
                # Decode the base64 audio data
         | 
| 217 | 
            +
                audio_bytes = base64.b64decode(audio_data)
         | 
| 218 | 
            +
             | 
| 219 | 
            +
                # Create a temporary file to store the audio data
         | 
| 220 | 
            +
                with tempfile.NamedTemporaryFile(delete=False, suffix=f".{extension}") as temp_file:
         | 
| 221 | 
            +
                    temp_file.write(audio_bytes)
         | 
| 222 | 
            +
                    temp_path = temp_file.name
         | 
| 223 | 
            +
             | 
| 224 | 
            +
                return temp_path
         | 
| 225 | 
            +
             | 
| 226 | 
            +
             | 
| 227 | 
            +
            if __name__ == "__main__":
         | 
| 228 | 
            +
                print(
         | 
| 229 | 
            +
                    predict_dia(
         | 
| 230 | 
            +
                        [
         | 
| 231 | 
            +
                            {"text": "Hello, how are you?", "speaker_id": 0},
         | 
| 232 | 
            +
                            {"text": "I'm great, thank you!", "speaker_id": 1},
         | 
| 233 | 
            +
                        ]
         | 
| 234 | 
            +
                    )
         | 
| 235 | 
            +
                )
         | 
| 236 | 
            +
                # print("Predicting PlayDialog")
         | 
| 237 | 
            +
                # print(
         | 
| 238 | 
            +
                #     predict_playdialog(
         | 
| 239 | 
            +
                #         [
         | 
| 240 | 
            +
                #             {"text": "Hey how are you doing.", "speaker_id": 0},
         | 
| 241 | 
            +
                #             {"text": "Pretty good, pretty good.", "speaker_id": 1},
         | 
| 242 | 
            +
                #             {"text": "I'm great, so happy to be speaking to you.", "speaker_id": 0},
         | 
| 243 | 
            +
                #         ]
         | 
| 244 | 
            +
                #     )
         | 
| 245 | 
            +
                # )
         | 
