first commit
This commit is contained in:
commit
7e580d75d0
176
.gitignore
vendored
Normal file
176
.gitignore
vendored
Normal file
@ -0,0 +1,176 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
19
bot/Dockerfile
Normal file
19
bot/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
# ベースイメージを指定
|
||||
FROM python:3.9-slim
|
||||
|
||||
# 作業ディレクトリを設定
|
||||
WORKDIR /bot
|
||||
|
||||
# 必要なパッケージをインストール
|
||||
RUN apt-get update && \
|
||||
apt-get install -y gcc libc-dev ffmpeg
|
||||
|
||||
# 依存関係をインストール
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# アプリケーションのコピー
|
||||
COPY . .
|
||||
|
||||
# アプリケーションの実行
|
||||
CMD ["python", "main.py"]
|
174
bot/app.py
Normal file
174
bot/app.py
Normal file
@ -0,0 +1,174 @@
|
||||
from flask import Flask, request, jsonify
|
||||
import logging
|
||||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
from discord import VoiceChannel
|
||||
import asyncio
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['DEBUG'] = True
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Discord Botインスタンスを保持するための変数
|
||||
bot_instance = None
|
||||
quiz_message_id = None
|
||||
|
||||
def create_response(error_message=None, success_message=None):
|
||||
if error_message:
|
||||
logger.error(error_message)
|
||||
return jsonify({"error": error_message}), 500
|
||||
if success_message:
|
||||
logger.info(success_message)
|
||||
return jsonify({"message": success_message}), 200
|
||||
|
||||
@app.route('/join', methods=['POST'])
|
||||
def join_via_api():
|
||||
if bot_instance is None:
|
||||
return create_response(error_message="BOTが初期化されていません")
|
||||
|
||||
channel_id = request.json.get('channel_id')
|
||||
logger.info(f"リクエストされたチャンネルID: {channel_id}")
|
||||
|
||||
if not channel_id:
|
||||
return create_response(error_message="チャンネルIDが必要です")
|
||||
|
||||
try:
|
||||
channel_id = int(channel_id)
|
||||
except ValueError:
|
||||
return create_response(error_message=f"無効なチャンネルIDです: {channel_id}")
|
||||
|
||||
channel = bot_instance.get_channel(channel_id)
|
||||
if channel:
|
||||
logger.info(f"チャンネルが見つかりました: {channel.name}")
|
||||
if isinstance(channel, VoiceChannel):
|
||||
bot_instance.loop.create_task(channel.connect())
|
||||
return create_response(success_message=f'{channel.name}に参加します')
|
||||
else:
|
||||
return create_response(error_message=f"ID {channel_id} はボイスチャンネルではありません")
|
||||
else:
|
||||
return create_response(error_message="指定されたチャンネルが見つかりません")
|
||||
|
||||
@app.route('/leave', methods=['POST'])
|
||||
def leave_via_api():
|
||||
if bot_instance is None:
|
||||
return create_response(error_message="BOTが初期化されていません")
|
||||
|
||||
if bot_instance.voice_clients:
|
||||
for vc in bot_instance.voice_clients:
|
||||
bot_instance.loop.create_task(vc.disconnect())
|
||||
return create_response(success_message="ボイスチャンネルから切断しました")
|
||||
return create_response(error_message="ボイスチャンネルに接続されていません")
|
||||
|
||||
@app.route('/play', methods=['POST'])
|
||||
def play_via_api():
|
||||
if bot_instance is None:
|
||||
return create_response(error_message="BOTが初期化されていません")
|
||||
|
||||
filename = request.json.get('filename')
|
||||
volume = request.json.get('volume', 100)
|
||||
try:
|
||||
volume = float(volume) / 100.0
|
||||
except ValueError:
|
||||
return create_response(error_message="無効な音量値です")
|
||||
|
||||
if bot_instance.voice_clients:
|
||||
for vc in bot_instance.voice_clients:
|
||||
audio_source = discord.FFmpegPCMAudio(f'bucket/{filename}', options=f"-filter:a 'volume={volume}'")
|
||||
vc.play(audio_source)
|
||||
return create_response(success_message=f'再生中: {filename}')
|
||||
return create_response(error_message="ボイスチャンネルに接続されていません")
|
||||
|
||||
@app.route('/start_quiz', methods=['POST'])
|
||||
def start_quiz():
|
||||
if bot_instance is None:
|
||||
return create_response(error_message="BOTが初期化されていません")
|
||||
|
||||
data = request.json
|
||||
intro_audio_name = data.get('intro_audio_name')
|
||||
volume = data.get('volume', 100)
|
||||
if not intro_audio_name:
|
||||
return create_response(error_message="イントロ音源名が必要です")
|
||||
|
||||
try:
|
||||
volume = float(volume) / 100.0
|
||||
except ValueError:
|
||||
return create_response(error_message="無効な音量値です")
|
||||
|
||||
if bot_instance.voice_clients:
|
||||
for vc in bot_instance.voice_clients:
|
||||
if not vc.is_connected():
|
||||
return create_response(error_message="ボイスチャンネルに接続されていません")
|
||||
|
||||
# クイズ回答用のメッセージをVCチャットに送信
|
||||
asyncio.run_coroutine_threadsafe(send_quiz_message(vc.channel, intro_audio_name, volume), bot_instance.loop)
|
||||
return create_response(success_message=f'イントロ音源再生準備中: {intro_audio_name}')
|
||||
return create_response(error_message="ボイスチャンネルに接続されていません")
|
||||
|
||||
@app.route('/stop_audio', methods=['POST'])
|
||||
def stop_audio():
|
||||
if bot_instance is None:
|
||||
return create_response(error_message="BOTが初期化されていません")
|
||||
|
||||
if bot_instance.voice_clients:
|
||||
for vc in bot_instance.voice_clients:
|
||||
if vc.is_playing():
|
||||
vc.stop()
|
||||
return create_response(success_message="再生を停止しました")
|
||||
return create_response(error_message="再生中の音源がありません")
|
||||
|
||||
async def send_quiz_message(channel, intro_audio_name, volume):
|
||||
global quiz_message_id
|
||||
button = discord.ui.Button(label="回答", custom_id="quiz_answer")
|
||||
view = discord.ui.View()
|
||||
view.add_item(button)
|
||||
message = await channel.send("クイズが開始されました!", view=view)
|
||||
quiz_message_id = message.id
|
||||
|
||||
# インターバルを設ける
|
||||
await asyncio.sleep(5) # 5秒間待機
|
||||
|
||||
# イントロ音源を再生
|
||||
for vc in bot_instance.voice_clients:
|
||||
if vc.channel == channel:
|
||||
audio_source = discord.FFmpegPCMAudio(f'bucket/{intro_audio_name}', options=f"-filter:a 'volume={volume}'")
|
||||
vc.play(audio_source)
|
||||
|
||||
await disable_button_after_timeout(message)
|
||||
|
||||
async def disable_button_after_timeout(message):
|
||||
await asyncio.sleep(20)
|
||||
view = discord.ui.View()
|
||||
for action_row in message.components:
|
||||
for item in action_row.children:
|
||||
item.disabled = True
|
||||
view.add_item(discord.ui.Button.from_component(item))
|
||||
await message.edit(view=view)
|
||||
await message.delete()
|
||||
|
||||
def run_app():
|
||||
app.run(host='0.0.0.0', port=80, use_reloader=False)
|
||||
|
||||
def set_bot_instance(bot):
|
||||
global bot_instance
|
||||
bot_instance = bot
|
||||
|
||||
@bot.event
|
||||
async def on_interaction(interaction):
|
||||
global quiz_message_id
|
||||
global bot_instance
|
||||
if interaction.data.get('custom_id') == "quiz_answer":
|
||||
if interaction.message.id == quiz_message_id:
|
||||
quiz_message_id = None
|
||||
await interaction.response.send_message(f"{interaction.user.mention}が回答ボタンを押しました!", ephemeral=False)
|
||||
if bot_instance.voice_clients:
|
||||
for vc in bot_instance.voice_clients:
|
||||
if vc.is_playing():
|
||||
vc.stop()
|
||||
view = discord.ui.View()
|
||||
for action_row in interaction.message.components:
|
||||
for item in action_row.children:
|
||||
item.disabled = True
|
||||
view.add_item(discord.ui.Button.from_component(item))
|
||||
await interaction.message.edit(view=view)
|
24
bot/bot.py
Normal file
24
bot/bot.py
Normal file
@ -0,0 +1,24 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.messages = True
|
||||
intents.guilds = True
|
||||
intents.voice_states = True
|
||||
intents.message_content = True
|
||||
intents.reactions = True
|
||||
bot = commands.Bot(command_prefix='!', intents=intents)
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
logger.info(f'{bot.user.name} がログインしました')
|
||||
|
||||
def run_bot(token):
|
||||
bot.run(token)
|
||||
|
||||
def get_bot():
|
||||
return bot
|
16
bot/main.py
Normal file
16
bot/main.py
Normal file
@ -0,0 +1,16 @@
|
||||
import os
|
||||
import threading
|
||||
from app import run_app, set_bot_instance
|
||||
from bot import run_bot, get_bot
|
||||
|
||||
if __name__ == '__main__':
|
||||
bot_instance = get_bot()
|
||||
set_bot_instance(bot_instance)
|
||||
|
||||
threading.Thread(target=run_app).start()
|
||||
|
||||
token = os.getenv('DISCORD_TOKEN')
|
||||
if not token:
|
||||
raise ValueError("Discord BOT トークンが設定されていません")
|
||||
|
||||
run_bot(token)
|
4
bot/requirements.txt
Normal file
4
bot/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
discord.py
|
||||
flask
|
||||
flask-cors
|
||||
pynacl
|
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@ -0,0 +1,18 @@
|
||||
services:
|
||||
bot:
|
||||
build: ./bot
|
||||
volumes:
|
||||
- ./bot:/bot
|
||||
- ./bucket:/bot/bucket
|
||||
environment:
|
||||
- DISCORD_TOKEN=MTIyNjIwMTA4ODg1NjgyMTg4MQ.GH0q-p.5A8k4UCGXK5UT6g4uCksFNHhAky9EvRQLuZckQ
|
||||
web:
|
||||
build: ./web
|
||||
volumes:
|
||||
- ./web:/web
|
||||
- ./bucket:/web/bucket
|
||||
- ./web/data:/web/data
|
||||
ports:
|
||||
- "5000:80"
|
||||
links:
|
||||
- bot
|
22
web/Dockerfile
Normal file
22
web/Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
# Pythonベースのイメージを使用
|
||||
FROM python:3.9-slim
|
||||
|
||||
# 作業ディレクトリを作成
|
||||
WORKDIR /web
|
||||
|
||||
# 必要なパッケージをインストール
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# アプリケーションコードをコピー
|
||||
COPY . .
|
||||
|
||||
# Flaskのホットリロードのための環境変数設定
|
||||
ENV FLASK_ENV=development
|
||||
ENV FLASK_APP=app.py
|
||||
|
||||
# Flaskサーバーの起動コマンド
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
# 標準出力と標準エラー出力を一つに統合
|
||||
CMD ["sh", "-c", "python app.py > /proc/1/fd/1 2>&1"]
|
343
web/app.py
Normal file
343
web/app.py
Normal file
@ -0,0 +1,343 @@
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
import secrets
|
||||
import random
|
||||
import requests
|
||||
from flask import Flask, session, redirect, url_for, g, request, render_template, jsonify, flash
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from db import get_db, close_connection, initialize_db, reset_active_sessions
|
||||
from auth import login_required, check_login, login, logout, create_account
|
||||
from handlers import handle_file_upload, delete_file, play_file, join_channel, leave_channel, index, get_files, get_quiz_master, update_quiz_master, add_quiz_master, delete_quiz_master, get_unique_keywords
|
||||
from init_account import create_initial_account
|
||||
from models import ActiveSession, QuizMaster
|
||||
from sqlalchemy.sql import func
|
||||
import csv
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['UPLOAD_FOLDER'] = 'bucket'
|
||||
app.config['SECRET_KEY'] = secrets.token_hex(16)
|
||||
app.config['DATABASE'] = '/web/data/database.db'
|
||||
app.config['BOT_API_URL'] = 'http://bot:80'
|
||||
app.config['SESSION_TIMEOUT_MINUTES'] = 30
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
drawn_quiz_ids = set()
|
||||
|
||||
if not os.path.exists(app.config['UPLOAD_FOLDER']):
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'])
|
||||
logger.info(f"{app.config['UPLOAD_FOLDER']}ディレクトリを作成しました")
|
||||
|
||||
with app.app_context():
|
||||
create_initial_account(app.config['DATABASE'])
|
||||
reset_active_sessions(logger)
|
||||
initialize_db(logger)
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db_connection(exception):
|
||||
close_connection(exception)
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
if 'session_id' not in session:
|
||||
session['session_id'] = secrets.token_hex(16)
|
||||
logger.info(f"新しいセッションIDが生成されました: {session['session_id']}")
|
||||
#else:
|
||||
#logger.info(f"既存のセッションIDが存在します: {session['session_id']}")
|
||||
|
||||
session.permanent = True
|
||||
app.permanent_session_lifetime = timedelta(minutes=app.config['SESSION_TIMEOUT_MINUTES'])
|
||||
session.modified = True
|
||||
|
||||
db = get_db()
|
||||
if session.get('logged_in'):
|
||||
active_session = db.query(ActiveSession).filter_by(session_id=session['session_id']).first()
|
||||
if active_session:
|
||||
active_session.last_active = func.now()
|
||||
db.commit()
|
||||
elif request.endpoint not in ('login_route', 'static'):
|
||||
return redirect(url_for('login_route'))
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login_route():
|
||||
return login()
|
||||
|
||||
@app.route('/logout', methods=['POST'])
|
||||
def logout_route():
|
||||
return logout()
|
||||
|
||||
@app.route('/create_account', methods=['POST'])
|
||||
def create_account_route():
|
||||
return create_account()
|
||||
|
||||
@app.route('/')
|
||||
@login_required
|
||||
def index_route():
|
||||
return index()
|
||||
|
||||
@app.route('/upload', methods=['POST'])
|
||||
@login_required
|
||||
def upload_file_route():
|
||||
return handle_file_upload()
|
||||
|
||||
@app.route('/uploaded_files', methods=['GET'])
|
||||
@login_required
|
||||
def uploaded_files_route():
|
||||
return render_template('files.html')
|
||||
|
||||
@app.route('/uploaded_files_list', methods=['GET'])
|
||||
@login_required
|
||||
def uploaded_files_list_route():
|
||||
files = os.listdir(app.config['UPLOAD_FOLDER'])
|
||||
return jsonify(files)
|
||||
|
||||
@app.route('/used_files_list', methods=['GET'])
|
||||
@login_required
|
||||
def used_files_list_route():
|
||||
db = get_db()
|
||||
used_files = db.query(QuizMaster.full_audio_name, QuizMaster.intro_audio_name).all()
|
||||
return jsonify([file for sublist in used_files for file in sublist])
|
||||
|
||||
@app.route('/delete/<filename>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_file_route(filename):
|
||||
return delete_file(filename)
|
||||
|
||||
@app.route('/play/<filename>', methods=['POST'])
|
||||
@login_required
|
||||
def play_file_route(filename):
|
||||
volume = request.json.get('volume', 100)
|
||||
return play_file(filename, volume)
|
||||
|
||||
@app.route('/stop_audio', methods=['POST'])
|
||||
@login_required
|
||||
def stop_audio_route():
|
||||
response = requests.post(f"{app.config['BOT_API_URL']}/stop_audio")
|
||||
return jsonify(response.json())
|
||||
|
||||
@app.route('/join', methods=['POST'])
|
||||
@login_required
|
||||
def join_channel_route():
|
||||
return join_channel()
|
||||
|
||||
@app.route('/leave', methods=['POST'])
|
||||
@login_required
|
||||
def leave_channel_route():
|
||||
return leave_channel()
|
||||
|
||||
@app.route('/files', methods=['GET'])
|
||||
@login_required
|
||||
def get_files_route():
|
||||
return get_files()
|
||||
|
||||
@app.route('/quiz_master_page', methods=['GET'])
|
||||
@login_required
|
||||
def quiz_master_page_route():
|
||||
return render_template('quiz_master.html')
|
||||
|
||||
@app.route('/quiz_master', methods=['GET'])
|
||||
@login_required
|
||||
def get_quiz_master_route():
|
||||
return get_quiz_master()
|
||||
|
||||
@app.route('/unique_keywords', methods=['GET'])
|
||||
@login_required
|
||||
def unique_keywords_route():
|
||||
return get_unique_keywords()
|
||||
|
||||
@app.route('/update_quiz_master', methods=['POST'])
|
||||
@login_required
|
||||
def update_quiz_master_route():
|
||||
return update_quiz_master()
|
||||
|
||||
@app.route('/add_quiz_master', methods=['POST'])
|
||||
@login_required
|
||||
def add_quiz_master_route():
|
||||
return add_quiz_master()
|
||||
|
||||
@app.route('/delete_quiz_master', methods=['POST'])
|
||||
@login_required
|
||||
def delete_quiz_master_route():
|
||||
return delete_quiz_master()
|
||||
|
||||
@app.route('/random_quiz', methods=['POST'])
|
||||
@login_required
|
||||
def random_quiz_route():
|
||||
data = request.json
|
||||
keyword = data.get('keyword')
|
||||
db = get_db()
|
||||
quiz_master_data = db.query(QuizMaster).filter(
|
||||
(QuizMaster.keyword1 == keyword) |
|
||||
(QuizMaster.keyword2 == keyword) |
|
||||
(QuizMaster.keyword3 == keyword)
|
||||
).all()
|
||||
|
||||
if not quiz_master_data:
|
||||
return jsonify({'error': '指定されたキーワードでクイズマスターが見つかりません'}), 404
|
||||
|
||||
available_quizzes = [quiz for quiz in quiz_master_data if quiz.id not in drawn_quiz_ids]
|
||||
|
||||
if not available_quizzes:
|
||||
return jsonify({'error': '指定されたキーワードで利用可能なクイズが見つかりません'}), 404
|
||||
|
||||
selected_quiz = random.choice(available_quizzes)
|
||||
drawn_quiz_ids.add(selected_quiz.id)
|
||||
|
||||
return jsonify({
|
||||
'id': selected_quiz.id,
|
||||
'title': selected_quiz.title,
|
||||
'full_audio_name': selected_quiz.full_audio_name,
|
||||
'intro_audio_name': selected_quiz.intro_audio_name,
|
||||
'keyword1': selected_quiz.keyword1,
|
||||
'keyword2': selected_quiz.keyword2,
|
||||
'keyword3': selected_quiz.keyword3
|
||||
})
|
||||
|
||||
@app.route('/reset_quiz', methods=['POST'])
|
||||
@login_required
|
||||
def reset_quiz_route():
|
||||
global drawn_quiz_ids
|
||||
drawn_quiz_ids = set()
|
||||
return jsonify({'message': 'クイズがリセットされました'})
|
||||
|
||||
@app.route('/start_quiz', methods=['POST'])
|
||||
@login_required
|
||||
def start_quiz_route():
|
||||
data = request.json
|
||||
quiz_id = data.get('quiz_id')
|
||||
intro_audio_name = data.get('intro_audio_name')
|
||||
volume = data.get('volume', 100)
|
||||
if not quiz_id or not intro_audio_name:
|
||||
return jsonify({'error': 'クイズIDとイントロ音源名が必要です'}), 400
|
||||
|
||||
response = requests.post(f"{app.config['BOT_API_URL']}/start_quiz", json={'quiz_id': quiz_id, 'intro_audio_name': intro_audio_name, 'volume': volume})
|
||||
if response.status_code == 200:
|
||||
return jsonify({'message': 'クイズを開始しました'})
|
||||
else:
|
||||
return jsonify({'error': 'クイズの開始に失敗しました'}), response.status_code
|
||||
|
||||
@app.route('/search_quiz', methods=['GET'])
|
||||
@login_required
|
||||
def search_quiz_route():
|
||||
title = request.args.get('title', '')
|
||||
db = get_db()
|
||||
results = db.query(QuizMaster).filter(QuizMaster.title.like(f'%{title}%')).all()
|
||||
quizzes = [{'id': quiz.id, 'title': quiz.title, 'full_audio_name': quiz.full_audio_name, 'intro_audio_name': quiz.intro_audio_name,
|
||||
'keyword1': quiz.keyword1, 'keyword2': quiz.keyword2, 'keyword3': quiz.keyword3} for quiz in results]
|
||||
return jsonify(quizzes)
|
||||
|
||||
@app.route('/select_quiz', methods=['POST'])
|
||||
@login_required
|
||||
def select_quiz_route():
|
||||
data = request.json
|
||||
quiz_id = data.get('quiz_id')
|
||||
db = get_db()
|
||||
quiz = db.query(QuizMaster).filter_by(id=quiz_id).first()
|
||||
if not quiz:
|
||||
return jsonify({'error': '指定されたIDのクイズが見つかりません'}), 404
|
||||
selected_quiz = {
|
||||
'id': quiz.id,
|
||||
'title': quiz.title,
|
||||
'full_audio_name': quiz.full_audio_name,
|
||||
'intro_audio_name': quiz.intro_audio_name,
|
||||
'keyword1': quiz.keyword1,
|
||||
'keyword2': quiz.keyword2,
|
||||
'keyword3': quiz.keyword3
|
||||
}
|
||||
return jsonify(selected_quiz)
|
||||
|
||||
@app.route('/upload_quiz_master', methods=['POST'])
|
||||
@login_required
|
||||
def upload_quiz_master_route():
|
||||
global uploaded_file_path
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
logger.debug('No file part in the request')
|
||||
flash('No file part')
|
||||
return redirect(request.url)
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
logger.debug('No selected file')
|
||||
flash('No selected file')
|
||||
return redirect(request.url)
|
||||
if file and file.filename.endswith('.csv'):
|
||||
uploaded_file_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
|
||||
file.save(uploaded_file_path)
|
||||
logger.debug(f'File {file.filename} uploaded successfully to {uploaded_file_path}')
|
||||
flash('File uploaded successfully')
|
||||
return redirect(url_for('quiz_master_page_route'))
|
||||
logger.debug('Invalid file format')
|
||||
flash('Invalid file format')
|
||||
return redirect(request.url)
|
||||
except Exception as e:
|
||||
logger.error(f'Error during file upload: {str(e)}')
|
||||
flash('Error during file upload')
|
||||
return redirect(request.url)
|
||||
|
||||
@app.route('/import_quiz_master', methods=['POST'])
|
||||
@login_required
|
||||
def import_quiz_master_route():
|
||||
global uploaded_file_path
|
||||
try:
|
||||
if uploaded_file_path and os.path.exists(uploaded_file_path):
|
||||
logger.debug(f'Starting import of file {uploaded_file_path}')
|
||||
process_csv(uploaded_file_path)
|
||||
logger.debug('File imported successfully')
|
||||
flash('File imported successfully')
|
||||
uploaded_file_path = None # Reset after processing
|
||||
return redirect(url_for('quiz_master_page_route'))
|
||||
logger.debug('No file uploaded to import')
|
||||
flash('No file uploaded to import')
|
||||
return redirect(url_for('quiz_master_page_route'))
|
||||
except Exception as e:
|
||||
logger.error(f'Error during file import: {str(e)}')
|
||||
flash('Error during file import')
|
||||
return redirect(url_for('quiz_master_page_route'))
|
||||
|
||||
def process_csv(file_path):
|
||||
try:
|
||||
with open(file_path, newline='', encoding='utf-8') as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
db = get_db()
|
||||
for row in reader:
|
||||
# BOT格納状況が「格納済」のデータをスキップ
|
||||
if row['BOT格納状況'] == '格納済':
|
||||
logger.debug(f'Skipping row due to BOT格納状況 being 格納済: {row}')
|
||||
continue
|
||||
# 必要な情報がそろっているかチェック
|
||||
if not (row['曲名'] and row['フル音源名'] and row['イントロ音源名']):
|
||||
logger.debug(f'Skipping row due to missing information: {row}')
|
||||
continue
|
||||
# キーワード1からキーワード3のうちどれか1つが入力されているかチェック
|
||||
if not (row['キーワード1(歌唱)'] or row['キーワード2(種別)'] or row['キーワード3']):
|
||||
logger.debug(f'Skipping row due to missing keywords: {row}')
|
||||
continue
|
||||
# データベースに保存
|
||||
quiz_master = QuizMaster(
|
||||
title=row['曲名'],
|
||||
full_audio_name=f"{row['フル音源名']}.mp3",
|
||||
intro_audio_name=f"{row['イントロ音源名']}.mp3",
|
||||
keyword1=row['キーワード1(歌唱)'],
|
||||
keyword2=row['キーワード2(種別)'],
|
||||
keyword3=row['キーワード3']
|
||||
)
|
||||
db.add(quiz_master)
|
||||
logger.debug(f'Added row to database: {row}')
|
||||
db.commit()
|
||||
logger.debug('CSV file processed and committed to database')
|
||||
except Exception as e:
|
||||
logger.error(f'Error processing CSV file: {str(e)}')
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
app.run(debug=True, host='0.0.0.0', port=80)
|
||||
except ImportError as e:
|
||||
logger.error(f"ImportErrorが発生しました: {e}")
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
except Exception as e:
|
||||
logger.error(f"例外が発生しました: {e}")
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
|
64
web/auth.py
Normal file
64
web/auth.py
Normal file
@ -0,0 +1,64 @@
|
||||
from flask import request, session, redirect, url_for, render_template, g, jsonify
|
||||
import bcrypt
|
||||
import secrets
|
||||
from db import get_db
|
||||
from models import Account, ActiveSession
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def login_required(f):
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not check_login(session, logger):
|
||||
return redirect(url_for('login_route'))
|
||||
return f(*args, **kwargs)
|
||||
decorated_function.__name__ = f.__name__
|
||||
return decorated_function
|
||||
|
||||
def check_login(session, logger):
|
||||
if 'logged_in' not in session or not session['logged_in']:
|
||||
logger.info("セッションにログイン情報がありません")
|
||||
return False
|
||||
|
||||
db = get_db()
|
||||
active_session_count = db.query(ActiveSession).filter_by(session_id=session['session_id']).count()
|
||||
if active_session_count != 1:
|
||||
logger.info(f"セッションID {session['session_id']} のアクティブセッション数: {active_session_count}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
user_id = request.form['user_id']
|
||||
password = request.form['password'].encode('utf-8')
|
||||
db = get_db()
|
||||
account = db.query(Account).filter_by(user_id=user_id).first()
|
||||
if account and bcrypt.checkpw(password, account.password.encode('utf-8')):
|
||||
active_sessions = db.query(ActiveSession).count()
|
||||
if active_sessions >= 1:
|
||||
return "他のユーザーが既にログインしています。", 403
|
||||
session['logged_in'] = True
|
||||
new_session = ActiveSession(session_id=session['session_id'])
|
||||
db.add(new_session)
|
||||
db.commit()
|
||||
return redirect(url_for('index_route'))
|
||||
else:
|
||||
return "Invalid credentials", 403
|
||||
return render_template('login.html')
|
||||
|
||||
def logout():
|
||||
db = get_db()
|
||||
db.query(ActiveSession).filter_by(session_id=session['session_id']).delete()
|
||||
db.commit()
|
||||
session.clear()
|
||||
return redirect(url_for('login_route'))
|
||||
|
||||
def create_account():
|
||||
user_id = secrets.token_hex(8)
|
||||
password = secrets.token_hex(16)
|
||||
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
|
||||
db = get_db()
|
||||
new_account = Account(user_id=user_id, password=hashed_password.decode('utf-8'))
|
||||
db.add(new_account)
|
||||
db.commit()
|
||||
return jsonify({'message': 'アカウントが作成されました', 'user_id': user_id, 'password': password})
|
30
web/db.py
Normal file
30
web/db.py
Normal file
@ -0,0 +1,30 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from flask import g, current_app
|
||||
from models import Base, Account, ActiveSession, ChannelId, QuizMaster
|
||||
|
||||
def get_engine():
|
||||
return create_engine(f"sqlite:///{current_app.config['DATABASE']}")
|
||||
|
||||
def get_db():
|
||||
if 'db' not in g:
|
||||
engine = get_engine()
|
||||
g.db = scoped_session(sessionmaker(bind=engine))
|
||||
return g.db
|
||||
|
||||
def close_connection(exception):
|
||||
db = g.pop('db', None)
|
||||
if db is not None:
|
||||
db.remove()
|
||||
|
||||
def initialize_db(logger):
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(engine)
|
||||
logger.info("データベースの初期化が完了しました")
|
||||
|
||||
def reset_active_sessions(logger):
|
||||
db = get_db()
|
||||
db.query(ActiveSession).delete()
|
||||
db.commit()
|
||||
logger.info("アクティブセッションをリセットしました")
|
142
web/handlers.py
Normal file
142
web/handlers.py
Normal file
@ -0,0 +1,142 @@
|
||||
import os
|
||||
import urllib.parse
|
||||
from flask import request, jsonify, session, render_template, current_app
|
||||
import requests
|
||||
from db import get_db
|
||||
from models import ChannelId, QuizMaster
|
||||
|
||||
def handle_file_upload():
|
||||
if 'files' not in request.files:
|
||||
return jsonify({'error': 'ファイルが見つかりません'})
|
||||
files = request.files.getlist('files')
|
||||
if not files or files[0].filename == '':
|
||||
return jsonify({'error': '選択されたファイルがありません'})
|
||||
|
||||
for file in files:
|
||||
filename = file.filename
|
||||
file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename))
|
||||
|
||||
return jsonify({'message': 'ファイルがアップロードされました'})
|
||||
|
||||
def delete_file(filename):
|
||||
filename = urllib.parse.unquote(filename)
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return jsonify({'message': 'ファイルが削除されました'})
|
||||
return jsonify({'error': 'ファイルが見つかりません'})
|
||||
|
||||
def play_file(filename, volume):
|
||||
filename = urllib.parse.unquote(filename)
|
||||
response = requests.post(f"{current_app.config['BOT_API_URL']}/play", json={'filename': filename, 'volume': volume})
|
||||
return jsonify(response.json())
|
||||
|
||||
def join_channel():
|
||||
channel_id = request.json.get('channel_id')
|
||||
db = get_db()
|
||||
new_channel = ChannelId(channel_id=channel_id)
|
||||
db.add(new_channel)
|
||||
db.commit()
|
||||
response = requests.post(f"{current_app.config['BOT_API_URL']}/join", json={'channel_id': channel_id})
|
||||
return jsonify(response.json())
|
||||
|
||||
def leave_channel():
|
||||
response = requests.post(f"{current_app.config['BOT_API_URL']}/leave")
|
||||
return jsonify(response.json())
|
||||
|
||||
def index():
|
||||
files = os.listdir(current_app.config['UPLOAD_FOLDER'])
|
||||
db = get_db()
|
||||
last_channel_id = db.query(ChannelId).order_by(ChannelId.id.desc()).first()
|
||||
last_channel_id = last_channel_id.channel_id if last_channel_id else ""
|
||||
return render_template('index.html', files=files, last_channel_id=last_channel_id)
|
||||
|
||||
def get_files():
|
||||
files = os.listdir(current_app.config['UPLOAD_FOLDER'])
|
||||
return jsonify(files)
|
||||
|
||||
def get_quiz_master():
|
||||
db = get_db()
|
||||
quiz_master_data = db.query(QuizMaster).all()
|
||||
result = []
|
||||
for qm in quiz_master_data:
|
||||
result.append({
|
||||
'id': qm.id,
|
||||
'title': qm.title,
|
||||
'full_audio_name': qm.full_audio_name,
|
||||
'intro_audio_name': qm.intro_audio_name,
|
||||
'keyword1': qm.keyword1,
|
||||
'keyword2': qm.keyword2,
|
||||
'keyword3': qm.keyword3
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
def get_unique_keywords():
|
||||
db = get_db()
|
||||
keywords = set()
|
||||
quiz_master_data = db.query(QuizMaster).all()
|
||||
for qm in quiz_master_data:
|
||||
if qm.keyword1:
|
||||
keywords.add(qm.keyword1)
|
||||
if qm.keyword2:
|
||||
keywords.add(qm.keyword2)
|
||||
if qm.keyword3:
|
||||
keywords.add(qm.keyword3)
|
||||
sorted_keywords = sorted(keywords)
|
||||
return jsonify(sorted_keywords)
|
||||
|
||||
def update_quiz_master():
|
||||
data = request.json
|
||||
db = get_db()
|
||||
quiz_master = db.query(QuizMaster).filter_by(id=data['id']).first()
|
||||
if quiz_master:
|
||||
quiz_master.title = data['title']
|
||||
quiz_master.full_audio_name = data['full_audio_name']
|
||||
quiz_master.intro_audio_name = data['intro_audio_name']
|
||||
quiz_master.keyword1 = data['keyword1']
|
||||
quiz_master.keyword2 = data['keyword2']
|
||||
quiz_master.keyword3 = data['keyword3']
|
||||
db.commit()
|
||||
return jsonify({'message': '更新が完了しました'})
|
||||
|
||||
def add_quiz_master():
|
||||
data = request.json
|
||||
db = get_db()
|
||||
new_quiz_master = QuizMaster(
|
||||
title=data['title'],
|
||||
full_audio_name=data['full_audio_name'],
|
||||
intro_audio_name=data['intro_audio_name'],
|
||||
keyword1=data['keyword1'],
|
||||
keyword2=data['keyword2'],
|
||||
keyword3=data['keyword3']
|
||||
)
|
||||
db.add(new_quiz_master)
|
||||
db.commit()
|
||||
return jsonify({'message': '追加が完了しました'})
|
||||
|
||||
def delete_quiz_master():
|
||||
data = request.json
|
||||
db = get_db()
|
||||
db.query(QuizMaster).filter_by(id=data['id']).delete()
|
||||
db.commit()
|
||||
return jsonify({'message': '削除が完了しました'})
|
||||
|
||||
def draw_random_quiz():
|
||||
data = request.json
|
||||
keyword = data.get('keyword')
|
||||
selected_quiz = random_quiz(keyword)
|
||||
|
||||
if not selected_quiz:
|
||||
return jsonify({'error': '利用可能なクイズがありません'})
|
||||
|
||||
result = {
|
||||
'id': selected_quiz.id,
|
||||
'title': selected_quiz.title,
|
||||
'full_audio_name': selected_quiz.full_audio_name,
|
||||
'intro_audio_name': selected_quiz.intro_audio_name,
|
||||
'keyword1': selected_quiz.keyword1,
|
||||
'keyword2': selected_quiz.keyword2,
|
||||
'keyword3': selected_quiz.keyword3
|
||||
}
|
||||
|
||||
return jsonify(result)
|
28
web/init_account.py
Normal file
28
web/init_account.py
Normal file
@ -0,0 +1,28 @@
|
||||
import os
|
||||
import secrets
|
||||
import bcrypt
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from models import Base, Account, ActiveSession, ChannelId
|
||||
|
||||
def create_initial_account(db_path):
|
||||
if not os.path.exists(os.path.dirname(db_path)):
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
if session.query(Account).count() == 0:
|
||||
user_id = secrets.token_hex(8)
|
||||
password = secrets.token_hex(16)
|
||||
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
|
||||
new_account = Account(user_id=user_id, password=hashed_password.decode('utf-8'))
|
||||
session.add(new_account)
|
||||
session.commit()
|
||||
|
||||
with open("/web/data/initial_account.txt", "w") as f:
|
||||
f.write(f"User ID: {user_id}\nPassword: {password}\n")
|
||||
|
||||
session.close()
|
31
web/models.py
Normal file
31
web/models.py
Normal file
@ -0,0 +1,31 @@
|
||||
from sqlalchemy import Column, Integer, String, TIMESTAMP
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = 'accounts'
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(String)
|
||||
password = Column(String)
|
||||
|
||||
class ActiveSession(Base):
|
||||
__tablename__ = 'active_sessions'
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(String)
|
||||
last_active = Column(TIMESTAMP)
|
||||
|
||||
class ChannelId(Base):
|
||||
__tablename__ = 'channel_ids'
|
||||
id = Column(Integer, primary_key=True)
|
||||
channel_id = Column(String)
|
||||
|
||||
class QuizMaster(Base):
|
||||
__tablename__ = 'quiz_master'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
title = Column(String)
|
||||
full_audio_name = Column(String)
|
||||
intro_audio_name = Column(String)
|
||||
keyword1 = Column(String)
|
||||
keyword2 = Column(String)
|
||||
keyword3 = Column(String)
|
5
web/requirements.txt
Normal file
5
web/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
Flask
|
||||
werkzeug
|
||||
requests
|
||||
bcrypt
|
||||
sqlalchemy
|
136
web/templates/files.html
Normal file
136
web/templates/files.html
Normal file
@ -0,0 +1,136 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>アップロードされたファイル一覧</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
button {
|
||||
margin-right: 5px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
async function handleFileUpload(event) {
|
||||
event.preventDefault();
|
||||
const form = document.getElementById('uploadForm');
|
||||
const formData = new FormData(form);
|
||||
const progressBar = document.getElementById('uploadProgress');
|
||||
progressBar.style.display = 'block';
|
||||
progressBar.value = 0;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/upload', true);
|
||||
|
||||
xhr.upload.onprogress = function(event) {
|
||||
if (event.lengthComputable) {
|
||||
const percentComplete = (event.loaded / event.total) * 100;
|
||||
progressBar.value = percentComplete;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
progressBar.style.display = 'none';
|
||||
if (result.error) {
|
||||
document.getElementById('message').innerText = result.error;
|
||||
} else {
|
||||
updateFileList();
|
||||
}
|
||||
} else {
|
||||
document.getElementById('message').innerText = 'エラーが発生しました: ' + xhr.statusText;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
document.getElementById('message').innerText = 'エラーが発生しました: ネットワークエラー';
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
async function handleAction(event, endpoint) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
document.getElementById('message').innerText = result.message || result.error;
|
||||
if (!result.error) {
|
||||
updateFileList();
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'エラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFileList() {
|
||||
try {
|
||||
const response = await fetch('/files');
|
||||
const files = await response.json();
|
||||
const fileList = document.getElementById('fileList');
|
||||
fileList.innerHTML = '';
|
||||
files.forEach(file => {
|
||||
const encodedFile = encodeURIComponent(file);
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${file}</td>
|
||||
<td>
|
||||
<form onsubmit="handleAction(event, '/delete/${encodedFile}')">
|
||||
<button type="submit">削除</button>
|
||||
</form>
|
||||
<!--
|
||||
<form onsubmit="handleAction(event, '/play/${encodedFile}')">
|
||||
<button type="submit">再生</button>
|
||||
</form>
|
||||
-->
|
||||
</td>
|
||||
`;
|
||||
fileList.appendChild(tr);
|
||||
});
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'ファイルリストの更新中にエラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', updateFileList);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>アップロードされたファイル一覧</h1>
|
||||
<div id="message" style="color: red; margin-top: 20px;"></div>
|
||||
<button onclick="location.href='/'">トップページに戻る</button>
|
||||
<form id="uploadForm" onsubmit="handleFileUpload(event)">
|
||||
<label for="files">音源をアップロード:</label>
|
||||
<input type="file" name="files" id="files" multiple>
|
||||
<button type="submit">アップロード</button>
|
||||
</form>
|
||||
<progress id="uploadProgress" value="0" max="100" style="display:none; width: 100%;"></progress>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ファイル名</th>
|
||||
<th>アクション</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="fileList">
|
||||
<!-- ファイルリストはここに動的に挿入されます -->
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
307
web/templates/index.html
Normal file
307
web/templates/index.html
Normal file
@ -0,0 +1,307 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>イントロクイズBOT管理ページ</title>
|
||||
<style>
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
}
|
||||
.form-group input, .form-group select {
|
||||
width: calc(100% - 130px);
|
||||
}
|
||||
.form-group select {
|
||||
height: 100px;
|
||||
}
|
||||
.form-container, .table-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
.button-group {
|
||||
text-align: left;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse.
|
||||
}
|
||||
th, td {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
border: 1px solid #ddd;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
#randomQuizResult, #selectedQuizResult {
|
||||
display: none;
|
||||
}
|
||||
#message {
|
||||
font-size: 24px;
|
||||
color: red;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#searchResults div {
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border: 1px solid #ddd;
|
||||
margin-top: -1px;
|
||||
}
|
||||
#searchResults div:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
async function handleFormSubmit(event, formId, endpoint) {
|
||||
event.preventDefault();
|
||||
const form = document.getElementById(formId);
|
||||
const formData = new FormData(form);
|
||||
const payload = {};
|
||||
formData.forEach((value, key) => {
|
||||
payload[key] = value;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const result = await response.json();
|
||||
document.getElementById('message').innerText = result.message || result.error;
|
||||
if (result.user_id && result.password) {
|
||||
document.getElementById('accountInfo').innerText = `UserID: ${result.user_id}, Password: ${result.password}`;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'エラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(event, endpoint) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
document.getElementById('message').innerText = result.message || result.error;
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'エラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUniqueKeywords() {
|
||||
try {
|
||||
const response = await fetch('/unique_keywords');
|
||||
const keywords = await response.json();
|
||||
keywords.sort(); // ここでキーワードを五十音順にソート
|
||||
const keywordList = document.getElementById('uniqueKeywordsList');
|
||||
keywordList.innerHTML = '';
|
||||
keywords.forEach(keyword => {
|
||||
const option = document.createElement('option');
|
||||
option.value = keyword;
|
||||
option.textContent = keyword;
|
||||
keywordList.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'ユニークキーワードの更新中にエラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function drawRandomQuiz(event) {
|
||||
event.preventDefault();
|
||||
const keyword = document.getElementById('uniqueKeywordsList').value;
|
||||
if (!keyword) {
|
||||
document.getElementById('message').innerText = 'キーワードを選択してください';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/random_quiz', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ keyword })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
document.getElementById('message').innerText = result.error;
|
||||
document.getElementById('randomQuizResult').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('randomQuizResult').innerHTML = `
|
||||
<p>ID: ${result.id}</p>
|
||||
<p>タイトル: ${result.title}</p>
|
||||
<p>フル音源名: ${result.full_audio_name}</p>
|
||||
<p>イントロ音源名: ${result.intro_audio_name}</p>
|
||||
<p>キーワード1: ${result.keyword1}</p>
|
||||
<p>キーワード2: ${result.keyword2}</p>
|
||||
<p>キーワード3: ${result.keyword3}</p>
|
||||
<button onclick="startQuiz(${result.id}, '${result.intro_audio_name}')">クイズを開始</button>
|
||||
<button onclick="stopAudio()">再生停止</button>
|
||||
<button onclick="playFullAudio('${result.full_audio_name}')">フル音源を再生</button>
|
||||
`;
|
||||
document.getElementById('randomQuizResult').style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'クイズの抽選中にエラーが発生しました: ' + error.message;
|
||||
document.getElementById('randomQuizResult').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function startQuiz(quizId, introAudioName) {
|
||||
const volume = document.getElementById('quizVolume').value;
|
||||
try {
|
||||
const response = await fetch('/start_quiz', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ quiz_id: quizId, intro_audio_name: introAudioName, volume: volume })
|
||||
});
|
||||
const result = await response.json();
|
||||
document.getElementById('message').innerText = result.message || result.error;
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'クイズの開始中にエラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAudio() {
|
||||
try {
|
||||
const response = await fetch('/stop_audio', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
document.getElementById('message').innerText = result.message || result.error;
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = '再生停止中にエラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function playFullAudio(fullAudioName) {
|
||||
const volume = document.getElementById('fullAudioVolume').value;
|
||||
try {
|
||||
const encodedFilename = encodeURIComponent(fullAudioName);
|
||||
const response = await fetch(`/play/${encodedFilename}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ volume: volume })
|
||||
});
|
||||
const result = await response.json();
|
||||
document.getElementById('message').innerText = result.message || result.error;
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'フル音源の再生中にエラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetQuiz() {
|
||||
try {
|
||||
const response = await fetch('/reset_quiz', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
document.getElementById('message').innerText = result.message || result.error;
|
||||
document.getElementById('randomQuizResult').style.display = 'none';
|
||||
document.getElementById('selectedQuizResult').style.display = 'none';
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'クイズのリセット中にエラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchQuizByTitle(event) {
|
||||
const title = document.getElementById('searchTitle').value;
|
||||
if (!title) {
|
||||
document.getElementById('message').innerText = 'タイトルを入力してください';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/search_quiz?title=${encodeURIComponent(title)}`);
|
||||
const results = await response.json();
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
searchResults.innerHTML = '';
|
||||
results.forEach(result => {
|
||||
const resultDiv = document.createElement('div');
|
||||
resultDiv.innerHTML = `
|
||||
<p>ID: ${result.id}</p>
|
||||
<p>タイトル: ${result.title}</p>
|
||||
<p>フル音源名: ${result.full_audio_name}</p>
|
||||
<p>イントロ音源名: ${result.intro_audio_name}</p>
|
||||
<p>キーワード1: ${result.keyword1}</p>
|
||||
<p>キーワード2: ${result.keyword2}</p>
|
||||
<p>キーワード3: ${result.keyword3}</p>
|
||||
`;
|
||||
resultDiv.addEventListener('click', () => selectQuiz(result));
|
||||
searchResults.appendChild(resultDiv);
|
||||
});
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'クイズの検索中にエラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectQuiz(result) {
|
||||
document.getElementById('selectedQuizResult').innerHTML = `
|
||||
<p>ID: ${result.id}</p>
|
||||
<p>タイトル: ${result.title}</p>
|
||||
<p>フル音源名: ${result.full_audio_name}</p>
|
||||
<p>イントロ音源名: ${result.intro_audio_name}</p>
|
||||
<p>キーワード1: ${result.keyword1}</p>
|
||||
<p>キーワード2: ${result.keyword2}</p>
|
||||
<p>キーワード3: ${result.keyword3}</p>
|
||||
<button onclick="startQuiz(${result.id}, '${result.intro_audio_name}')">クイズを開始</button>
|
||||
<button onclick="stopAudio()">再生停止</button>
|
||||
<button onclick="playFullAudio('${result.full_audio_name}')">フル音源を再生</button>
|
||||
`;
|
||||
document.getElementById('selectedQuizResult').style.display = 'block';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateUniqueKeywords();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>イントロクイズBOT管理ページ</h1>
|
||||
<div id="message" style="color: red; margin-top: 20px;"></div>
|
||||
<button onclick="location.href='/quiz_master_page'">マスタデータ管理ページに移動</button>
|
||||
<button onclick="location.href='/uploaded_files'">ファイル管理ページに移動</button>
|
||||
<h2>ボイスチャンネル操作</h2>
|
||||
<form id="joinForm" onsubmit="handleFormSubmit(event, 'joinForm', '/join')">
|
||||
<label for="channel_id">チャンネルID:</label>
|
||||
<input type="text" name="channel_id" id="channel_id" required>
|
||||
<button type="submit">参加</button>
|
||||
</form>
|
||||
<form id="leaveForm" onsubmit="handleAction(event, '/leave')">
|
||||
<button type="submit">退出</button>
|
||||
</form>
|
||||
<div class="form-group">
|
||||
<label for="quizVolume">クイズ音量:</label>
|
||||
<input type="text" id="quizVolume" name="quizVolume" value="13">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fullAudioVolume">フル音源音量:</label>
|
||||
<input type="text" id="fullAudioVolume" name="fullAudioVolume" value="3">
|
||||
</div>
|
||||
<h2>クイズ出題</h2>
|
||||
<h4>グループランダム選択</h4>
|
||||
<select id="uniqueKeywordsList">
|
||||
<!-- ユニークキーワードはここに動的に挿入されます -->
|
||||
</select>
|
||||
<button onclick="drawRandomQuiz(event)">ランダムにクイズを抽選</button>
|
||||
<button onclick="resetQuiz()">クイズをリセット</button>
|
||||
<div id="randomQuizResult" style="color: green; margin-top: 20px;"></div>
|
||||
|
||||
<h4>タイトル検索</h4>
|
||||
<form id="searchForm" onsubmit="searchQuizByTitle(event)">
|
||||
<div class="form-group">
|
||||
<input type="text" id="searchTitle" name="searchTitle" oninput="searchQuizByTitle(event)" required>
|
||||
</div>
|
||||
</form>
|
||||
<div id="searchResults" style="color: green; margin-top: 20px;"></div>
|
||||
<div id="selectedQuizResult" style="color: green; margin-top: 20px;"></div>
|
||||
|
||||
<h2>ログアウト</h2>
|
||||
<form id="logoutForm" action="/logout" method="POST">
|
||||
<button type="submit">ログアウト</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
17
web/templates/login.html
Normal file
17
web/templates/login.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ログイン</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ログイン</h1>
|
||||
<form method="POST">
|
||||
<label for="user_id">ユーザーID:</label>
|
||||
<input type="text" id="user_id" name="user_id" required><br>
|
||||
<label for="password">パスワード:</label>
|
||||
<input type="password" id="password" name="password" required><br>
|
||||
<button type="submit">ログイン</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
273
web/templates/quiz_master.html
Normal file
273
web/templates/quiz_master.html
Normal file
@ -0,0 +1,273 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>クイズマスタデータ管理</title>
|
||||
<style>
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
}
|
||||
.form-group input, .form-group select {
|
||||
width: calc(100% - 130px);
|
||||
}
|
||||
.form-group select {
|
||||
height: 100px;
|
||||
}
|
||||
.form-container, .table-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
.button-group {
|
||||
text-align: left;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
border: 1px solid #ddd;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
let files = [];
|
||||
let usedFiles = [];
|
||||
|
||||
async function fetchFiles() {
|
||||
try {
|
||||
const response = await fetch('/uploaded_files_list');
|
||||
files = await response.json();
|
||||
await fetchUsedFiles();
|
||||
filterFiles();
|
||||
} catch (error) {
|
||||
console.error('Error fetching files:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsedFiles() {
|
||||
try {
|
||||
const response = await fetch('/used_files_list');
|
||||
usedFiles = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching used files:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function filterFiles() {
|
||||
const fullAudioInput = document.getElementById('full_audio_name_input').value.toLowerCase();
|
||||
|
||||
const fullAudioSelect = document.getElementById('full_audio_name_select');
|
||||
const introAudioSelect = document.getElementById('intro_audio_name_select');
|
||||
fullAudioSelect.innerHTML = '';
|
||||
introAudioSelect.innerHTML = '';
|
||||
|
||||
const filteredFiles = files.filter(file => file.toLowerCase().includes(fullAudioInput) && !usedFiles.includes(file));
|
||||
|
||||
filteredFiles.forEach(file => {
|
||||
const optionFull = document.createElement('option');
|
||||
optionFull.value = file;
|
||||
optionFull.text = file;
|
||||
fullAudioSelect.appendChild(optionFull);
|
||||
|
||||
const optionIntro = document.createElement('option');
|
||||
optionIntro.value = file;
|
||||
optionIntro.text = file;
|
||||
introAudioSelect.appendChild(optionIntro);
|
||||
});
|
||||
|
||||
// タイトルフィールドを更新(拡張子を削除)
|
||||
const title = fullAudioInput.split('.').slice(0, -1).join('.');
|
||||
document.getElementById('title').value = title;
|
||||
|
||||
// イントロ音源名を自動入力
|
||||
const extension = fullAudioInput.split('.').pop();
|
||||
const introAudioName = `${title}_intro.${extension}`;
|
||||
if (files.includes(introAudioName)) {
|
||||
document.getElementById('intro_audio_name_input').value = introAudioName;
|
||||
document.getElementById('intro_audio_name_hidden').value = introAudioName;
|
||||
}
|
||||
|
||||
// キーワード1を自動入力
|
||||
const keyword1 = fullAudioInput.split(' - ')[0];
|
||||
document.getElementById('keyword1').value = keyword1;
|
||||
}
|
||||
|
||||
function selectFile(inputId, selectId, hiddenId) {
|
||||
const select = document.getElementById(selectId);
|
||||
const input = document.getElementById(inputId);
|
||||
const hidden = document.getElementById(hiddenId);
|
||||
input.value = select.value;
|
||||
hidden.value = select.value;
|
||||
|
||||
// タイトルフィールドを更新(拡張子を削除)
|
||||
if (inputId === 'full_audio_name_input') {
|
||||
const title = select.value.split('.').slice(0, -1).join('.');
|
||||
document.getElementById('title').value = title;
|
||||
|
||||
// イントロ音源名を自動入力
|
||||
const extension = select.value.split('.').pop();
|
||||
const introAudioName = `${title}_intro.${extension}`;
|
||||
if (files.includes(introAudioName)) {
|
||||
document.getElementById('intro_audio_name_input').value = introAudioName;
|
||||
document.getElementById('intro_audio_name_hidden').value = introAudioName;
|
||||
}
|
||||
|
||||
// キーワード1を自動入力
|
||||
const keyword1 = select.value.split(' - ')[0];
|
||||
document.getElementById('keyword1').value = keyword1;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFormSubmit(event, endpoint) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
const payload = {};
|
||||
formData.forEach((value, key) => {
|
||||
payload[key] = value;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const result = await response.json();
|
||||
document.getElementById('message').innerText = result.message || result.error;
|
||||
if (!result.error) {
|
||||
form.reset();
|
||||
fetchQuizMasters();
|
||||
await fetchUsedFiles();
|
||||
filterFiles();
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('message').innerText = 'エラーが発生しました: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchQuizMasters() {
|
||||
try {
|
||||
const response = await fetch('/quiz_master');
|
||||
const quizMasters = await response.json();
|
||||
const quizMasterList = document.getElementById('quizMasterList');
|
||||
quizMasterList.innerHTML = '';
|
||||
quizMasters.forEach(qm => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${qm.title}</td>
|
||||
<td>${qm.full_audio_name}</td>
|
||||
<td>${qm.intro_audio_name}</td>
|
||||
<td>${qm.keyword1}</td>
|
||||
<td>${qm.keyword2}</td>
|
||||
<td>${qm.keyword3}</td>
|
||||
<td>
|
||||
<form onsubmit="handleFormSubmit(event, '/delete_quiz_master')">
|
||||
<input type="hidden" name="id" value="${qm.id}">
|
||||
<button type="submit">削除</button>
|
||||
</form>
|
||||
</td>
|
||||
`;
|
||||
quizMasterList.appendChild(tr);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching quiz masters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await fetchFiles();
|
||||
fetchQuizMasters();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="form-container">
|
||||
<h1>クイズマスタデータ管理</h1>
|
||||
<div id="message" style="color: red; margin-top: 20px;"></div>
|
||||
<button onclick="location.href='/'">トップページに戻る</button>
|
||||
<form id="addQuizMasterForm" onsubmit="handleFormSubmit(event, '/add_quiz_master')">
|
||||
<div class="form-group">
|
||||
<label for="title">タイトル:</label>
|
||||
<input type="text" name="title" id="title" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="full_audio_name_input">フル音源名:</label>
|
||||
<input type="text" id="full_audio_name_input" oninput="filterFiles()">
|
||||
<select id="full_audio_name_select" onchange="selectFile('full_audio_name_input', 'full_audio_name_select', 'full_audio_name_hidden')" size="5"></select>
|
||||
<input type="hidden" name="full_audio_name" id="full_audio_name_hidden" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="intro_audio_name_input">イントロ音源名:</label>
|
||||
<input type="text" id="intro_audio_name_input" oninput="filterFiles()">
|
||||
<select id="intro_audio_name_select" onchange="selectFile('intro_audio_name_input', 'intro_audio_name_select', 'intro_audio_name_hidden')" size="5"></select>
|
||||
<input type="hidden" name="intro_audio_name" id="intro_audio_name_hidden" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="keyword1">キーワード1:</label>
|
||||
<input type="text" name="keyword1" id="keyword1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="keyword2">キーワード2:</label>
|
||||
<input type="text" name="keyword2" id="keyword2">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="keyword3">キーワード3:</label>
|
||||
<input type="text" name="keyword3" id="keyword3">
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button type="submit">追加</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<h2>CSVアップロード</h2>
|
||||
<form action="/upload_quiz_master" method="post" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<label for="file">CSVファイル:</label>
|
||||
<input type="file" name="file" id="file" accept=".csv" required>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button type="submit">アップロード</button>
|
||||
</div>
|
||||
</form>
|
||||
<form action="/import_quiz_master" method="post">
|
||||
<div class="button-group">
|
||||
<button type="submit">インポート</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<h2>クイズマスタ一覧</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>タイトル</th>
|
||||
<th>フル音源名</th>
|
||||
<th>イントロ音源名</th>
|
||||
<th>キーワード1</th>
|
||||
<th>キーワード2</th>
|
||||
<th>キーワード3</th>
|
||||
<th>アクション</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="quizMasterList">
|
||||
<!-- クイズマスタ一覧はここに動的に挿入されます -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
Loading…
Reference in New Issue
Block a user