This commit is contained in:
2025-12-30 17:37:14 +03:00
commit c33c5fd674
66 changed files with 10282 additions and 0 deletions

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-slim
WORKDIR /app
# Copy package files
COPY package.json .
# Install dependencies
RUN npm install
# Copy source files
COPY . .
# Expose port
EXPOSE 5173
# Run dev server
CMD ["npm", "run", "dev"]

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anime Quiz Video Generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

18
frontend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "anime-quiz-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.6"
}
}

756
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,756 @@
<template>
<div class="app-container">
<!-- Navigation -->
<nav class="main-nav">
<button
class="nav-btn"
:class="{ active: currentPage === 'quiz' }"
@click="currentPage = 'quiz'"
>
Quiz Generator
</button>
<button
class="nav-btn"
:class="{ active: currentPage === 'media' }"
@click="currentPage = 'media'"
>
Media Manager
</button>
<button
class="nav-btn"
:class="{ active: currentPage === 'admin' }"
@click="currentPage = 'admin'"
>
Upload Files
</button>
</nav>
<!-- Quiz Generator Page -->
<div v-if="currentPage === 'quiz'" class="app-layout">
<!-- Left Sidebar - Selected Openings Summary -->
<aside class="sidebar">
<h3>Selected Openings</h3>
<div class="selected-summary">
<div v-if="allSelectedOpenings.length === 0" class="empty-state">
No openings selected
</div>
<div
v-for="(item, index) in allSelectedOpenings"
:key="index"
class="selected-item"
:class="'difficulty-' + item.difficulty"
>
<span class="item-number">{{ index + 1 }}</span>
<div class="item-info">
<div class="item-anime">{{ item.animeName }}</div>
<div class="item-op">{{ item.opNumber }}</div>
</div>
<button class="btn-remove-small" @click="removeFromSection(item.sectionIndex, item.openingIndex)">×</button>
</div>
</div>
<div class="summary-stats" v-if="allSelectedOpenings.length > 0">
<div>Total: {{ allSelectedOpenings.length }} openings</div>
<div class="difficulty-stats">
<span class="stat easy">{{ difficultyCount.easy }} Easy</span>
<span class="stat medium">{{ difficultyCount.medium }} Medium</span>
<span class="stat hard">{{ difficultyCount.hard }} Hard</span>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<h1>Anime Quiz Generator</h1>
<p class="subtitle">Generate "Guess the Anime Opening" videos</p>
<div v-if="healthStatus" class="status-indicator" :class="healthStatus.status === 'healthy' ? 'success' : 'error'">
<span>{{ healthStatus.status === 'healthy' ? 'Backend connected' : 'Backend issue' }}</span>
</div>
<!-- Global Settings -->
<div class="card settings-card">
<h2>Global Settings</h2>
<div class="settings-grid">
<div class="setting-group">
<label>Video Mode</label>
<div class="mode-selector">
<button class="mode-btn" :class="{ active: mode === 'shorts' }" @click="mode = 'shorts'">
<span class="icon">📱</span>
<span>Shorts</span>
</button>
<button class="mode-btn" :class="{ active: mode === 'full' }" @click="mode = 'full'">
<span class="icon">🖥</span>
<span>Full</span>
</button>
</div>
</div>
<div class="setting-group">
<label>Default Guess Duration (sec)</label>
<input type="number" v-model.number="defaultAudioDuration" min="1" max="15" step="1" />
</div>
<div class="setting-group">
<label>Default Start Time (sec)</label>
<input type="number" v-model.number="defaultStartTime" min="0" step="5" />
</div>
<div class="setting-group">
<label class="checkbox-label" style="display: flex;">
<input type="checkbox" v-model="defaultContinueAudio" />
<span style="margin-top: 1px; margin-left: -8px;">Continue audio after reveal</span>
</label>
</div>
<div class="setting-group" v-if="content.background_videos.length">
<label>Background Video</label>
<select v-model="backgroundVideo">
<option value="">Auto (random)</option>
<option v-for="bg in content.background_videos" :key="bg" :value="bg">{{ bg }}</option>
</select>
</div>
</div>
</div>
<!-- Sections -->
<div class="card sections-card">
<div class="sections-header">
<h2>Sections</h2>
<button class="btn-add-section" @click="addSection">+ Add Section</button>
</div>
<div v-if="sections.length === 0" class="empty-sections">
No sections yet. Add a section to start selecting openings.
</div>
<div v-for="(section, sIndex) in sections" :key="sIndex" class="section-item" :class="'section-' + section.difficulty">
<div class="section-header">
<div class="section-title">
<span class="section-badge" :class="section.difficulty">{{ section.difficulty.toUpperCase() }}</span>
<span>Section {{ sIndex + 1 }} ({{ section.openings.length }} openings)</span>
</div>
<div class="section-actions">
<button class="btn-settings" @click="openSectionSettings(sIndex)"> Settings</button>
<button class="btn-add" @click="openModal(sIndex)">+ Add Openings</button>
<button class="btn-remove" @click="removeSection(sIndex)">🗑</button>
</div>
</div>
<!-- Section Settings (collapsible) -->
<div v-if="section.showSettings" class="section-settings">
<div class="settings-row">
<div class="setting-group">
<label>Difficulty</label>
<select v-model="section.difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
<div class="setting-group">
<label>
<input type="checkbox" v-model="section.overrideDuration" />
Override Duration
</label>
<input
v-if="section.overrideDuration"
type="number"
v-model.number="section.audioDuration"
min="1" max="15"
/>
</div>
<div class="setting-group">
<label>
<input type="checkbox" v-model="section.overrideStartTime" />
Override Start Time
</label>
<input
v-if="section.overrideStartTime"
type="number"
v-model.number="section.startTime"
min="0"
/>
</div>
<div class="setting-group">
<label>
<input type="checkbox" v-model="section.overrideContinueAudio" />
Override Continue Audio
</label>
<select v-if="section.overrideContinueAudio" v-model="section.continueAudio">
<option :value="true">Yes</option>
<option :value="false">No</option>
</select>
</div>
</div>
</div>
<!-- Section Openings Preview -->
<div class="section-openings" v-if="section.openings.length > 0">
<div
v-for="(op, opIndex) in section.openings"
:key="opIndex"
class="opening-item"
>
<div class="opening-poster-thumb">
<img v-if="op.poster" :src="'/api/media/posters/' + op.poster" :alt="op.animeName" />
<div v-else class="no-poster-thumb">?</div>
</div>
<div class="opening-item-info">
<div class="opening-item-name">{{ op.animeName }}</div>
<div class="opening-item-op">{{ op.opNumber }}</div>
</div>
<select
class="poster-select"
:value="op.poster || ''"
@change="updatePoster(sIndex, opIndex, $event.target.value)"
>
<option value="">No poster</option>
<option v-for="poster in getPostersForAnime(op.animeName)" :key="poster" :value="poster">
{{ poster }}
</option>
<optgroup label="All posters" v-if="content.posters.length > 0">
<option v-for="poster in content.posters" :key="'all-' + poster" :value="poster">
{{ poster }}
</option>
</optgroup>
</select>
<button class="btn-remove-item" @click="removeOpening(sIndex, opIndex)">×</button>
</div>
</div>
</div>
</div>
<!-- Generate Button -->
<div class="card">
<button class="btn-generate" @click="generate" :disabled="generating || !canGenerate">
{{ generating ? 'Generating...' : 'Generate Video' }}
</button>
<div v-if="generating" class="progress-container">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<p class="progress-text">{{ progressMessage }}</p>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<!-- Result -->
<div v-if="result" class="card result-card">
<h3>Video Generated!</h3>
<video class="video-preview" :src="'/api' + result.video_url" controls></video>
<a class="btn-download" :href="'/api/download/' + result.filename" :download="result.filename">
Download Video
</a>
</div>
</main>
<!-- Modal for Opening Selection -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>Select Openings for Section {{ currentSectionIndex + 1 }}</h2>
<button class="modal-close" @click="closeModal">×</button>
</div>
<div class="modal-search">
<input type="text" v-model="searchQuery" placeholder="Search anime..." />
</div>
<div class="modal-body">
<div class="openings-grid">
<div
v-for="opening in filteredOpenings"
:key="opening.filename"
class="opening-card"
:class="{ selected: isOpeningSelected(opening.filename) }"
@click="toggleOpeningSelection(opening)"
>
<div class="card-poster">
<img v-if="opening.associatedPoster" :src="'/api/media/posters/' + opening.associatedPoster" :alt="opening.animeName" />
<div v-else class="no-poster">No Poster</div>
</div>
<div class="card-info">
<div class="card-anime">{{ opening.animeName }}</div>
<div class="card-op">{{ opening.opNumber }}</div>
<div class="card-song" v-if="opening.songName">{{ opening.songName }}</div>
</div>
<div class="card-check" v-if="isOpeningSelected(opening.filename)"></div>
<!-- Poster selector -->
<div class="poster-selector" v-if="isOpeningSelected(opening.filename) && opening.availablePosters.length > 1" @click.stop>
<select v-model="selectedPosters[opening.filename]" @change="updateOpeningPoster(opening.filename)">
<option v-for="poster in opening.availablePosters" :key="poster" :value="poster">
{{ poster }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<div class="selection-count">{{ tempSelectedOpenings.length }} selected</div>
<button class="btn-cancel" @click="closeModal">Cancel</button>
<button class="btn-confirm" @click="confirmSelection" :disabled="tempSelectedOpenings.length === 0">
Add to Section
</button>
</div>
</div>
</div>
</div>
<!-- Media Manager -->
<MediaManager v-if="currentPage === 'media'" />
<!-- Admin Page (Upload Files) -->
<AdminPage v-if="currentPage === 'admin'" />
</div>
</template>
<script>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import AdminPage from './components/AdminPage.vue'
import MediaManager from './components/MediaManager.vue'
const STORAGE_KEY = 'animeQuizSettings'
export default {
name: 'App',
components: {
AdminPage,
MediaManager
},
setup() {
// Navigation
const currentPage = ref('quiz')
// Load saved settings from localStorage
const loadSettings = () => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
return JSON.parse(saved)
}
} catch (e) {
console.error('Failed to load settings:', e)
}
return null
}
const savedSettings = loadSettings()
// Global settings
const mode = ref(savedSettings?.mode || 'shorts')
const defaultAudioDuration = ref(savedSettings?.defaultAudioDuration || 5)
const defaultStartTime = ref(savedSettings?.defaultStartTime || 30)
const defaultContinueAudio = ref(savedSettings?.defaultContinueAudio || false)
const backgroundVideo = ref(savedSettings?.backgroundVideo || '')
// State
const generating = ref(false)
const progress = ref(0)
const progressMessage = ref('')
const error = ref('')
const result = ref(null)
const healthStatus = ref(null)
// Content from backend
const content = reactive({
audio_files: [],
background_videos: [],
posters: []
})
// Sections
const sections = ref(savedSettings?.sections || [])
// Save settings to localStorage
const saveSettings = () => {
try {
const settings = {
mode: mode.value,
defaultAudioDuration: defaultAudioDuration.value,
defaultStartTime: defaultStartTime.value,
defaultContinueAudio: defaultContinueAudio.value,
backgroundVideo: backgroundVideo.value,
sections: sections.value
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
} catch (e) {
console.error('Failed to save settings:', e)
}
}
// Watch for changes and save
watch([mode, defaultAudioDuration, defaultStartTime, defaultContinueAudio, backgroundVideo], saveSettings)
watch(sections, saveSettings, { deep: true })
// Modal state
const showModal = ref(false)
const currentSectionIndex = ref(-1)
const searchQuery = ref('')
const tempSelectedOpenings = ref([])
const selectedPosters = reactive({})
// Parse audio filename: "anime name_opnum_song name.mp3"
const parseAudioFilename = (filename) => {
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '')
const parts = nameWithoutExt.split('_')
if (parts.length >= 2) {
return {
animeName: parts[0],
opNumber: parts[1] || 'OP',
songName: parts.slice(2).join(' ') || ''
}
}
return {
animeName: nameWithoutExt,
opNumber: 'OP',
songName: ''
}
}
// Find associated posters for an anime
const findAssociatedPosters = (animeName) => {
const normalizedAnimeName = animeName.toLowerCase()
return content.posters.filter(poster => {
const normalizedPoster = poster.toLowerCase()
return normalizedPoster.includes(normalizedAnimeName)
})
}
// Processed openings with parsed info
const processedOpenings = computed(() => {
return content.audio_files.map(filename => {
const parsed = parseAudioFilename(filename)
const availablePosters = findAssociatedPosters(parsed.animeName)
return {
filename,
animeName: parsed.animeName,
opNumber: parsed.opNumber,
songName: parsed.songName,
availablePosters,
associatedPoster: availablePosters[0] || null
}
})
})
// Filtered openings for search
const filteredOpenings = computed(() => {
if (!searchQuery.value) return processedOpenings.value
const query = searchQuery.value.toLowerCase()
return processedOpenings.value.filter(op =>
op.animeName.toLowerCase().includes(query) ||
op.songName.toLowerCase().includes(query)
)
})
// All selected openings across sections (for sidebar)
const allSelectedOpenings = computed(() => {
const result = []
sections.value.forEach((section, sIndex) => {
section.openings.forEach((op, opIndex) => {
result.push({
...op,
difficulty: section.difficulty,
sectionIndex: sIndex,
openingIndex: opIndex
})
})
})
return result
})
// Difficulty counts
const difficultyCount = computed(() => {
const counts = { easy: 0, medium: 0, hard: 0 }
allSelectedOpenings.value.forEach(op => {
counts[op.difficulty]++
})
return counts
})
// Can generate
const canGenerate = computed(() => {
return allSelectedOpenings.value.length > 0
})
// Section management
const getNextDifficulty = () => {
const count = sections.value.length
if (count === 0) return 'easy'
if (count === 1) return 'medium'
return 'hard'
}
const addSection = () => {
sections.value.push({
difficulty: getNextDifficulty(),
openings: [],
showSettings: false,
overrideDuration: false,
audioDuration: defaultAudioDuration.value,
overrideStartTime: false,
startTime: defaultStartTime.value,
overrideContinueAudio: false,
continueAudio: defaultContinueAudio.value
})
}
const removeSection = (index) => {
sections.value.splice(index, 1)
}
const openSectionSettings = (index) => {
sections.value[index].showSettings = !sections.value[index].showSettings
}
// Modal functions
const openModal = (sectionIndex) => {
currentSectionIndex.value = sectionIndex
tempSelectedOpenings.value = []
searchQuery.value = ''
showModal.value = true
}
const closeModal = () => {
showModal.value = false
currentSectionIndex.value = -1
tempSelectedOpenings.value = []
}
const isOpeningSelected = (filename) => {
return tempSelectedOpenings.value.some(op => op.filename === filename)
}
const toggleOpeningSelection = (opening) => {
const index = tempSelectedOpenings.value.findIndex(op => op.filename === opening.filename)
if (index >= 0) {
tempSelectedOpenings.value.splice(index, 1)
delete selectedPosters[opening.filename]
} else {
tempSelectedOpenings.value.push({ ...opening })
if (opening.associatedPoster) {
selectedPosters[opening.filename] = opening.associatedPoster
}
}
}
const updateOpeningPoster = (filename) => {
const opening = tempSelectedOpenings.value.find(op => op.filename === filename)
if (opening) {
opening.associatedPoster = selectedPosters[filename]
}
}
const confirmSelection = () => {
const section = sections.value[currentSectionIndex.value]
tempSelectedOpenings.value.forEach(op => {
// Check if already in section
if (!section.openings.some(existing => existing.filename === op.filename)) {
section.openings.push({
filename: op.filename,
animeName: op.animeName,
opNumber: op.opNumber,
songName: op.songName,
poster: selectedPosters[op.filename] || op.associatedPoster
})
}
})
closeModal()
}
const removeOpening = (sectionIndex, openingIndex) => {
sections.value[sectionIndex].openings.splice(openingIndex, 1)
}
const removeFromSection = (sectionIndex, openingIndex) => {
sections.value[sectionIndex].openings.splice(openingIndex, 1)
}
// Get posters associated with anime name
const getPostersForAnime = (animeName) => {
const normalizedName = animeName.toLowerCase()
return content.posters.filter(poster =>
poster.toLowerCase().includes(normalizedName)
)
}
// Update poster for an opening in a section
const updatePoster = (sectionIndex, openingIndex, poster) => {
sections.value[sectionIndex].openings[openingIndex].poster = poster || null
}
// Validate saved settings against available content
const validateSettings = () => {
// Check background video
if (backgroundVideo.value && !content.background_videos.includes(backgroundVideo.value)) {
backgroundVideo.value = ''
}
// Check sections openings
sections.value.forEach(section => {
// Filter out openings with non-existent audio files
section.openings = section.openings.filter(op =>
content.audio_files.includes(op.filename)
)
// Clear invalid posters
section.openings.forEach(op => {
if (op.poster && !content.posters.includes(op.poster)) {
op.poster = null
}
})
})
// Remove empty sections
sections.value = sections.value.filter(section => section.openings.length > 0)
}
// API functions
const fetchContent = async () => {
try {
const response = await fetch('/api/content')
const data = await response.json()
content.audio_files = data.audio_files
content.background_videos = data.background_videos
content.posters = data.posters
// Validate saved settings after content is loaded
validateSettings()
} catch (e) {
console.error('Failed to fetch content:', e)
}
}
const checkHealth = async () => {
try {
const response = await fetch('/api/health')
healthStatus.value = await response.json()
} catch (e) {
healthStatus.value = { status: 'error', ffmpeg: false }
}
}
const generate = async () => {
error.value = ''
result.value = null
generating.value = true
progress.value = 10
progressMessage.value = 'Preparing video generation...'
try {
// Build questions from sections
const questions = []
sections.value.forEach(section => {
const audioDuration = section.overrideDuration ? section.audioDuration : defaultAudioDuration.value
const startTime = section.overrideStartTime ? section.startTime : defaultStartTime.value
const continueAudio = section.overrideContinueAudio ? section.continueAudio : defaultContinueAudio.value
section.openings.forEach(op => {
questions.push({
anime: op.animeName,
opening_file: op.filename,
start_time: startTime,
difficulty: section.difficulty,
poster: op.poster || null,
// Per-question settings (need backend update for this)
audio_duration: audioDuration,
continue_audio: continueAudio
})
})
})
const payload = {
mode: mode.value,
questions,
audio_duration: defaultAudioDuration.value,
background_video: backgroundVideo.value || null,
continue_audio: defaultContinueAudio.value
}
progress.value = 30
progressMessage.value = 'Generating video (this may take a few minutes)...'
const response = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await response.json()
if (data.success) {
progress.value = 100
progressMessage.value = 'Video generated successfully!'
result.value = data
} else {
throw new Error(data.error || 'Generation failed')
}
} catch (e) {
error.value = e.message
} finally {
generating.value = false
}
}
onMounted(() => {
checkHealth()
fetchContent()
})
return {
// Navigation
currentPage,
// Global settings
mode,
defaultAudioDuration,
defaultStartTime,
defaultContinueAudio,
backgroundVideo,
// State
generating,
progress,
progressMessage,
error,
result,
healthStatus,
content,
// Sections
sections,
addSection,
removeSection,
openSectionSettings,
// Modal
showModal,
currentSectionIndex,
searchQuery,
tempSelectedOpenings,
selectedPosters,
filteredOpenings,
openModal,
closeModal,
isOpeningSelected,
toggleOpeningSelection,
updateOpeningPoster,
confirmSelection,
removeOpening,
removeFromSection,
getPostersForAnime,
updatePoster,
// Computed
allSelectedOpenings,
difficultyCount,
canGenerate,
// Actions
generate
}
}
}
</script>
<style>
/* Import base styles then add component-specific */
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,921 @@
<template>
<div class="manager-page">
<h1>Backgrounds Manager</h1>
<p class="subtitle">Manage background videos for different difficulties</p>
<!-- Header Actions -->
<div class="header-actions">
<div class="filters">
<input
type="text"
v-model="searchQuery"
placeholder="Search by name..."
class="search-input"
/>
<select v-model="filterDifficulty" class="filter-select">
<option value="">All Difficulties</option>
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
<button class="btn-primary" @click="openCreateModal">+ Add Background</button>
</div>
<!-- Stats -->
<div class="stats-bar">
<div class="stat-item easy">
<span class="stat-value">{{ countByDifficulty('easy') }}</span>
<span class="stat-label">Easy</span>
</div>
<div class="stat-item medium">
<span class="stat-value">{{ countByDifficulty('medium') }}</span>
<span class="stat-label">Medium</span>
</div>
<div class="stat-item hard">
<span class="stat-value">{{ countByDifficulty('hard') }}</span>
<span class="stat-label">Hard</span>
</div>
<div class="stat-item total">
<span class="stat-value">{{ total }}</span>
<span class="stat-label">Total</span>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<span>Loading backgrounds...</span>
</div>
<!-- Empty State -->
<div v-else-if="backgrounds.length === 0" class="empty-state">
<span class="empty-icon">🎬</span>
<p>No backgrounds found</p>
<button class="btn-primary" @click="openCreateModal">Add your first background</button>
</div>
<!-- Backgrounds Grid -->
<div v-else class="backgrounds-grid">
<div
v-for="bg in backgrounds"
:key="bg.id"
class="background-card"
:class="'difficulty-' + bg.difficulty"
>
<div class="background-preview">
<span class="video-icon">🎬</span>
<span class="video-file">{{ bg.video_file }}</span>
</div>
<div class="background-info">
<h3 class="bg-name">{{ bg.name }}</h3>
<span class="difficulty-badge" :class="bg.difficulty">{{ bg.difficulty.toUpperCase() }}</span>
</div>
<div class="background-actions">
<button class="btn-icon" @click="openEditModal(bg)" title="Edit"></button>
<button class="btn-icon btn-delete" @click="confirmDelete(bg)" title="Delete">🗑</button>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>{{ editingBackground ? 'Edit Background' : 'Add Background' }}</h2>
<button class="modal-close" @click="closeModal">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input type="text" v-model="form.name" placeholder="e.g., Calm Ocean" />
</div>
<div class="form-group">
<label>Video File (S3 key) *</label>
<select v-model="form.video_file">
<option value="">Select video file...</option>
<option v-for="file in videoFiles" :key="file" :value="file">{{ file }}</option>
</select>
</div>
<div class="form-group">
<label>Difficulty *</label>
<div class="difficulty-selector">
<button
type="button"
class="diff-btn easy"
:class="{ active: form.difficulty === 'easy' }"
@click="form.difficulty = 'easy'"
>
Easy
</button>
<button
type="button"
class="diff-btn medium"
:class="{ active: form.difficulty === 'medium' }"
@click="form.difficulty = 'medium'"
>
Medium
</button>
<button
type="button"
class="diff-btn hard"
:class="{ active: form.difficulty === 'hard' }"
@click="form.difficulty = 'hard'"
>
Hard
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="closeModal">Cancel</button>
<button class="btn-primary" @click="saveBackground" :disabled="saving">
{{ saving ? 'Saving...' : (editingBackground ? 'Update' : 'Create') }}
</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="delete-modal">
<h3>Confirm Delete</h3>
<p>Are you sure you want to delete this background?</p>
<div class="delete-preview">
<strong>{{ deletingBackground?.name }}</strong>
<span class="difficulty-badge" :class="deletingBackground?.difficulty">
{{ deletingBackground?.difficulty?.toUpperCase() }}
</span>
</div>
<div class="modal-actions">
<button class="btn-cancel" @click="showDeleteModal = false">Cancel</button>
<button class="btn-delete-confirm" @click="executeDelete">Delete</button>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div class="toast-container">
<div v-for="(t, index) in toasts" :key="index" class="toast" :class="t.type">
{{ t.message }}
</div>
</div>
</div>
</template>
<script>
import { ref, reactive, computed, onMounted, watch } from 'vue'
export default {
name: 'BackgroundsManager',
setup() {
const backgrounds = ref([])
const allBackgrounds = ref([])
const total = ref(0)
const loading = ref(true)
const saving = ref(false)
const searchQuery = ref('')
const filterDifficulty = ref('')
const toasts = ref([])
// Modal states
const showModal = ref(false)
const showDeleteModal = ref(false)
const editingBackground = ref(null)
const deletingBackground = ref(null)
// Form data
const form = reactive({
name: '',
video_file: '',
difficulty: 'medium'
})
// Available files from S3
const videoFiles = ref([])
const countByDifficulty = (difficulty) => {
return allBackgrounds.value.filter(bg => bg.difficulty === difficulty).length
}
const showToast = (message, type = 'success') => {
const toast = { message, type }
toasts.value.push(toast)
setTimeout(() => {
const idx = toasts.value.indexOf(toast)
if (idx > -1) toasts.value.splice(idx, 1)
}, 3000)
}
const fetchBackgrounds = async () => {
loading.value = true
try {
const params = new URLSearchParams()
if (searchQuery.value) params.append('search', searchQuery.value)
if (filterDifficulty.value) params.append('difficulty', filterDifficulty.value)
const response = await fetch(`/api/api/backgrounds?${params}`)
const data = await response.json()
backgrounds.value = data.backgrounds
total.value = data.total
// Also fetch all for stats
const allResponse = await fetch('/api/api/backgrounds')
const allData = await allResponse.json()
allBackgrounds.value = allData.backgrounds
} catch (e) {
showToast('Failed to fetch backgrounds', 'error')
} finally {
loading.value = false
}
}
const fetchContent = async () => {
try {
const response = await fetch('/api/content')
const data = await response.json()
videoFiles.value = data.background_videos || []
} catch (e) {
console.error('Failed to fetch content:', e)
}
}
const openCreateModal = () => {
editingBackground.value = null
form.name = ''
form.video_file = ''
form.difficulty = 'medium'
showModal.value = true
}
const openEditModal = (bg) => {
editingBackground.value = bg
form.name = bg.name
form.video_file = bg.video_file
form.difficulty = bg.difficulty
showModal.value = true
}
const closeModal = () => {
showModal.value = false
editingBackground.value = null
}
const saveBackground = async () => {
if (!form.name || !form.video_file) {
showToast('Please fill all required fields', 'error')
return
}
saving.value = true
try {
const payload = {
name: form.name,
video_file: form.video_file,
difficulty: form.difficulty
}
if (editingBackground.value) {
// Update
await fetch(`/api/api/backgrounds/${editingBackground.value.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
showToast('Background updated')
} else {
// Create
await fetch('/api/api/backgrounds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
showToast('Background created')
}
closeModal()
await fetchBackgrounds()
} catch (e) {
showToast('Failed to save background', 'error')
} finally {
saving.value = false
}
}
const confirmDelete = (bg) => {
deletingBackground.value = bg
showDeleteModal.value = true
}
const executeDelete = async () => {
if (!deletingBackground.value) return
try {
await fetch(`/api/api/backgrounds/${deletingBackground.value.id}`, {
method: 'DELETE'
})
showToast('Background deleted')
showDeleteModal.value = false
deletingBackground.value = null
await fetchBackgrounds()
} catch (e) {
showToast('Failed to delete background', 'error')
}
}
// Search/filter with debounce
let searchTimeout
watch([searchQuery, filterDifficulty], () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(fetchBackgrounds, 300)
})
onMounted(() => {
fetchBackgrounds()
fetchContent()
})
return {
backgrounds,
total,
loading,
saving,
searchQuery,
filterDifficulty,
toasts,
showModal,
showDeleteModal,
editingBackground,
deletingBackground,
form,
videoFiles,
countByDifficulty,
openCreateModal,
openEditModal,
closeModal,
saveBackground,
confirmDelete,
executeDelete
}
}
}
</script>
<style scoped>
.manager-page {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.manager-page h1 {
text-align: center;
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 2rem;
}
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.filters {
display: flex;
gap: 1rem;
flex: 1;
flex-wrap: wrap;
}
.search-input {
flex: 1;
max-width: 300px;
padding: 0.75rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 1rem;
}
.search-input:focus {
outline: none;
border-color: #00d4ff;
}
.filter-select {
padding: 0.75rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 1rem;
min-width: 150px;
}
.filter-select:focus {
outline: none;
border-color: #00d4ff;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stats-bar {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.05);
padding: 1rem 2rem;
border-radius: 12px;
border: 2px solid transparent;
}
.stat-item.easy {
border-color: rgba(0, 255, 136, 0.3);
}
.stat-item.medium {
border-color: rgba(255, 170, 0, 0.3);
}
.stat-item.hard {
border-color: rgba(255, 68, 68, 0.3);
}
.stat-item.total {
border-color: rgba(0, 212, 255, 0.3);
}
.stat-item.easy .stat-value { color: #00ff88; }
.stat-item.medium .stat-value { color: #ffaa00; }
.stat-item.hard .stat-value { color: #ff4444; }
.stat-item.total .stat-value { color: #00d4ff; }
.stat-value {
font-size: 2rem;
font-weight: 700;
}
.stat-label {
color: #888;
font-size: 0.9rem;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem;
color: #888;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(0, 212, 255, 0.3);
border-top-color: #00d4ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
text-align: center;
padding: 4rem;
color: #666;
}
.empty-icon {
font-size: 4rem;
display: block;
margin-bottom: 1rem;
}
.empty-state p {
margin-bottom: 1.5rem;
}
/* Backgrounds Grid */
.backgrounds-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.background-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
overflow: hidden;
border: 2px solid transparent;
transition: all 0.3s ease;
}
.background-card:hover {
transform: translateY(-4px);
}
.background-card.difficulty-easy {
border-color: rgba(0, 255, 136, 0.3);
}
.background-card.difficulty-medium {
border-color: rgba(255, 170, 0, 0.3);
}
.background-card.difficulty-hard {
border-color: rgba(255, 68, 68, 0.3);
}
.background-card:hover.difficulty-easy {
border-color: #00ff88;
}
.background-card:hover.difficulty-medium {
border-color: #ffaa00;
}
.background-card:hover.difficulty-hard {
border-color: #ff4444;
}
.background-preview {
height: 120px;
background: rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.video-icon {
font-size: 2.5rem;
}
.video-file {
color: #666;
font-size: 0.75rem;
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.background-info {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.bg-name {
font-size: 1.1rem;
color: #fff;
margin: 0;
}
.difficulty-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.difficulty-badge.easy {
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
}
.difficulty-badge.medium {
background: rgba(255, 170, 0, 0.2);
color: #ffaa00;
}
.difficulty-badge.hard {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
}
.background-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0 1rem 1rem;
}
.btn-icon {
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: rgba(255, 255, 255, 0.1);
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.2);
}
.btn-icon.btn-delete {
background: rgba(255, 68, 68, 0.2);
}
.btn-icon.btn-delete:hover {
background: rgba(255, 68, 68, 0.3);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #1a1a2e;
border-radius: 16px;
width: 100%;
max-width: 500px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header h2 {
color: #00d4ff;
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 0.95rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #00d4ff;
}
.difficulty-selector {
display: flex;
gap: 0.75rem;
}
.diff-btn {
flex: 1;
padding: 0.75rem;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.2);
background: transparent;
color: #888;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.diff-btn.easy:hover,
.diff-btn.easy.active {
border-color: #00ff88;
color: #00ff88;
background: rgba(0, 255, 136, 0.1);
}
.diff-btn.medium:hover,
.diff-btn.medium.active {
border-color: #ffaa00;
color: #ffaa00;
background: rgba(255, 170, 0, 0.1);
}
.diff-btn.hard:hover,
.diff-btn.hard.active {
border-color: #ff4444;
color: #ff4444;
background: rgba(255, 68, 68, 0.1);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-cancel {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
cursor: pointer;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Delete Modal */
.delete-modal {
background: #1a1a2e;
border-radius: 16px;
padding: 2rem;
max-width: 400px;
width: 90%;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.delete-modal h3 {
color: #ff4444;
margin-bottom: 0.5rem;
}
.delete-modal p {
color: #888;
margin-bottom: 1.5rem;
}
.delete-preview {
background: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn-delete-confirm {
padding: 0.75rem 1.5rem;
background: #ff4444;
border: none;
border-radius: 8px;
color: #fff;
cursor: pointer;
font-weight: 500;
}
.btn-delete-confirm:hover {
background: #ff2222;
}
/* Toast */
.toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 1001;
}
.toast {
padding: 1rem 1.5rem;
border-radius: 8px;
color: #fff;
font-weight: 500;
animation: slideIn 0.3s ease;
}
.toast.success {
background: linear-gradient(90deg, #00d4ff, #00a8cc);
}
.toast.error {
background: linear-gradient(90deg, #ff4444, #cc2222);
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (max-width: 768px) {
.manager-page {
padding: 1rem;
}
.header-actions {
flex-direction: column;
}
.filters {
width: 100%;
}
.search-input {
max-width: 100%;
}
.backgrounds-grid {
grid-template-columns: 1fr;
}
.stats-bar {
gap: 0.75rem;
}
.stat-item {
padding: 0.75rem 1rem;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

5
frontend/src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

1034
frontend/src/style.css Normal file

File diff suppressed because it is too large Load Diff

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/videos': {
target: 'http://backend:8000',
changeOrigin: true
},
'/download': {
target: 'http://backend:8000',
changeOrigin: true
}
}
}
})